feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { AgentConfig, ProviderType } from '../types/index.js';
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
|
||||
interface StoredConfig {
|
||||
provider?: ProviderType;
|
||||
apiKey?: string;
|
||||
deepseekApiKey?: string;
|
||||
openaiApiKey?: string;
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
tavilyApiKey?: string;
|
||||
/** 自定义 API 基础 URL(用于 OpenAI 兼容服务,如阿里云百炼) */
|
||||
baseUrl?: string;
|
||||
// Vision 配置
|
||||
visionProvider?: ProviderType;
|
||||
visionModel?: string;
|
||||
/** Vision 专用的 API Key(可选,不设置则使用对应 provider 的 key) */
|
||||
visionApiKey?: string;
|
||||
/** Vision 专用的 Base URL(用于 OpenAI 兼容的 Vision 服务) */
|
||||
visionBaseUrl?: string;
|
||||
}
|
||||
|
||||
// Vision 配置接口
|
||||
export interface VisionConfig {
|
||||
provider: ProviderType;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** 自定义 Base URL(用于 OpenAI 兼容的 Vision 服务) */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
// 默认模型配置
|
||||
const DEFAULT_MODELS: Record<ProviderType, string> = {
|
||||
anthropic: 'claude-sonnet-4-20250514',
|
||||
deepseek: 'deepseek-chat',
|
||||
openai: 'gpt-4o',
|
||||
};
|
||||
|
||||
// 默认 Vision 模型(需要支持图片理解)
|
||||
const DEFAULT_VISION_MODELS: Record<ProviderType, string> = {
|
||||
anthropic: 'claude-sonnet-4-20250514',
|
||||
deepseek: 'deepseek-chat', // DeepSeek 暂不支持 vision,占位用
|
||||
openai: 'gpt-4o',
|
||||
};
|
||||
|
||||
// 默认系统提示词
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是一个运行在终端中的 AI 编程助手。你可以帮助用户:
|
||||
- 读取和写入文件
|
||||
- 执行 bash 命令
|
||||
- 搜索代码和文件
|
||||
- 回答编程问题
|
||||
|
||||
使用工具时请注意:
|
||||
1. 在修改文件前,先读取文件内容
|
||||
2. 执行可能有风险的命令前,先向用户确认
|
||||
3. 给出清晰、简洁的回答
|
||||
|
||||
重要的工具使用规则:
|
||||
- 创建或修改文件时,必须使用 write_file 或 edit_file 工具,不要使用 bash 命令(如 cat、echo 等)
|
||||
- write_file 和 edit_file 工具集成了代码诊断功能,可以在写入后自动检查代码错误
|
||||
- bash 工具仅用于运行命令、安装依赖、执行脚本等操作,不要用于文件内容的创建和修改
|
||||
|
||||
当前工作目录: ${process.cwd()}
|
||||
操作系统: ${process.platform}`;
|
||||
|
||||
// 获取原始配置(包含所有字段)
|
||||
export function getConfig(): StoredConfig {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
export function loadConfig(): AgentConfig {
|
||||
// 从环境变量获取
|
||||
const provider = (process.env.AI_PROVIDER as ProviderType) || 'anthropic';
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
const model = process.env.AI_MODEL;
|
||||
const maxTokens = parseInt(process.env.AI_MAX_TOKENS || '4096', 10);
|
||||
const baseUrl = process.env.AI_BASE_URL;
|
||||
|
||||
// 从配置文件读取
|
||||
let storedConfig: StoredConfig = {};
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
storedConfig = JSON.parse(content);
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 确定最终的 provider
|
||||
const finalProvider = storedConfig.provider || provider;
|
||||
|
||||
// 根据 provider 获取对应的 API Key
|
||||
let finalApiKey: string | undefined;
|
||||
if (finalProvider === 'anthropic') {
|
||||
finalApiKey = anthropicApiKey || storedConfig.apiKey;
|
||||
} else if (finalProvider === 'deepseek') {
|
||||
finalApiKey = deepseekApiKey || storedConfig.deepseekApiKey;
|
||||
} else if (finalProvider === 'openai') {
|
||||
finalApiKey = openaiApiKey || storedConfig.openaiApiKey;
|
||||
}
|
||||
|
||||
if (!finalApiKey) {
|
||||
const envVarMap: Record<ProviderType, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
deepseek: 'DEEPSEEK_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
};
|
||||
const envVar = envVarMap[finalProvider];
|
||||
console.error(`❌ 错误: 未设置 ${envVar}`);
|
||||
console.error(`请设置环境变量: export ${envVar}=your-api-key`);
|
||||
console.error('或运行: ai-assist init 进行初始化配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 确定模型
|
||||
const finalModel = model || storedConfig.model || DEFAULT_MODELS[finalProvider];
|
||||
|
||||
// 确定 baseUrl(环境变量优先)
|
||||
const finalBaseUrl = baseUrl || storedConfig.baseUrl;
|
||||
|
||||
return {
|
||||
provider: finalProvider,
|
||||
apiKey: finalApiKey,
|
||||
model: finalModel,
|
||||
maxTokens: storedConfig.maxTokens || maxTokens,
|
||||
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
||||
baseUrl: finalBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 Vision 配置
|
||||
* Vision 用于图片理解,当主模型不支持 vision 时使用
|
||||
* 优先级:环境变量 > 配置文件 > 默认使用 Anthropic Claude
|
||||
*/
|
||||
export function loadVisionConfig(): VisionConfig | null {
|
||||
// 从环境变量获取
|
||||
const visionProvider = process.env.VISION_PROVIDER as ProviderType | undefined;
|
||||
const visionModel = process.env.VISION_MODEL;
|
||||
const visionApiKey = process.env.VISION_API_KEY;
|
||||
const visionBaseUrl = process.env.VISION_BASE_URL;
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
// 从配置文件读取
|
||||
const storedConfig = getConfig();
|
||||
|
||||
// 确定 vision provider(默认使用 anthropic,因为 Claude 支持 vision)
|
||||
const finalProvider = visionProvider || storedConfig.visionProvider || 'anthropic';
|
||||
|
||||
// 获取 Vision 专用的 API Key(优先级:环境变量 > 配置文件专用 key > provider 对应的 key)
|
||||
let finalApiKey: string | undefined;
|
||||
finalApiKey = visionApiKey || storedConfig.visionApiKey;
|
||||
|
||||
// 如果没有专用 key,回退到对应 provider 的 key
|
||||
if (!finalApiKey) {
|
||||
if (finalProvider === 'anthropic') {
|
||||
finalApiKey = anthropicApiKey || storedConfig.apiKey;
|
||||
} else if (finalProvider === 'deepseek') {
|
||||
finalApiKey = deepseekApiKey || storedConfig.deepseekApiKey;
|
||||
} else if (finalProvider === 'openai') {
|
||||
finalApiKey = openaiApiKey || storedConfig.openaiApiKey;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 API Key,返回 null
|
||||
if (!finalApiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 确定模型
|
||||
const finalModel = visionModel || storedConfig.visionModel || DEFAULT_VISION_MODELS[finalProvider];
|
||||
|
||||
// 确定 baseUrl(Vision 专用)
|
||||
const finalBaseUrl = visionBaseUrl || storedConfig.visionBaseUrl;
|
||||
|
||||
return {
|
||||
provider: finalProvider,
|
||||
apiKey: finalApiKey,
|
||||
model: finalModel,
|
||||
baseUrl: finalBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
export function saveConfig(config: Partial<StoredConfig>): void {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(CONFIG_DIR)) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 读取现有配置
|
||||
let existingConfig: StoredConfig = {};
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
existingConfig = JSON.parse(content);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 合并并保存
|
||||
const newConfig = { ...existingConfig, ...config };
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2));
|
||||
}
|
||||
|
||||
// 初始化配置向导
|
||||
export async function initConfig(): Promise<void> {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
|
||||
console.log('\n🔧 初始化 AI Terminal Assistant 配置\n');
|
||||
|
||||
// 选择 provider
|
||||
const { provider } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'provider',
|
||||
message: '选择 AI 服务商:',
|
||||
choices: [
|
||||
{ name: 'Anthropic (Claude)', value: 'anthropic' },
|
||||
{ name: 'OpenAI (GPT)', value: 'openai' },
|
||||
{ name: 'OpenAI 兼容服务 (阿里云百炼、Azure 等)', value: 'openai-compatible' },
|
||||
{ name: 'DeepSeek', value: 'deepseek' },
|
||||
],
|
||||
default: 'anthropic',
|
||||
},
|
||||
]);
|
||||
|
||||
// 是否是 OpenAI 兼容服务
|
||||
const isOpenAICompatible = provider === 'openai-compatible';
|
||||
const actualProvider = isOpenAICompatible ? 'openai' : provider;
|
||||
|
||||
// 如果是 OpenAI 兼容服务,询问 base URL
|
||||
let baseUrl: string | undefined;
|
||||
if (isOpenAICompatible) {
|
||||
const { customBaseUrl } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customBaseUrl',
|
||||
message: '请输入 API 基础 URL (如: https://dashscope.aliyuncs.com/compatible-mode/v1):',
|
||||
validate: (input: string) => {
|
||||
if (!input) return 'Base URL 不能为空';
|
||||
try {
|
||||
new URL(input);
|
||||
return true;
|
||||
} catch {
|
||||
return '请输入有效的 URL';
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
baseUrl = customBaseUrl;
|
||||
}
|
||||
|
||||
// 根据 provider 显示不同的模型选项
|
||||
let modelChoices: Array<{ name: string; value: string }>;
|
||||
let allowCustomModel = false;
|
||||
|
||||
if (actualProvider === 'anthropic') {
|
||||
modelChoices = [
|
||||
{ name: 'Claude Sonnet 4 (推荐,平衡性能和成本)', value: 'claude-sonnet-4-20250514' },
|
||||
{ name: 'Claude Opus 4 (最强,成本较高)', value: 'claude-opus-4-20250514' },
|
||||
{ name: 'Claude 3.5 Haiku (快速,成本低)', value: 'claude-3-5-haiku-20241022' },
|
||||
];
|
||||
} else if (actualProvider === 'openai') {
|
||||
if (isOpenAICompatible) {
|
||||
// OpenAI 兼容服务允许自定义模型名称
|
||||
modelChoices = [
|
||||
{ name: 'qwen-plus (通义千问)', value: 'qwen-plus' },
|
||||
{ name: 'qwen-turbo (通义千问快速版)', value: 'qwen-turbo' },
|
||||
{ name: 'qwen-max (通义千问最强版)', value: 'qwen-max' },
|
||||
{ name: 'gpt-4o', value: 'gpt-4o' },
|
||||
{ name: '自定义模型名称...', value: '__custom__' },
|
||||
];
|
||||
allowCustomModel = true;
|
||||
} else {
|
||||
modelChoices = [
|
||||
{ name: 'GPT-4o (推荐,支持 vision)', value: 'gpt-4o' },
|
||||
{ name: 'GPT-4o mini (快速,成本低)', value: 'gpt-4o-mini' },
|
||||
{ name: 'GPT-4 Turbo', value: 'gpt-4-turbo' },
|
||||
{ name: 'o1 (推理增强)', value: 'o1' },
|
||||
{ name: 'o1-mini (推理,成本低)', value: 'o1-mini' },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
modelChoices = [
|
||||
{ name: 'DeepSeek Chat (推荐)', value: 'deepseek-chat' },
|
||||
{ name: 'DeepSeek Reasoner (推理增强)', value: 'deepseek-reasoner' },
|
||||
];
|
||||
}
|
||||
|
||||
const apiKeyMessageMap: Record<string, string> = {
|
||||
anthropic: '请输入你的 Anthropic API Key:',
|
||||
openai: '请输入你的 OpenAI API Key:',
|
||||
deepseek: '请输入你的 DeepSeek API Key:',
|
||||
};
|
||||
|
||||
// 分开询问 API Key
|
||||
const { apiKey } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'apiKey',
|
||||
message: isOpenAICompatible ? '请输入你的 API Key:' : apiKeyMessageMap[actualProvider],
|
||||
validate: (input: string) => input.length > 0 || 'API Key 不能为空',
|
||||
},
|
||||
]);
|
||||
|
||||
// 询问模型配置
|
||||
const { model: selectedModel } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'model',
|
||||
message: '选择默认模型:',
|
||||
choices: modelChoices,
|
||||
default: DEFAULT_MODELS[actualProvider as ProviderType],
|
||||
},
|
||||
]);
|
||||
|
||||
// 如果选择自定义模型,询问模型名称
|
||||
let finalModel = selectedModel;
|
||||
if (allowCustomModel && selectedModel === '__custom__') {
|
||||
const { customModel } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customModel',
|
||||
message: '请输入模型名称:',
|
||||
validate: (input: string) => input.length > 0 || '模型名称不能为空',
|
||||
},
|
||||
]);
|
||||
finalModel = customModel;
|
||||
}
|
||||
|
||||
// 询问 token 配置
|
||||
const { maxTokens } = await inquirer.prompt([
|
||||
{
|
||||
type: 'number',
|
||||
name: 'maxTokens',
|
||||
message: '最大输出 token 数:',
|
||||
default: 4096,
|
||||
},
|
||||
]);
|
||||
|
||||
// 根据 provider 构建配置对象
|
||||
const configToSave: Partial<StoredConfig> = {
|
||||
provider: actualProvider as ProviderType,
|
||||
model: finalModel,
|
||||
maxTokens,
|
||||
};
|
||||
|
||||
// 存储 API Key 到对应字段
|
||||
if (actualProvider === 'anthropic') {
|
||||
configToSave.apiKey = apiKey;
|
||||
} else if (actualProvider === 'openai') {
|
||||
configToSave.openaiApiKey = apiKey;
|
||||
} else if (actualProvider === 'deepseek') {
|
||||
configToSave.deepseekApiKey = apiKey;
|
||||
}
|
||||
|
||||
// 存储 base URL
|
||||
if (baseUrl) {
|
||||
configToSave.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
saveConfig(configToSave);
|
||||
console.log('\n✅ 配置已保存到', CONFIG_FILE);
|
||||
console.log('现在可以运行 ai-assist 开始使用了!\n');
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 文件 diff 对比和确认工具
|
||||
* 用于在写入文件前显示变更并让用户确认
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'add' | 'remove' | 'context';
|
||||
lineNumber: number | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
oldContent: string | null;
|
||||
newContent: string;
|
||||
isNew: boolean;
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个字符串的 diff
|
||||
* 使用简化的 LCS (最长公共子序列) 算法
|
||||
*/
|
||||
export function computeDiff(oldContent: string | null, newContent: string): DiffResult {
|
||||
const isNew = oldContent === null;
|
||||
const oldLines = oldContent ? oldContent.split('\n') : [];
|
||||
const newLines = newContent.split('\n');
|
||||
|
||||
if (isNew) {
|
||||
// 新文件,所有行都是新增
|
||||
return {
|
||||
oldContent,
|
||||
newContent,
|
||||
isNew: true,
|
||||
hunks: [{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: newLines.length,
|
||||
lines: newLines.map((line, i) => ({
|
||||
type: 'add' as const,
|
||||
lineNumber: i + 1,
|
||||
content: line,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// 计算 diff hunks
|
||||
const hunks = computeHunks(oldLines, newLines);
|
||||
|
||||
return {
|
||||
oldContent,
|
||||
newContent,
|
||||
isNew: false,
|
||||
hunks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 diff hunks
|
||||
*/
|
||||
function computeHunks(oldLines: string[], newLines: string[]): DiffHunk[] {
|
||||
// 使用简化的 diff 算法
|
||||
const lcs = computeLCS(oldLines, newLines);
|
||||
const hunks: DiffHunk[] = [];
|
||||
|
||||
let oldIdx = 0;
|
||||
let newIdx = 0;
|
||||
let lcsIdx = 0;
|
||||
let currentHunk: DiffHunk | null = null;
|
||||
|
||||
const CONTEXT_LINES = 3;
|
||||
|
||||
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
||||
const lcsLine = lcsIdx < lcs.length ? lcs[lcsIdx] : null;
|
||||
|
||||
// 检查是否匹配 LCS
|
||||
const oldMatch = lcsLine !== null && oldIdx < oldLines.length && oldLines[oldIdx] === lcsLine.content;
|
||||
const newMatch = lcsLine !== null && newIdx < newLines.length && newLines[newIdx] === lcsLine.content;
|
||||
|
||||
if (oldMatch && newMatch) {
|
||||
// 上下文行
|
||||
if (currentHunk) {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
lineNumber: newIdx + 1,
|
||||
content: newLines[newIdx],
|
||||
});
|
||||
currentHunk.oldCount++;
|
||||
currentHunk.newCount++;
|
||||
}
|
||||
oldIdx++;
|
||||
newIdx++;
|
||||
lcsIdx++;
|
||||
} else if (!oldMatch && oldIdx < oldLines.length && (lcsLine === null || oldLines[oldIdx] !== lcsLine.content)) {
|
||||
// 删除行
|
||||
if (!currentHunk) {
|
||||
currentHunk = {
|
||||
oldStart: oldIdx + 1,
|
||||
oldCount: 0,
|
||||
newStart: newIdx + 1,
|
||||
newCount: 0,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
currentHunk.lines.push({
|
||||
type: 'remove',
|
||||
lineNumber: oldIdx + 1,
|
||||
content: oldLines[oldIdx],
|
||||
});
|
||||
currentHunk.oldCount++;
|
||||
oldIdx++;
|
||||
} else if (!newMatch && newIdx < newLines.length) {
|
||||
// 新增行
|
||||
if (!currentHunk) {
|
||||
currentHunk = {
|
||||
oldStart: oldIdx + 1,
|
||||
oldCount: 0,
|
||||
newStart: newIdx + 1,
|
||||
newCount: 0,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
currentHunk.lines.push({
|
||||
type: 'add',
|
||||
lineNumber: newIdx + 1,
|
||||
content: newLines[newIdx],
|
||||
});
|
||||
currentHunk.newCount++;
|
||||
newIdx++;
|
||||
} else {
|
||||
// 匹配但还没到
|
||||
if (currentHunk && currentHunk.lines.length > 0) {
|
||||
// 检查是否应该结束当前 hunk
|
||||
const lastNonContext = [...currentHunk.lines].reverse().findIndex(l => l.type !== 'context');
|
||||
if (lastNonContext > CONTEXT_LINES) {
|
||||
hunks.push(currentHunk);
|
||||
currentHunk = null;
|
||||
}
|
||||
}
|
||||
oldIdx++;
|
||||
newIdx++;
|
||||
if (lcsLine) lcsIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk && currentHunk.lines.some(l => l.type !== 'context')) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
// 如果没有实际变化,返回空
|
||||
if (hunks.length === 0 && oldLines.join('\n') !== newLines.join('\n')) {
|
||||
// 全文替换的情况
|
||||
return [{
|
||||
oldStart: 1,
|
||||
oldCount: oldLines.length,
|
||||
newStart: 1,
|
||||
newCount: newLines.length,
|
||||
lines: [
|
||||
...oldLines.map((line, i) => ({
|
||||
type: 'remove' as const,
|
||||
lineNumber: i + 1,
|
||||
content: line,
|
||||
})),
|
||||
...newLines.map((line, i) => ({
|
||||
type: 'add' as const,
|
||||
lineNumber: i + 1,
|
||||
content: line,
|
||||
})),
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最长公共子序列
|
||||
*/
|
||||
function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: string; oldIdx: number; newIdx: number }> {
|
||||
const m = oldLines.length;
|
||||
const n = newLines.length;
|
||||
|
||||
// DP 表
|
||||
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回溯找出 LCS
|
||||
const result: Array<{ content: string; oldIdx: number; newIdx: number }> = [];
|
||||
let i = m, j = n;
|
||||
|
||||
while (i > 0 && j > 0) {
|
||||
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||
result.unshift({ content: oldLines[i - 1], oldIdx: i - 1, newIdx: j - 1 });
|
||||
i--;
|
||||
j--;
|
||||
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||
i--;
|
||||
} else {
|
||||
j--;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 diff 输出
|
||||
*/
|
||||
export function formatDiff(diff: DiffResult, filePath: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (diff.isNew) {
|
||||
lines.push(chalk.green(`+++ 新文件: ${filePath}`));
|
||||
} else {
|
||||
lines.push(chalk.gray(`--- ${filePath} (原文件)`));
|
||||
lines.push(chalk.green(`+++ ${filePath} (修改后)`));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
for (const hunk of diff.hunks) {
|
||||
// Hunk 头部
|
||||
lines.push(chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`));
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
const lineNum = line.lineNumber ? chalk.gray(`${line.lineNumber.toString().padStart(4)} `) : ' ';
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
lines.push(chalk.green(`${lineNum}+ ${line.content}`));
|
||||
break;
|
||||
case 'remove':
|
||||
lines.push(chalk.red(`${lineNum}- ${line.content}`));
|
||||
break;
|
||||
case 'context':
|
||||
lines.push(chalk.gray(`${lineNum} ${line.content}`));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计变更数量
|
||||
*/
|
||||
export function countChanges(diff: DiffResult): { additions: number; deletions: number } {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (const hunk of diff.hunks) {
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'add') additions++;
|
||||
if (line.type === 'remove') deletions++;
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
export interface FileConfirmResult {
|
||||
confirmed: boolean;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 diff 并让用户确认
|
||||
*/
|
||||
export async function confirmFileChange(
|
||||
filePath: string,
|
||||
newContent: string,
|
||||
operation: 'write' | 'edit'
|
||||
): Promise<FileConfirmResult> {
|
||||
// 读取原文件内容
|
||||
let oldContent: string | null = null;
|
||||
try {
|
||||
oldContent = await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
// 文件不存在,是新文件
|
||||
}
|
||||
|
||||
// 如果内容相同,直接通过
|
||||
if (oldContent === newContent) {
|
||||
return { confirmed: true, remember: false };
|
||||
}
|
||||
|
||||
// 计算 diff
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
const changes = countChanges(diff);
|
||||
|
||||
// 显示 diff
|
||||
console.log('');
|
||||
console.log(chalk.yellow('📝 文件变更预览'));
|
||||
console.log(chalk.cyan('操作: ') + chalk.white(operation === 'write' ? '写入文件' : '编辑文件'));
|
||||
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
|
||||
console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`));
|
||||
console.log('');
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
console.log(formatDiff(diff, filePath));
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
console.log('');
|
||||
|
||||
// 询问用户确认
|
||||
return promptFileConfirm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示用户确认文件操作
|
||||
*/
|
||||
async function promptFileConfirm(): Promise<FileConfirmResult> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
console.log(chalk.white('选择操作:'));
|
||||
console.log(chalk.green(' [y] ') + '确认写入');
|
||||
console.log(chalk.green(' [Y] ') + '确认写入,并记住此类操作(本次会话)');
|
||||
console.log(chalk.red(' [n] ') + '取消操作');
|
||||
console.log(chalk.red(' [N] ') + '取消操作,并记住此类操作(本次会话)');
|
||||
console.log('');
|
||||
|
||||
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
|
||||
rl.close();
|
||||
|
||||
const choice = answer.trim();
|
||||
|
||||
switch (choice) {
|
||||
case 'y':
|
||||
resolve({ confirmed: true, remember: false });
|
||||
break;
|
||||
case 'Y':
|
||||
resolve({ confirmed: true, remember: true });
|
||||
break;
|
||||
case 'N':
|
||||
resolve({ confirmed: false, remember: true });
|
||||
break;
|
||||
case 'n':
|
||||
default:
|
||||
resolve({ confirmed: false, remember: false });
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的 diff 显示(用于编辑操作,只显示变更部分)
|
||||
*/
|
||||
export function formatEditDiff(oldString: string, newString: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(chalk.gray('变更内容:'));
|
||||
|
||||
// 显示删除的内容
|
||||
const oldLines = oldString.split('\n');
|
||||
for (const line of oldLines) {
|
||||
lines.push(chalk.red(`- ${line}`));
|
||||
}
|
||||
|
||||
// 显示新增的内容
|
||||
const newLines = newString.split('\n');
|
||||
for (const line of newLines) {
|
||||
lines.push(chalk.green(`+ ${line}`));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 图片处理工具
|
||||
*
|
||||
* 提供图片文件读取、格式检测、base64 编码等功能
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
/** 支持的图片扩展名 */
|
||||
export const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
|
||||
|
||||
/** 图片 MIME 类型映射 */
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
/** 图片信息 */
|
||||
export interface ImageInfo {
|
||||
/** 原始文件路径 */
|
||||
path: string;
|
||||
/** 文件名 */
|
||||
filename: string;
|
||||
/** 扩展名 */
|
||||
extension: string;
|
||||
/** MIME 类型 */
|
||||
mimeType: string;
|
||||
/** 文件大小(字节) */
|
||||
size: number;
|
||||
/** base64 编码的数据 */
|
||||
base64: string;
|
||||
/** 完整的 data URL */
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
/** 图片加载结果 */
|
||||
export interface ImageLoadResult {
|
||||
success: boolean;
|
||||
image?: ImageInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件路径是否为图片
|
||||
*/
|
||||
export function isImagePath(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从输入文本中提取图片引用
|
||||
* 支持多种格式:
|
||||
* 1. @path/to/image.png(不带空格的路径)
|
||||
* 2. @"path/to/image with spaces.png"(带空格的路径用引号包裹)
|
||||
* 3. @/path/to/image.png(绝对路径,自动匹配到图片扩展名结束)
|
||||
*
|
||||
* @param input 用户输入
|
||||
* @returns 图片路径列表和去除图片引用后的文本
|
||||
*/
|
||||
export function extractImageReferences(input: string): {
|
||||
imagePaths: string[];
|
||||
textContent: string;
|
||||
} {
|
||||
const imagePaths: string[] = [];
|
||||
let textContent = input;
|
||||
|
||||
// 模式1: 带引号的路径 @"path/to/image.png" 或 @'path/to/image.png'
|
||||
const quotedMatches = [...input.matchAll(/@["']([^"']+\.(?:png|jpg|jpeg|gif|webp))["']/gi)];
|
||||
for (const match of quotedMatches) {
|
||||
imagePaths.push(match[1]);
|
||||
textContent = textContent.replace(match[0], ' ');
|
||||
}
|
||||
|
||||
// 模式2: 绝对路径(以 / 或 ~ 开头,匹配到图片扩展名结束)
|
||||
// 支持路径中包含空格
|
||||
const absoluteMatches = [...textContent.matchAll(/@([/~][^\n]*?\.(?:png|jpg|jpeg|gif|webp))(?=\s|$)/gi)];
|
||||
for (const match of absoluteMatches) {
|
||||
if (!imagePaths.includes(match[1])) {
|
||||
imagePaths.push(match[1]);
|
||||
textContent = textContent.replace(match[0], ' ');
|
||||
}
|
||||
}
|
||||
|
||||
// 模式3: 相对路径(不以 / 开头,不包含空格)
|
||||
const relativeMatches = [...textContent.matchAll(/@((?:\.\/|\.\.\/)?[^\s@"'/][^\s@"']*\.(?:png|jpg|jpeg|gif|webp))/gi)];
|
||||
for (const match of relativeMatches) {
|
||||
if (!imagePaths.includes(match[1])) {
|
||||
imagePaths.push(match[1]);
|
||||
textContent = textContent.replace(match[0], ' ');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理多余空格
|
||||
textContent = textContent.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { imagePaths, textContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片文件
|
||||
* @param filePath 图片路径(相对或绝对)
|
||||
* @param workdir 工作目录(用于解析相对路径)
|
||||
*/
|
||||
export async function loadImage(
|
||||
filePath: string,
|
||||
workdir: string = process.cwd()
|
||||
): Promise<ImageLoadResult> {
|
||||
try {
|
||||
// 解析路径
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(workdir, filePath);
|
||||
|
||||
// 检查扩展名
|
||||
const ext = path.extname(absolutePath).toLowerCase();
|
||||
if (!IMAGE_EXTENSIONS.includes(ext)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `不支持的图片格式: ${ext}。支持的格式: ${IMAGE_EXTENSIONS.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
const buffer = await fs.readFile(absolutePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
// 转换为 base64
|
||||
const base64 = buffer.toString('base64');
|
||||
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
image: {
|
||||
path: absolutePath,
|
||||
filename: path.basename(absolutePath),
|
||||
extension: ext,
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
base64,
|
||||
dataUrl,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {
|
||||
success: false,
|
||||
error: `图片文件不存在: ${filePath}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `加载图片失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载图片
|
||||
* @param filePaths 图片路径列表
|
||||
* @param workdir 工作目录
|
||||
*/
|
||||
export async function loadImages(
|
||||
filePaths: string[],
|
||||
workdir: string = process.cwd()
|
||||
): Promise<{
|
||||
images: ImageInfo[];
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}> {
|
||||
const images: ImageInfo[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const result = await loadImage(filePath, workdir);
|
||||
if (result.success && result.image) {
|
||||
images.push(result.image);
|
||||
} else {
|
||||
errors.push({ path: filePath, error: result.error || '未知错误' });
|
||||
}
|
||||
}
|
||||
|
||||
return { images, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user