1 Commits

Author SHA1 Message Date
yincg 9231f09176 fix: 修复掉一些未处理的merger 冲突 2025-12-19 16:45:15 +08:00
72 changed files with 664 additions and 3930 deletions
-5
View File
@@ -45,11 +45,6 @@ packages/desktop/src-tauri/Cargo.lock
*.temp
.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/
+1 -3
View File
@@ -41,9 +41,7 @@
"website:build": "pnpm --filter @ai-assistant/website build",
"website:dev": "pnpm --filter @ai-assistant/website dev",
"website:preview": "pnpm --filter @ai-assistant/website preview",
"build:standalone": "bun run scripts/build-standalone.ts"
"website:preview": "pnpm --filter @ai-assistant/website preview"
},
"keywords": [
"ai",
+1 -2
View File
@@ -40,8 +40,7 @@
}
},
"scripts": {
"prebuild": "bun run scripts/generate-descriptions.ts",
"build": "tsc",
"build": "tsc && cp -r src/tools/descriptions dist/tools/",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
@@ -1,89 +0,0 @@
#!/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();
+2 -31
View File
@@ -6,7 +6,7 @@ import {
type Tool as AITool,
type LanguageModel,
} from 'ai';
import type { Tool, ToolResult, AgentConfig, ContentBlock, TokenUsageInfo } from '../types/index.js';
import type { Tool, ToolResult, AgentConfig, ContentBlock } from '../types/index.js';
import { buildZodSchema } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js';
import type {
@@ -95,7 +95,6 @@ export class AgentExecutor {
let fullResponse = '';
let steps = 0;
let usage: TokenUsageInfo | undefined;
// 工具调用时间追踪(用于计算持续时间)
const toolStartTimes = new Map<string, number>();
@@ -201,9 +200,7 @@ export class AgentExecutor {
}
}
const response = await result.response;
// 提取 usage 信息
usage = this.extractUsage(response);
await result.response;
} else {
// 非流式模式
const result = await generateText({
@@ -217,8 +214,6 @@ export class AgentExecutor {
fullResponse = result.text;
steps = result.steps.length;
// 提取 usage 信息
usage = this.extractUsage(result.response);
}
return {
@@ -226,7 +221,6 @@ export class AgentExecutor {
text: fullResponse,
steps,
sessionId: context.parentSessionId ?? 'standalone',
usage,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -422,27 +416,4 @@ export class AgentExecutor {
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,
};
}
}
+1 -3
View File
@@ -1,4 +1,4 @@
import type { ProviderType, TokenUsageInfo } from '../types/index.js';
import type { ProviderType } from '../types/index.js';
import type { PermissionAction, PermissionRule } from '../permission/types.js';
// 重新导出权限类型,方便外部使用
@@ -196,6 +196,4 @@ export interface AgentExecutionResult {
sessionId: string;
/** 错误信息 */
error?: string;
/** Token 使用统计 */
usage?: TokenUsageInfo;
}
@@ -11,7 +11,7 @@ import {
type Tool as AITool,
type LanguageModel,
} from 'ai';
import type { ToolResult, UserInput, ContentBlock, ChatResult, TokenUsageInfo } from '../types/index.js';
import type { ToolResult, UserInput, ContentBlock, ChatResult } from '../types/index.js';
import {
CompressionManager,
CompressionStatus,
@@ -188,18 +188,6 @@ export class AgentMessageHandler {
const response = await result.response;
responseMessages = response.messages as ModelMessage[];
// 提取 usage 信息
const usage = this.extractUsage(response);
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
usage,
};
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || abortSignal?.aborted)) {
onStream?.('\n[已取消]\n');
@@ -213,6 +201,14 @@ export class AgentMessageHandler {
}
throw error;
}
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
};
}
/**
@@ -324,39 +320,12 @@ export class AgentMessageHandler {
const fullResponse = result.text;
const responseMessages = result.response.messages as ModelMessage[];
// 提取 usage 信息
const usage = this.extractUsage(result.response);
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
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,
};
}
+2 -14
View File
@@ -4,9 +4,9 @@
*/
import type { LanguageModel } from 'ai';
import type { Tool, AgentConfig, UserInput, Message, ChatResult, TokenUsageInfo } from '../types/index.js';
import type { Tool, AgentConfig, UserInput, Message, ChatResult } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js';
import { SessionManager, tokenStatsManager } from '../session/index.js';
import { SessionManager } from '../session/index.js';
import {
CompressionManager,
type TokenUsage,
@@ -256,18 +256,6 @@ 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);
+2 -5
View File
@@ -29,8 +29,8 @@ export type {
DetailedCompressionResult,
} from './context/index.js';
// Session - 新的三层存储结构
export { SessionManager, tokenStatsManager, TokenStatsManager } from './session/index.js';
export type { SessionData, SessionSummary, ProjectMetadata, SessionTokenStats, ProjectStats } from './session/index.js';
export { SessionManager } from './session/index.js';
export type { SessionData, SessionSummary, ProjectMetadata } from './session/index.js';
// Session Storage API
export {
@@ -354,9 +354,6 @@ export type {
FileSearchOptions,
} from './file-index/index.js';
// RepoMap WASM 加载器(用于 standalone 构建)
export { setEmbeddedWasm, hasEmbeddedWasm } from './repomap/index.js';
// Constants - 统一存储路径
export {
APP_DIR_NAME,
+22 -68
View File
@@ -1,41 +1,29 @@
import { Parser, Language, type Node } from 'web-tree-sitter';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { hasEmbeddedWasm, getEmbeddedWasm } from '../repomap/tags/wasm-loader.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 解析后的命令结构
export interface ParsedCommand {
name: string; // 命令名,如 "git"
name: string; // 命令名,如 "git"
subcommand?: string; // 子命令,如 "push"
args: string[]; // 参数列表
text: string; // 原始命令文本
args: string[]; // 参数列表
text: string; // 原始命令文本
}
// 解析结果
export interface ParseResult {
commands: ParsedCommand[]; // 所有解析出的命令
commands: ParsedCommand[]; // 所有解析出的命令
success: boolean;
error?: string;
}
// Tree-sitter 类型
interface TreeSitterNode {
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 parserInstance: Parser | null = null;
let bashLanguage: Language | null = null;
let initPromise: Promise<void> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let TreeSitter: any = null;
/**
* 获取 wasm 文件路径
@@ -67,51 +55,19 @@ async function initParser(): Promise<void> {
initPromise = (async () => {
try {
// 动态导入 web-tree-sitter
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) => {
return getWasmPath(scriptName);
},
});
}
} else {
// 使用文件系统加载
await TreeSitter.Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
}
// 初始化 tree-sitter
await Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
// 创建解析器实例
parserInstance = new TreeSitter.Parser();
parserInstance = new Parser();
// 加载 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');
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmPath);
}
} else {
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmPath);
}
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await Language.load(bashWasmPath);
parserInstance.setLanguage(bashLanguage);
} catch (error) {
initPromise = null;
@@ -125,7 +81,7 @@ async function initParser(): Promise<void> {
/**
* 从语法树节点中提取命令信息
*/
function extractCommandFromNode(node: TreeSitterNode): ParsedCommand {
function extractCommandFromNode(node: Node): ParsedCommand {
const parts: string[] = [];
for (let i = 0; i < node.childCount; i++) {
@@ -145,10 +101,8 @@ function extractCommandFromNode(node: TreeSitterNode): ParsedCommand {
// 对于字符串类型,提取内部文本(去掉引号)
if (child.type === 'string' || child.type === 'raw_string') {
const text = child.text;
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
if ((text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))) {
parts.push(text.slice(1, -1));
} else {
parts.push(text);
@@ -184,8 +138,8 @@ function extractCommandFromNode(node: TreeSitterNode): ParsedCommand {
/**
* 递归查找所有命令节点
*/
function findCommandNodes(node: TreeSitterNode): TreeSitterNode[] {
const commands: TreeSitterNode[] = [];
function findCommandNodes(node: Node): Node[] {
const commands: Node[] = [];
if (node.type === 'command') {
commands.push(node);
@@ -10,6 +10,7 @@ export const anthropicProvider: ProviderInfo = {
name: 'Anthropic',
description: 'Claude AI models by Anthropic',
builtin: true,
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
models: [
{
id: 'claude-sonnet-4-20250514',
@@ -10,6 +10,7 @@ export const deepseekProvider: ProviderInfo = {
name: 'DeepSeek',
description: 'DeepSeek AI models',
builtin: true,
apiKeyEnvVar: 'DEEPSEEK_API_KEY',
models: [
{
id: 'deepseek-chat',
@@ -11,6 +11,7 @@ export const openaiProvider: ProviderInfo = {
name: 'OpenAI',
description: 'GPT models by OpenAI (also supports OpenAI-compatible APIs)',
builtin: true,
apiKeyEnvVar: 'OPENAI_API_KEY',
models: [
{
id: 'gpt-4o',
+2
View File
@@ -168,6 +168,7 @@ export class ProviderRegistry {
description: provider.info.description,
builtin: provider.info.builtin,
baseUrl: config?.baseUrl ?? provider.info.baseUrl,
apiKeyEnvVar: provider.info.apiKeyEnvVar,
models: provider.info.models,
allowCustomModels: provider.info.allowCustomModels ?? false,
config: {
@@ -199,6 +200,7 @@ export class ProviderRegistry {
description: definition.description,
builtin: false,
baseUrl: definition.baseUrl,
apiKeyEnvVar: definition.apiKeyEnvVar,
models: definition.models ?? [],
allowCustomModels: definition.allowCustomModels ?? true,
};
+8 -1
View File
@@ -48,6 +48,8 @@ export interface ProviderInfo {
builtin: boolean;
/** API 基础 URL */
baseUrl?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 可用模型列表 */
models: ModelInfo[];
/** 是否允许自定义模型 */
@@ -58,8 +60,10 @@ export interface ProviderInfo {
export interface ProviderConfig {
/** 提供商 ID */
id: string;
/** API Key */
/** API Key(直接存储,不推荐) */
apiKey?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 自定义 Base URL */
baseUrl?: string;
/** 是否启用 */
@@ -78,6 +82,8 @@ export interface CustomProviderDefinition {
description?: string;
/** API 基础 URL(必填) */
baseUrl: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 可用模型列表 */
models?: ModelInfo[];
/** 是否允许自定义模型 */
@@ -151,6 +157,7 @@ export interface ProviderDetail {
description?: string;
builtin: boolean;
baseUrl?: string;
apiKeyEnvVar?: string;
models: ModelInfo[];
allowCustomModels: boolean;
config?: {
-3
View File
@@ -11,9 +11,6 @@ export { RepoMap, createRepoMap } from './repomap.js';
// Tag 提取
export { TagExtractor } from './tags/index.js';
// WASM 加载器(用于 standalone 构建)
export { setEmbeddedWasm, hasEmbeddedWasm } from './tags/wasm-loader.js';
// PageRank 排序
export {
Graph,
+3 -33
View File
@@ -8,7 +8,6 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import type { Tag } from '../types.js';
import { getLanguageFromFilename } from '../types.js';
import { hasEmbeddedWasm, getEmbeddedWasm } from './wasm-loader.js';
// Tree-sitter 类型(web-tree-sitter
interface TreeSitterParser {
@@ -57,25 +56,8 @@ async function initTreeSitter(): Promise<void> {
try {
const TreeSitter = await import('web-tree-sitter');
// 检查是否有嵌入的 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();
}
} else {
// 使用默认行为(从 node_modules 加载)
await TreeSitter.Parser.init();
}
// Parser 是命名导出的类,init 是其静态方法
await TreeSitter.Parser.init();
ParserClass = TreeSitter.Parser;
treeSitterInitialized = true;
} catch (error) {
@@ -233,19 +215,7 @@ export class TagExtractor {
}
try {
// 首先检查是否有嵌入的语言 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;
}
}
// 回退到从文件系统加载
// 尝试加载 WASM 语言文件
const wasmPath = this.getWasmPath(lang);
const language = await ParserClass.Language.load(wasmPath);
this.languages.set(lang, language);
@@ -1,52 +0,0 @@
/**
* 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,10 +77,3 @@ export type { SessionData, SessionSummary, ProjectMetadata } from './manager.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,17 +6,6 @@
import * as storage from './storage/index.js';
import { getProjectId, isGitRepository } from './project.js';
/**
* 项目 Token 统计
*/
export interface ProjectStats {
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
sessionCount: number;
updatedAt: number;
}
/**
* 项目元数据
*/
@@ -25,7 +14,6 @@ export interface ProjectMetadata {
workdir: string;
createdAt: string;
isGitRepo: boolean;
stats?: ProjectStats;
}
/**
@@ -113,8 +113,6 @@ export class SessionStore {
messageCount: session.messages.length,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
},
};
+2 -2
View File
@@ -17,8 +17,8 @@ export {
// Session storage
export * as SessionStorage from './session.js';
export type { SessionInfo, SessionStats } from './session.js';
export { SessionInfoSchema, SessionStatsSchema, ChildSessionsTokensSchema } from './session.js';
export type { SessionInfo } from './session.js';
export { SessionInfoSchema } from './session.js';
// Message storage
export * as MessageStorage from './message.js';
+7 -28
View File
@@ -2,33 +2,6 @@ import { z } from 'zod';
import * as base from './base.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
*/
@@ -43,7 +16,13 @@ export const SessionInfoSchema = z.object({
title: z.string().optional(),
discoveredTools: z.array(z.string()).default([]),
// 统计信息
stats: SessionStatsSchema.optional(),
stats: z
.object({
messageCount: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
})
.optional(),
});
export type SessionInfo = z.infer<typeof SessionInfoSchema>;
@@ -1,287 +0,0 @@
/**
* 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();
+59 -11
View File
@@ -1,16 +1,64 @@
/**
* Tool Description Loader
*
* 从内联的 descriptions.generated.ts 加载工具描述
* 支持 Bun 单文件打包
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { TOOL_DESCRIPTIONS } from './descriptions.generated.js';
const __filename = fileURLToPath(import.meta.url);
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 {
const description = TOOL_DESCRIPTIONS[toolName];
if (!description) {
throw new Error(`工具描述未找到: ${toolName}`);
const category = TOOL_CATEGORY_MAP[toolName];
const filePath = category
? path.join(__dirname, 'descriptions', category, `${toolName}.txt`)
: path.join(__dirname, 'descriptions', `${toolName}.txt`);
try {
return fs.readFileSync(filePath, 'utf-8').trim();
} catch {
throw new Error(`无法加载工具描述文件: ${filePath}`);
}
return description;
}
+1 -17
View File
@@ -4,7 +4,7 @@ import type { AgentConfig, ToolResult } from '../../types/index.js';
import type { ImageData } from '../../agent/types.js';
import { agentRegistry, AgentExecutor, agentEventEmitter } from '../../agent/index.js';
import { toolRegistry } from '../registry.js';
import { SessionManager, tokenStatsManager } from '../../session/index.js';
import { SessionManager } from '../../session/index.js';
import { getAgentManager } from '../../agent/manager.js';
import { loadVisionConfig } from '../../utils/config.js';
import { loadDescription } from '../load_description.js';
@@ -317,22 +317,6 @@ async function executeTask(params: TaskParams): Promise<ToolResult> {
];
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) {
return {
success: true,
-18
View File
@@ -96,30 +96,12 @@ export interface ConversationContext {
workingDirectory: string;
}
/**
* Token 使用统计信息
*/
export interface TokenUsageInfo {
/** 输入 tokensprompt */
promptTokens: number;
/** 输出 tokenscompletion */
completionTokens: number;
/** 总 tokens */
totalTokens: number;
/** 缓存读取的输入 tokensAnthropic API */
cacheReadInputTokens?: number;
/** 缓存创建的输入 tokensAnthropic API */
cacheCreationInputTokens?: number;
}
// Chat 返回结果(包含完整的消息链)
export interface ChatResult {
/** 最终文本响应 */
text: string;
/** 完整的响应消息链(包含 tool-call 和 tool-result */
messages: unknown[];
/** Token 使用统计(从 AI SDK response.usage 提取) */
usage?: TokenUsageInfo;
}
// 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema
+32 -36
View File
@@ -26,11 +26,7 @@ describe('Edit Parsers', () => {
describe('parseSearchReplaceBlocks', () => {
it('should parse marker format blocks', () => {
const content = `
<<<<<<< SEARCH
old content
=======
new content
>>>>>>> REPLACE
`;
const blocks = parseSearchReplaceBlocks(content);
expect(blocks).toHaveLength(1);
@@ -40,17 +36,8 @@ new content
it('should parse multiple marker format blocks', () => {
const content = `
<<<<<<< SEARCH
first old
=======
first new
>>>>>>> REPLACE
<<<<<<< SEARCH
second old
=======
second new
>>>>>>> REPLACE
`;
const blocks = parseSearchReplaceBlocks(content);
expect(blocks).toHaveLength(2);
@@ -81,9 +68,7 @@ second new
describe('createSearchReplaceEdit', () => {
it('should create search-replace edit', () => {
const blocks: SearchReplaceBlock[] = [
{ search: 'old', replace: 'new' },
];
const blocks: SearchReplaceBlock[] = [{ search: 'old', replace: 'new' }];
const edit = createSearchReplaceEdit('/test/file.ts', blocks);
expect(edit.mode).toBe('search-replace');
expect(edit.blocks).toHaveLength(1);
@@ -124,7 +109,9 @@ new
it('should trim trailing whitespace', () => {
const input = 'line1 \nline2 ';
const result = normalizeSearchString(input, { trimTrailingWhitespace: true });
const result = normalizeSearchString(input, {
trimTrailingWhitespace: true,
});
expect(result).toBe('line1\nline2');
});
});
@@ -331,12 +318,15 @@ describe('Edit Applier', () => {
await fs.writeFile(file1, 'content 1');
await fs.writeFile(file2, 'content 2');
const result = await applyBatchEdits({
edits: [
createWholeFileEdit(file1, 'new content 1'),
createWholeFileEdit(file2, 'new content 2'),
],
}, { runDiagnostics: false });
const result = await applyBatchEdits(
{
edits: [
createWholeFileEdit(file1, 'new content 1'),
createWholeFileEdit(file2, 'new content 2'),
],
},
{ runDiagnostics: false },
);
expect(result.success).toBe(true);
expect(result.results).toHaveLength(2);
@@ -353,13 +343,16 @@ describe('Edit Applier', () => {
await fs.writeFile(file1, 'original 1');
await fs.writeFile(file2, 'original 2');
const result = await applyBatchEdits({
edits: [
createWholeFileEdit(file1, 'new content 1'),
createSingleSearchReplaceEdit(file2, 'not found', 'replacement'),
],
atomic: true,
}, { runDiagnostics: false });
const result = await applyBatchEdits(
{
edits: [
createWholeFileEdit(file1, 'new content 1'),
createSingleSearchReplaceEdit(file2, 'not found', 'replacement'),
],
atomic: true,
},
{ runDiagnostics: false },
);
expect(result.success).toBe(false);
@@ -374,12 +367,15 @@ describe('Edit Applier', () => {
await fs.writeFile(file1, 'line1\nline2');
await fs.writeFile(file2, 'a\nb\nc');
const result = await applyBatchEdits({
edits: [
createWholeFileEdit(file1, 'line1\nline2\nline3'),
createWholeFileEdit(file2, 'x\ny'),
],
}, { runDiagnostics: false });
const result = await applyBatchEdits(
{
edits: [
createWholeFileEdit(file1, 'line1\nline2\nline3'),
createWholeFileEdit(file2, 'x\ny'),
],
},
{ runDiagnostics: false },
);
expect(result.success).toBe(true);
expect(result.totalStats.blocksApplied).toBe(2);
@@ -1,372 +0,0 @@
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
@@ -1,25 +0,0 @@
<!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 = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
@@ -5,15 +5,6 @@
"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",
@@ -1 +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:*/*"}]}]}}
{"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:*/*"}]}]}}
@@ -2,7 +2,6 @@ use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
#[derive(Serialize)]
@@ -117,70 +116,3 @@ pub async fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String>
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())
}
}
+2 -18
View File
@@ -21,22 +21,16 @@ pub fn run() {
commands::open_directory_dialog,
commands::read_local_file,
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!())
.expect("error while running tauri application");
}
fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error::Error>> {
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 show_item = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &floating_item, &quit_item])?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let _tray = TrayIconBuilder::new()
.menu(&menu)
@@ -48,16 +42,6 @@ fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error:
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" => {
app.exit(0);
}
+1 -19
View File
@@ -11,10 +11,8 @@
},
"app": {
"withGlobalTauri": true,
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
"title": "AI Assistant",
"width": 1200,
"height": 800,
@@ -23,29 +21,13 @@
"resizable": true,
"fullscreen": false,
"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": {
"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": {
"iconPath": "icons/32x32.png",
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}
},
+88 -233
View File
@@ -1,89 +1,64 @@
/**
* App Component
*
* 响应式布局:支持桌面端和移动端
*/
import { useState, useEffect, useCallback } from 'react';
import {
IDE,
Sidebar,
FileBrowser,
ConfigPanel,
CommandPanel,
MCPPanel,
HooksPanel,
AgentsPanel,
CheckpointPanel,
ProvidersPanel,
ServicesPanel,
LSPPanel,
DiagnosticsPanel,
SessionPanel,
StatusBar,
Resizer,
Toaster,
ThemeProvider,
listSessions,
createSession,
type Session,
type ActiveFileInfo,
type FileDiffInfo,
} from '@ai-assistant/ui';
import { ChatPage } from './pages/Chat';
export function App() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState(true);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = 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);
// 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(() => {
localStorage.setItem('ai-assistant-auto-attach-file', String(autoAttachActiveFile));
}, [autoAttachActiveFile]);
const HAS_SESSIONS_KEY = 'ai-assistant-has-sessions';
// 初始化:加载会话
useEffect(() => {
async function init() {
try {
const sessionsResult = await listSessions();
const { data: sessions } = sessionsResult;
const { data: sessions } = await listSessions();
if (sessions.length > 0) {
// 有会话,选择最近的
setCurrentSessionId(sessions[0].id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
} else {
// 无会话,自动创建一个新会话
// 这处理了新 project 目录的情况
const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id);
// 无会话:检查是否是首次启动
const hasHadSessions = localStorage.getItem(HAS_SESSIONS_KEY);
if (!hasHadSessions) {
// 首次启动,自动创建会话
const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
}
// 用户删除了所有会话:不自动创建,显示空状态
}
} catch (error) {
console.error('Failed to initialize:', error);
setConnectionError('无法连接到服务器。请确保后端服务已启动 (pnpm server:dev)');
} finally {
setIsInitializing(false);
}
@@ -100,211 +75,91 @@ export function App() {
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) => {
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) {
return (
<ThemeProvider defaultTheme="dark">
<div className="h-screen flex items-center justify-center bg-surface-base">
<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" />
<p className="text-fg-muted">Initializing...</p>
</div>
<div className="h-screen flex items-center justify-center bg-gray-900">
<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" />
<p className="text-gray-400">Initializing...</p>
</div>
</ThemeProvider>
</div>
);
}
return (
<ThemeProvider defaultTheme="dark">
<div className="h-screen flex flex-col bg-surface-base">
{/* 主内容区域:左侧文件浏览器 + 右侧对话框 */}
<div className="flex-1 flex min-w-0 overflow-hidden">
{/* 左侧:IDE(文件浏览器 + 代码编辑器) */}
<div
className="hidden md:flex flex-col"
style={{ width: `${idePanelWidth}%` }}
>
<IDE
onActiveFileChange={setActiveFile}
pendingDiff={pendingDiff}
onDiffClose={handleDiffClose}
<div className="h-screen flex bg-gray-900">
<Sidebar
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
sessionTitleUpdate={sessionTitleUpdate}
/>
<div className="flex-1 flex">
{/* 聊天区域 */}
<div className={`flex-1 ${showFileBrowser ? 'w-1/2' : 'w-full'}`}>
{currentSessionId ? (
<ChatPage
key={currentSessionId}
sessionId={currentSessionId}
onSessionUpdated={handleSessionUpdated}
showFileBrowser={showFileBrowser}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenConfig={() => setShowConfig(true)}
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
onOpenProviders={() => setShowProviders(true)}
/>
</div>
{/* 可拖拽分割线 */}
<Resizer
onResize={handleResize}
onResizeEnd={handleResizeEnd}
className="hidden md:block"
/>
{/* 右侧:聊天区域 */}
<div className="flex-1 min-w-0">
{connectionError ? (
<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
key={currentSessionId}
sessionId={currentSessionId}
onSessionNotFound={handleSessionNotFound}
onSessionUpdated={handleSessionUpdated}
onSessionSwitch={handleSessionSwitch}
responsive
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(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">
<p className="text-fg-muted">Select or create a session</p>
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-gray-400">Select or create a session</p>
</div>
)}
</div>
{/* 底部状态栏 */}
<StatusBar
sessionId={currentSessionId}
onDiagnosticsClick={() => setShowDiagnostics(true)}
/>
{/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
{/* MCP 面板 */}
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
{/* Hooks 面板 */}
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} responsive />}
{/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} responsive />}
{/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
{/* Providers 面板 */}
{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
/>
{/* 文件浏览器 */}
{showFileBrowser && (
<div className="w-1/2 border-l border-gray-700">
<FileBrowser
onFileSelect={(path, _content) => {
console.log('Selected file:', path);
}}
/>
</div>
)}
{/* 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 通知 */}
<Toaster />
</div>
</ThemeProvider>
{/* 配置面板 */}
{showConfig && <ConfigPanel onClose={() => setShowConfig(false)} />}
{/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />}
{/* MCP 面板 */}
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />}
{/* Hooks 面板 */}
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} />}
{/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} />}
{/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />}
{/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} />}
{/* Toast 通知 */}
<Toaster />
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
/**
* 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>
);
+155 -130
View File
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare, Globe } from 'lucide-react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History, Server } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import {
@@ -11,66 +11,38 @@ import {
ChatMessage,
TypingIndicator,
ChatInput,
ContextUsage,
SubagentProgress,
DiagnosticsIndicator,
ToolbarOverflowMenu,
type ActiveFileInfo,
type FileDiffInfo,
} from '@ai-assistant/ui';
interface ChatPageProps {
sessionId: string;
onSessionNotFound?: () => void;
onSessionUpdated?: (sessionId: string, name: string) => void;
/** 切换会话回调(如 :new 命令创建新会话) */
onSessionSwitch?: (newSessionId: string) => void;
responsive?: boolean;
// 工具栏按钮
showFileBrowser?: boolean;
onToggleFileBrowser?: () => void;
onOpenConfig?: () => void;
onOpenCommands?: () => void;
onOpenMCP?: () => void;
onOpenHooks?: () => void;
onOpenAgents?: () => void;
onOpenCheckpoints?: () => 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({
sessionId,
onSessionNotFound,
onSessionUpdated,
onSessionSwitch,
responsive = false,
showFileBrowser,
onToggleFileBrowser,
onOpenConfig,
onOpenCommands,
onOpenMCP,
onOpenHooks,
onOpenAgents,
onOpenCheckpoints,
onOpenProviders,
onOpenServices,
onOpenLSP,
onOpenDiagnostics,
onOpenSessions,
activeFile,
autoAttachActiveFile,
onAutoAttachActiveFileToggle,
onFileDiff,
onViewDiff,
}: ChatPageProps) {
const {
messages,
@@ -79,21 +51,12 @@ export function ChatPage({
streamingMessage,
sendMessage,
cancelProcessing,
allowPermission,
denyPermission,
agentMode,
autoApprove,
setAgentMode,
setAutoApprove,
currentAgent,
currentSubagent,
answerQuestion,
} = useChat({
sessionId,
onError: (error) => {
console.error('Chat error:', error);
},
onSessionNotFound,
onSessionUpdated,
onSessionSwitch,
onConfigError: (error) => {
@@ -107,7 +70,6 @@ export function ChatPage({
: undefined,
});
},
onFileDiff,
});
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -129,11 +91,11 @@ export function ChatPage({
<MessageSquare size={32} className="text-primary-400" />
</div>
<h2 className="text-2xl font-semibold mb-2 text-fg">
<h2 className="text-2xl font-semibold mb-2 bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent">
Start a conversation
</h2>
<p className="text-fg-muted mb-6 max-w-md mx-auto">
<p className="text-gray-400 mb-6 max-w-md mx-auto">
Ask me anything about coding, debugging, or software development.
</p>
@@ -144,7 +106,7 @@ export function ChatPage({
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => sendMessage(suggestion)}
className="px-3 py-1.5 bg-surface-subtle hover:bg-surface-muted rounded-full text-sm text-fg-secondary transition-colors"
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-full text-sm text-gray-300 transition-colors"
>
"{suggestion}"
</motion.button>
@@ -153,61 +115,152 @@ export function ChatPage({
</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 (
<div className="flex-1 flex flex-col h-full">
<div className="flex-1 flex flex-col h-screen">
{/* Header */}
<div className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-line bg-surface-subtle">
{/* 左侧:上下文使用情况 */}
<div className="flex items-center">
{sessionId && (
<ContextUsage
sessionId={sessionId}
compact
showCompressButton
refreshInterval={30000}
/>
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-700 bg-gray-800">
<h1 className="text-lg font-medium">Chat</h1>
<div className="flex items-center gap-3">
{/* 连接状态 */}
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* 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>
{/* Messages */}
@@ -217,35 +270,16 @@ export function ChatPage({
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
onAnswerQuestion={answerQuestion}
onViewDiff={onViewDiff ?? onFileDiff}
onAllowPermission={allowPermission}
onDenyPermission={denyPermission}
/>
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} />
))}
</AnimatePresence>
{/* 流式消息 - 复用 ChatMessage 组件 */}
{streamingMessage && (
<ChatMessage
message={streamingMessage}
isStreaming
onAnswerQuestion={answerQuestion}
onViewDiff={onViewDiff ?? onFileDiff}
onAllowPermission={allowPermission}
onDenyPermission={denyPermission}
/>
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} />
)}
{/* 子 Agent 进度显示 */}
{currentSubagent && (
<SubagentProgress subagent={currentSubagent} />
)}
{isLoading && !streamingMessage && !currentSubagent && <TypingIndicator agentName={currentAgent} />}
{isLoading && !streamingMessage && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
@@ -257,16 +291,7 @@ export function ChatPage({
onCancel={cancelProcessing}
isLoading={isLoading}
disabled={!isConnected}
responsive={responsive}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoApprove={autoApprove}
onAutoApproveChange={setAutoApprove}
activeFile={activeFile}
autoAttachActiveFile={autoAttachActiveFile}
onAutoAttachActiveFileToggle={onAutoAttachActiveFileToggle}
/>
</div>
);
}
-375
View File
@@ -1,375 +0,0 @@
/**
* 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
@@ -1,18 +0,0 @@
@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;
}
+71 -8
View File
@@ -2,14 +2,77 @@
@tailwind components;
@tailwind utilities;
/* 导入 UI 包的共享样式会在 main.tsx 中通过 import '@ai-assistant/ui/styles' 完成 */
/* 此文件仅包含 desktop 特有的样式覆盖 */
/* Desktop 特有的 Tauri 窗口拖拽区域 */
.titlebar-drag-region {
-webkit-app-region: drag;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.no-drag {
-webkit-app-region: no-drag;
::-webkit-scrollbar-track {
background: transparent;
}
::-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,26 +9,6 @@ export default {
theme: {
extend: {
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: {
50: '#f0f9ff',
100: '#e0f2fe',
-7
View File
@@ -1,7 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { resolve } from 'path';
const host = process.env.TAURI_DEV_HOST;
@@ -44,11 +43,5 @@ export default defineConfig({
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
floating: resolve(__dirname, 'floating.html'),
},
},
},
});
-2
View File
@@ -13,8 +13,6 @@
},
"scripts": {
"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",
"start": "bun run src/bin/server.ts",
"start:dev": "bun --watch run src/bin/server.ts",
+1 -8
View File
@@ -421,7 +421,7 @@ export async function processMessage(
});
});
// 发送完成消息(包含 token 使用信息)
// 发送完成消息
broadcastToSession(sessionId, {
type: 'done',
sessionId,
@@ -430,13 +430,6 @@ export async function processMessage(
hasToolCalls,
messageCount: result.messages.length,
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,
},
});
+15 -23
View File
@@ -14,13 +14,12 @@
import { app, websocket, startServer } from '../index.js';
// 解析命令行参数
function parseArgs(): { port: number; host: string; auth?: boolean; token?: string; staticDir?: string } {
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;
let staticDir: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
@@ -36,51 +35,44 @@ function parseArgs(): { port: number; host: string; auth?: boolean; token?: stri
} else if (args[i] === '--token' || args[i] === '-t') {
token = args[i + 1];
i++;
} else if (args[i] === '--static' || args[i] === '-s') {
staticDir = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
AI Assistant Server
Usage:
ai-server [options]
bun run server.ts [options]
Options:
-p, --port <port> Port to listen on (default: 3000)
-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
--no-auth Disable authentication
-t, --token <token> Set authentication token
-h, --help Show this help message
-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 (no auth)
ai-server
# Serve with Web UI
ai-server --static ./web-dist
bun run server.ts
# Remote server with auth
ai-server --host 0.0.0.0 --auth
bun run server.ts --host 0.0.0.0 --auth
# Full production setup
ai-server --host 0.0.0.0 --static ./web-dist --token mysecrettoken
# Custom token
bun run server.ts --host 0.0.0.0 --token mysecrettoken
`);
process.exit(0);
}
}
return { port, host, auth, token, staticDir };
return { port, host, auth, token };
}
// 主函数
async function main() {
const { port, host, auth, token, staticDir } = parseArgs();
const { port, host, auth, token } = parseArgs();
// 初始化并打印启动信息
await startServer({ port, host, auth, token, staticDir });
await startServer({ port, host, auth, token });
// 启动 Bun 服务器
const server = Bun.serve({
-320
View File
@@ -1,320 +0,0 @@
#!/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);
});
+2 -52
View File
@@ -7,10 +7,9 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
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 { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter } from './routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
@@ -92,7 +91,6 @@ api.route('/providers', providersRouter);
api.route('/services', servicesRouter);
api.route('/lsp', lspRouter);
api.route('/system-commands', systemCommandsRouter);
api.route('/stats', statsRouter);
// 上下文压缩相关(挂载到根路径,内部路由包含 /sessions/:id/context
api.route('/', contextRouter);
@@ -159,8 +157,6 @@ export interface ServerOptions {
auth?: boolean;
/** 预设的 token */
token?: string;
/** 静态文件目录 (托管 Web UI) */
staticDir?: string;
}
/**
@@ -213,63 +209,18 @@ 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 环境)
*/
export async function startServer(options: ServerOptions = {}): Promise<void> {
const { port = 3000, host = '127.0.0.1', staticDir } = options;
const { port = 3000, host = '127.0.0.1' } = options;
// 初始化
await initServer(options);
// 配置静态文件托管
if (staticDir) {
configureStaticFiles(staticDir);
}
const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available';
const authConfig = getAuthConfig();
const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled';
const staticStatus = staticDir ? `📁 ${staticDir}` : '❌ Disabled';
console.log(`
╔════════════════════════════════════════════╗
@@ -281,7 +232,6 @@ export async function startServer(options: ServerOptions = {}): Promise<void> {
║ Health: http://${host}:${port}/health
║ Agent: ${coreStatus}
║ Auth: ${authStatus}
║ Static: ${staticStatus}
╚════════════════════════════════════════════╝
`);
+1
View File
@@ -9,6 +9,7 @@ import { getConfig } from './config.js';
import type {
AgentMode,
AgentInfo,
AgentConfigFile,
AgentModelConfig,
AgentPermission,
} from '@ai-assistant/core';
+14 -1
View File
@@ -6,7 +6,20 @@
import { Hono } from 'hono';
import { getConfig } from './config.js';
import type { CheckpointMetadata } from '@ai-assistant/core';
import type {
CheckpointMetadata,
CheckpointConfig,
CheckpointTrigger,
FileChange,
FileChangeType,
DiffInfo,
FileDiff,
RollbackOptions,
RollbackResult,
RollbackRecord,
SafetyCheckResult,
UnrevertResult,
} from '@ai-assistant/core';
import {
CheckpointManager,
getCheckpointManager,
+5 -1
View File
@@ -7,7 +7,11 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { getConfig } from './config.js';
// Command, CommandInput, CommandExecutionResult 类型由函数自动推断
import type {
Command,
CommandInput,
CommandExecutionResult,
} from '@ai-assistant/core';
import {
getCommandRegistry,
createCommandExecutor,
-1
View File
@@ -18,4 +18,3 @@ export { servicesRouter } from './services.js';
export { contextRouter } from './context.js';
export { lspRouter } from './lsp.js';
export { systemCommandsRouter } from './system-commands.js';
export { statsRouter } from './stats.js';
+4 -1
View File
@@ -6,7 +6,10 @@
import { Hono } from 'hono';
import { getConfig } from './config.js';
import type { FileDiagnostic } from '@ai-assistant/core';
import type {
FileDiagnostic,
ServerStatus,
} from '@ai-assistant/core';
import {
initLSP,
listServers,
+6 -1
View File
@@ -6,7 +6,12 @@
import { Hono } from 'hono';
import { getConfig } from './config.js';
import type { MCPConfig } from '@ai-assistant/core';
import type {
MCPConfig,
MCPServerConfig,
MCPServerStatus,
MCPTool,
} from '@ai-assistant/core';
import {
getMCPManager,
loadMCPConfig,
+1 -1
View File
@@ -5,7 +5,7 @@
*/
import { Hono } from 'hono';
import type { ServiceType } from '@ai-assistant/core';
import type { ServiceConfig, ServiceType } from '@ai-assistant/core';
import {
loadProvidersConfig,
getServiceConfig,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
type Message,
type MessagePart,
} from '../types.js';
// MessageInfo, Part, ApiPart 从 Core 导入但仅用于类型推导
import type { MessageInfo, Part, ApiPart } from '@ai-assistant/core';
import { MessageStorage, PartStorage, partsToApiFormat } from '@ai-assistant/core';
export const sessionsRouter = new Hono();
-216
View File
@@ -1,216 +0,0 @@
/**
* 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,6 +365,11 @@ export interface Message {
parts: MessagePart[];
/** 所有文本拼接(兼容字段) */
content?: string;
metadata?: {
model?: string;
stepCount?: number;
totalTokens?: number;
};
}
/** @deprecated 使用 Message 代替 */
-118
View File
@@ -56,10 +56,6 @@ import type {
// System Commands types
SystemCommandListResponse,
SystemCommandInfo,
// Token Stats types
SessionTokenStats,
ProjectTokenStats,
TokenStatsSummary,
} from './types.js';
// Re-export types
@@ -159,11 +155,6 @@ export type {
ActiveFileInfo,
// File diff types
FileDiffInfo,
// Token Stats types
TokenCount,
SessionTokenStats,
ProjectTokenStats,
TokenStatsSummary,
} from './types.js';
// API Configuration
@@ -1165,112 +1156,3 @@ export async function getLSPDiagnostics(file?: string): Promise<{
const params = file ? `?file=${encodeURIComponent(file)}` : '';
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)}`);
}
+12 -63
View File
@@ -175,13 +175,14 @@ export interface Message {
parts: MessagePart[];
/** 所有文本拼接(兼容字段) */
content?: string;
/** 是否包含推理过程 */
hasReasoning?: boolean;
/** 推理内容 */
reasoning?: string;
/** 元数据 */
metadata?: {
/** 输入 Token 数 */
inputTokens?: number;
/** 输出 Token 数 */
outputTokens?: number;
/** 总 Token 数 */
model?: string;
stepCount?: number;
totalTokens?: number;
/** 生成此消息的 Agent 名称 */
agentName?: string;
@@ -837,6 +838,8 @@ export interface ProviderDetail {
builtin: boolean;
/** API 基础 URL */
baseUrl?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 可用模型列表 */
models: ModelInfo[];
/** 是否允许自定义模型 */
@@ -860,6 +863,8 @@ export interface CustomProviderDefinition {
description?: string;
/** API 基础 URL(必填) */
baseUrl: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 预设模型列表 */
models?: ModelInfo[];
/** 是否允许自定义模型 */
@@ -872,6 +877,8 @@ export interface ProviderConfig {
id?: string;
/** API Key */
apiKey?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 自定义 API 基础 URL */
baseUrl?: string;
/** 是否启用 */
@@ -1216,61 +1223,3 @@ export interface FileDiffInfo {
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,7 +15,6 @@ import {
CheckCircle2,
Loader2,
GitCompare,
Coins,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, forwardRef } from 'react';
@@ -42,28 +41,11 @@ interface ChatMessageProps {
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>(
({ message, isStreaming = false, onAnswerQuestion, onViewDiff, onAllowPermission, onDenyPermission }, ref) => {
const isUser = message.role === 'user';
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 () => {
await navigator.clipboard.writeText(message.content ?? '');
setCopied(true);
@@ -202,22 +184,6 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
</button>
</div>
{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>
</motion.div>
);
@@ -54,6 +54,7 @@ export function ProviderEditor({
// Form state
const [apiKey, setApiKey] = useState('');
const [apiKeyEnvVar, setApiKeyEnvVar] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [enabled, setEnabled] = useState(true);
@@ -83,6 +84,7 @@ export function ProviderEditor({
const config = result.data.config;
// API key is not returned for security, but we show if it's configured via hasApiKey
setApiKey('');
setApiKeyEnvVar(result.data.apiKeyEnvVar || '');
setBaseUrl(config.baseUrl || result.data.baseUrl || '');
setEnabled(config.enabled !== false);
} else {
@@ -112,6 +114,9 @@ export function ProviderEditor({
if (apiKey.trim()) {
config.apiKey = apiKey;
}
if (apiKeyEnvVar.trim() && apiKeyEnvVar !== provider?.apiKeyEnvVar) {
config.apiKeyEnvVar = apiKeyEnvVar;
}
if (baseUrl.trim() && baseUrl !== provider?.baseUrl) {
config.baseUrl = baseUrl;
}
@@ -331,6 +336,20 @@ export function ProviderEditor({
)}
</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>
{/* Base URL Section */}
@@ -406,6 +406,14 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
</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 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -680,6 +688,14 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
placeholder="http://localhost:11434/v1"
/>
</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 className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setShowAddProvider(false)}>
@@ -1,452 +0,0 @@
/**
* 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>
);
}
+62 -151
View File
@@ -5,23 +5,14 @@
*/
import { useState, useEffect, useCallback, forwardRef } from 'react';
import { X, Plus, MessageSquare, Trash2, MessageCircle, Coins } from 'lucide-react';
import { X, Plus, MessageSquare, Trash2, MessageCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition, fadeInUp } from '../utils/animations';
import { Button } from '../primitives/Button';
import { SessionSkeleton } from './Skeleton';
import {
listSessions,
createSession,
deleteSession,
getTokenStatsSummary,
getSessionTokenStats,
type Session,
type ProjectTokenStats,
type SessionTokenStats,
} from '../api/client.js';
import { listSessions, createSession, deleteSession, type Session } from '../api/client.js';
interface SessionPanelProps {
onClose: () => void;
@@ -37,17 +28,6 @@ interface SessionPanelProps {
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({
onClose,
currentSessionId,
@@ -58,45 +38,13 @@ export function SessionPanel({
}: SessionPanelProps) {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [projectStats, setProjectStats] = useState<ProjectTokenStats | null>(null);
const [sessionStats, setSessionStats] = useState<Record<string, SessionTokenStats>>({});
// 加载会话列表和 Token 统计
// 加载会话列表
const loadSessions = useCallback(async () => {
setIsLoading(true);
try {
const [sessionsResult, summaryResult] = await Promise.all([
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);
const { data } = await listSessions();
setSessions(data);
} catch (error) {
console.error('Failed to load sessions:', error);
toast.error('Failed to load sessions');
@@ -161,60 +109,41 @@ export function SessionPanel({
}, [sessionTitleUpdate]);
// 会话列表项
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => {
const stats = sessionStats[session.id];
const totalTokens = stats?.total.totalTokens ?? 0;
return (
<motion.div
ref={ref}
layout
variants={fadeInUp}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={() => handleSelectSession(session.id)}
className={cn(
'flex items-center gap-3 p-3 rounded-lg cursor-pointer group',
'hover:bg-surface-muted transition-colors',
'active:bg-surface-emphasis',
currentSessionId === session.id && 'bg-primary-500/20 border border-primary-500/30'
)}
>
<MessageSquare size={18} className="text-fg-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-fg">
{session.name || `Chat ${session.id.slice(0, 8)}`}
</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>
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => (
<motion.div
ref={ref}
layout
variants={fadeInUp}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={() => handleSelectSession(session.id)}
className={cn(
'flex items-center gap-3 p-3 rounded-lg cursor-pointer group',
'hover:bg-surface-muted transition-colors',
'active:bg-surface-emphasis',
currentSessionId === session.id && 'bg-primary-500/20 border border-primary-500/30'
)}
>
<MessageSquare size={18} className="text-fg-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-fg">
{session.name || `Chat ${session.id.slice(0, 8)}`}
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => handleDelete(session.id, e)}
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded transition-all"
aria-label="Delete session"
>
<Trash2 size={14} className="text-red-400" />
</motion.button>
</motion.div>
);
});
<div className="text-xs text-fg-subtle">{session.messageCount} messages</div>
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => handleDelete(session.id, e)}
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded transition-all"
aria-label="Delete session"
>
<Trash2 size={14} className="text-red-400" />
</motion.button>
</motion.div>
));
// 空状态
const EmptyState = () => (
@@ -260,48 +189,30 @@ export function SessionPanel({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="border-b border-line">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-2">
<MessageSquare size={20} className="text-primary-400" />
<h2 className="text-lg font-semibold text-fg">Sessions</h2>
{!isLoading && (
<span className="text-xs text-fg-muted bg-surface-muted px-2 py-0.5 rounded-full">
{sessions.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button onClick={handleCreate} variant="default" size="sm">
<Plus size={16} />
New
</Button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 hover:bg-surface-muted rounded-lg transition-colors"
>
<X size={20} className="text-fg-muted" />
</motion.button>
</div>
<div className="flex items-center justify-between p-4 border-b border-line">
<div className="flex items-center gap-2">
<MessageSquare size={20} className="text-primary-400" />
<h2 className="text-lg font-semibold text-fg">Sessions</h2>
{!isLoading && (
<span className="text-xs text-fg-muted bg-surface-muted px-2 py-0.5 rounded-full">
{sessions.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button onClick={handleCreate} variant="default" size="sm">
<Plus size={16} />
New
</Button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 hover:bg-surface-muted rounded-lg transition-colors"
>
<X size={20} className="text-fg-muted" />
</motion.button>
</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 */}
+7 -57
View File
@@ -1,28 +1,18 @@
/**
* StatusBar Component
*
* 底部状态栏,显示 Git 分支、诊断信息、连接状态、Token 统计
* 底部状态栏,显示 Git 分支、诊断信息、连接状态等
*/
import { useState, useEffect, useCallback } from 'react';
import { GitBranch, AlertTriangle, AlertCircle, WifiOff, RefreshCw, CheckCircle, Coins } from 'lucide-react';
import { GitBranch, AlertTriangle, AlertCircle, WifiOff, RefreshCw, CheckCircle } from 'lucide-react';
import { cn } from '../utils/cn.js';
import {
getLSPDiagnostics,
getGitInfo,
getHealth,
getSessionTokenStats,
type DiagnosticsSummary,
type GitInfo,
type SessionTokenStats,
} from '../api/client.js';
import { getLSPDiagnostics, getGitInfo, getHealth, type DiagnosticsSummary, type GitInfo } from '../api/client.js';
interface StatusBarProps {
className?: string;
/** 是否连接到服务器 */
isConnected?: boolean;
/** 当前会话 ID */
sessionId?: string | null;
/** 点击诊断信息回调 */
onDiagnosticsClick?: () => void;
/** 刷新间隔 (ms) */
@@ -32,39 +22,25 @@ interface StatusBarProps {
export function StatusBar({
className,
isConnected: isConnectedProp,
sessionId,
onDiagnosticsClick,
refreshInterval = 30000,
}: StatusBarProps) {
const [diagnostics, setDiagnostics] = useState<DiagnosticsSummary | null>(null);
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
const [tokenStats, setTokenStats] = useState<SessionTokenStats | null>(null);
const [loading, setLoading] = useState(false);
const [connectionStatus, setConnectionStatus] = useState(true);
// 如果外部传入了 isConnected,使用外部值;否则使用内部检测
const isConnected = isConnectedProp ?? connectionStatus;
// 加载诊断信息Git 信息和 Token 统计
// 加载诊断信息Git 信息
const loadData = useCallback(async () => {
setLoading(true);
try {
const promises: Promise<unknown>[] = [
const [diagResult, gitResult] = await Promise.all([
getLSPDiagnostics(),
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) {
setDiagnostics(diagResult.data.summary);
@@ -74,10 +50,6 @@ export function StatusBar({
setGitInfo(gitResult.data);
}
if (tokenResult?.success && tokenResult.data) {
setTokenStats(tokenResult.data);
}
// 如果没有外部传入连接状态,内部检测
if (isConnectedProp === undefined) {
setConnectionStatus(true);
@@ -90,7 +62,7 @@ export function StatusBar({
} finally {
setLoading(false);
}
}, [isConnectedProp, sessionId]);
}, [isConnectedProp]);
// 检测连接状态(如果没有外部传入)
const checkConnection = useCallback(async () => {
@@ -120,17 +92,6 @@ export function StatusBar({
const warningCount = diagnostics?.totalWarnings ?? 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 (
<div
className={cn(
@@ -188,17 +149,6 @@ export function StatusBar({
{/* 右侧 */}
<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
onClick={onDiagnosticsClick}
+2 -19
View File
@@ -415,12 +415,6 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
const content = message.payload?.content || streaming?.content || '';
// 从服务器 payload 获取 agentName,或使用当前 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
? {
@@ -428,13 +422,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
id: message.payload?.id || streaming.id,
timestamp: message.payload?.timestamp || streaming.timestamp,
content,
metadata: {
...streaming.metadata,
agentName,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
metadata: { ...streaming.metadata, agentName },
}
: {
id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
@@ -442,12 +430,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
timestamp: message.payload?.timestamp || new Date().toISOString(),
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
content,
metadata: {
agentName,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
metadata: { agentName },
};
return {
-7
View File
@@ -105,12 +105,6 @@ export {
stopLSPServer,
getRunningLSPServers,
getLSPDiagnostics,
// Services API
listServices,
getService,
updateService,
deleteService,
type ServiceListItem,
} from './api/client.js';
// Types
@@ -241,7 +235,6 @@ export { AgentEditor } from './components/AgentEditor.js';
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
export { ProvidersPanel } from './components/ProvidersPanel.js';
export { ProviderEditor } from './components/ProviderEditor.js';
export { ServicesPanel } from './components/ServicesPanel.js';
export { CheckpointPanel } from './components/CheckpointPanel.js';
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
export { RestoreDialog } from './components/RestoreDialog.js';
+14 -14
View File
@@ -13,7 +13,6 @@ import {
AgentsPanel,
CheckpointPanel,
ProvidersPanel,
ServicesPanel,
LSPPanel,
DiagnosticsPanel,
SessionPanel,
@@ -38,7 +37,6 @@ export function App() {
const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = 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);
@@ -66,6 +64,8 @@ export function App() {
// 初始化:加载会话
useEffect(() => {
const HAS_SESSIONS_KEY = 'ai-assistant-has-sessions';
async function init() {
try {
const sessionsResult = await listSessions();
@@ -74,11 +74,18 @@ export function App() {
if (sessions.length > 0) {
// 有会话,选择最近的
setCurrentSessionId(sessions[0].id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
} else {
// 无会话,自动创建一个新会话
// 这处理了新 project 目录的情况
const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id);
// 无会话:检查是否是首次启动
const hasHadSessions = localStorage.getItem(HAS_SESSIONS_KEY);
if (!hasHadSessions) {
// 首次启动,自动创建会话
const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
}
// 用户删除了所有会话:不自动创建,显示空状态
}
} catch (error) {
console.error('Failed to initialize:', error);
@@ -196,7 +203,6 @@ export function App() {
onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
onOpenProviders={() => setShowProviders(true)}
onOpenServices={() => setShowServices(true)}
onOpenLSP={() => setShowLSP(true)}
onOpenDiagnostics={() => setShowDiagnostics(true)}
onOpenSessions={() => setShowSessions(true)}
@@ -216,10 +222,7 @@ export function App() {
</div>
{/* 底部状态栏 */}
<StatusBar
sessionId={currentSessionId}
onDiagnosticsClick={() => setShowDiagnostics(true)}
/>
<StatusBar onDiagnosticsClick={() => setShowDiagnostics(true)} />
{/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
@@ -239,9 +242,6 @@ export function App() {
{/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
{/* Services 面板 */}
{showServices && <ServicesPanel onClose={() => setShowServices(false)} responsive />}
{/* LSP 面板 */}
{showLSP && (
<LSPPanel
+1 -4
View File
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare, Globe } from 'lucide-react';
import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import {
@@ -33,7 +33,6 @@ interface ChatPageProps {
onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
onOpenProviders?: () => void;
onOpenServices?: () => void;
onOpenLSP?: () => void;
onOpenDiagnostics?: () => void;
onOpenSessions?: () => void;
@@ -62,7 +61,6 @@ export function ChatPage({
onOpenAgents,
onOpenCheckpoints,
onOpenProviders,
onOpenServices,
onOpenLSP,
onOpenDiagnostics,
onOpenSessions,
@@ -199,7 +197,6 @@ export function ChatPage({
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 },
-285
View File
@@ -1,285 +0,0 @@
#!/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);
});