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
@@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SkillLoader } from '../../../src/skills/loader.js';
// Mock fs/promises
vi.mock('fs/promises', () => ({
access: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
}));
import * as fs from 'fs/promises';
describe('SkillLoader - Skill 加载器', () => {
let loader: SkillLoader;
beforeEach(() => {
vi.clearAllMocks();
loader = new SkillLoader();
});
describe('loadFromFile - 从文件加载', () => {
it('加载 YAML 格式的 Skill', async () => {
const yamlContent = `
skill:
name: test-skill
description: 测试 Skill
promptTemplate: "提示模板 {{param}}"
parameters:
param:
type: string
required: true
description: 参数描述
`;
vi.mocked(fs.readFile).mockResolvedValue(yamlContent);
const skill = await loader.loadFromFile('/test/skill.yaml', 'user');
expect(skill).not.toBeNull();
expect(skill?.name).toBe('test-skill');
expect(skill?.description).toBe('测试 Skill');
expect(skill?.source).toBe('user');
expect(skill?.parameters?.param.required).toBe(true);
});
it('加载 JSON 格式的 Skill', async () => {
const jsonContent = JSON.stringify({
skill: {
name: 'json-skill',
description: 'JSON Skill',
promptTemplate: '提示',
},
});
vi.mocked(fs.readFile).mockResolvedValue(jsonContent);
const skill = await loader.loadFromFile('/test/skill.json', 'project');
expect(skill).not.toBeNull();
expect(skill?.name).toBe('json-skill');
expect(skill?.source).toBe('project');
});
it('加载 Markdown 格式的 Skill(带 frontmatter', async () => {
const mdContent = `---
name: md-skill
description: Markdown Skill
parameters:
code:
type: string
required: true
description: 代码
---
# 代码审查
请审查以下代码:
{{code}}
`;
vi.mocked(fs.readFile).mockResolvedValue(mdContent);
const skill = await loader.loadFromFile('/test/skill.md', 'user');
expect(skill).not.toBeNull();
expect(skill?.name).toBe('md-skill');
expect(skill?.description).toBe('Markdown Skill');
expect(skill?.promptTemplate).toContain('{{code}}');
expect(skill?.promptTemplate).toContain('代码审查');
});
it('加载 Markdown 格式的 Skill(无 frontmatter', async () => {
const mdContent = `# 简单提示
这是一个简单的提示模板。
{{input}}
`;
vi.mocked(fs.readFile).mockResolvedValue(mdContent);
const skill = await loader.loadFromFile('/test/simple.md', 'user');
expect(skill).not.toBeNull();
expect(skill?.name).toBe('simple'); // 从文件名获取
expect(skill?.promptTemplate).toContain('简单提示');
});
it('缺少必需字段时返回 null', async () => {
const yamlContent = `
skill:
description: 没有 name 和 promptTemplate
`;
vi.mocked(fs.readFile).mockResolvedValue(yamlContent);
const skill = await loader.loadFromFile('/test/invalid.yaml', 'user');
expect(skill).toBeNull();
});
});
describe('loadFromDirectory - 从目录加载', () => {
it('目录不存在时返回空数组', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
const skills = await loader.loadFromDirectory('/non-existent', 'user');
expect(skills).toEqual([]);
});
it('加载目录中的所有 Skill 文件', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'skill1.yaml', isFile: () => true, isDirectory: () => false },
{ name: 'skill2.json', isFile: () => true, isDirectory: () => false },
{ name: 'readme.txt', isFile: () => true, isDirectory: () => false }, // 不支持的格式
] as any);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(`skill:\n name: skill1\n description: 1\n promptTemplate: t1`)
.mockResolvedValueOnce(JSON.stringify({ skill: { name: 'skill2', description: '2', promptTemplate: 't2' } }));
const skills = await loader.loadFromDirectory('/test', 'user');
expect(skills.length).toBe(2);
expect(skills.map((s) => s.name)).toContain('skill1');
expect(skills.map((s) => s.name)).toContain('skill2');
});
it('递归加载子目录', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir)
.mockResolvedValueOnce([
{ name: 'subdir', isFile: () => false, isDirectory: () => true },
{ name: 'root.yaml', isFile: () => true, isDirectory: () => false },
] as any)
.mockResolvedValueOnce([
{ name: 'sub.yaml', isFile: () => true, isDirectory: () => false },
] as any);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(`skill:\n name: root\n description: root\n promptTemplate: root`)
.mockResolvedValueOnce(`skill:\n name: sub\n description: sub\n promptTemplate: sub`);
const skills = await loader.loadFromDirectory('/test', 'user');
expect(skills.length).toBe(2);
expect(skills.map((s) => s.name)).toContain('root');
expect(skills.map((s) => s.name)).toContain('sub');
});
});
describe('目录路径', () => {
it('getUserSkillsDir 返回用户 Skills 目录', () => {
const originalHome = process.env.HOME;
process.env.HOME = '/home/testuser';
const dir = loader.getUserSkillsDir();
expect(dir).toBe('/home/testuser/.config/ai-terminal/skills');
process.env.HOME = originalHome;
});
it('getProjectSkillsDir 返回项目 Skills 目录', () => {
const dir = loader.getProjectSkillsDir('/workspace');
expect(dir).toBe('/workspace/.ai-terminal/skills');
});
});
});