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:
2025-12-12 10:42:20 +08:00
parent 59dbed926e
commit 5e32375f0e
301 changed files with 3281 additions and 43 deletions
+386
View File
@@ -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];
// 确定 baseUrlVision 专用)
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');
}
+394
View File
@@ -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');
}
+201
View File
@@ -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`;
}
}