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