Files
AIEC_Skills/requirement-web/server.js

351 lines
10 KiB
JavaScript
Raw Normal View History

2026-01-09 13:36:01 +08:00
/**
* requirement-generator-v1 Skill Web 服务器
*
* 功能
* 1. 通过 SDK 调用 requirement-generator-v1 Skill
* 2. 支持 AskUserQuestion 交互
* 3. 支持多轮对话
* 4. 文档展示和下载
*/
import express from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Skills 项目根目录(包含 .claude/skills/ 和 .claude/agents/
const SKILLS_PROJECT_DIR = path.resolve(__dirname, '..');
const app = express();
const PORT = 3457;
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// 存储待处理的 AskUserQuestion 请求
const pendingQuestions = new Map();
// 存储会话信息
const sessions = new Map();
/**
* SSE 流式执行 Skill
* GET /api/stream?prompt=xxx&sessionId=xxx
*/
app.get('/api/stream', async (req, res) => {
const { prompt, sessionId } = req.query;
const requestId = Date.now().toString();
const existingClaudeSessionId = sessionId ? sessions.get(sessionId) : null;
console.log('\n========================================');
console.log('[Server] 收到请求');
console.log('[Server] Prompt:', prompt);
console.log('[Server] SessionId:', existingClaudeSessionId || '(新会话)');
console.log('[Server] CWD:', SKILLS_PROJECT_DIR);
console.log('========================================\n');
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Request-Id', requestId);
const sendEvent = (type, data) => {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
try {
const options = {
// 项目目录 - 必须包含 .claude/skills/ 和 .claude/agents/
cwd: SKILLS_PROJECT_DIR,
// 权限模式 - 自动执行所有工具
permissionMode: 'bypassPermissions',
// 启用 Skill 和 Agent 加载
settingSources: ['user', 'project'],
// 不指定 allowedTools让 SDK 使用默认值(允许所有工具)
// 继续会话
...(existingClaudeSessionId && { resume: existingClaudeSessionId }),
// 处理 AskUserQuestion
canUseTool: async (toolName, input) => {
console.log(`[SDK] canUseTool: ${toolName}`);
if (toolName === 'AskUserQuestion') {
sendEvent('ask_user_question', {
requestId,
questions: input.questions
});
return new Promise((resolve) => {
pendingQuestions.set(requestId, (answers) => {
console.log('[SDK] 收到用户回答:', answers);
resolve({
behavior: 'allow',
updatedInput: {
questions: input.questions,
answers: answers
}
});
});
// 60分钟超时
setTimeout(() => {
if (pendingQuestions.has(requestId)) {
console.log('[SDK] 等待用户回答超时 (60分钟)');
pendingQuestions.delete(requestId);
resolve({ behavior: 'deny' });
}
}, 3600000);
});
}
return { behavior: 'allow' };
}
};
sendEvent('status', {
message: existingClaudeSessionId
? `继续会话 ${existingClaudeSessionId.substring(0, 8)}...`
: '正在启动 Skill...'
});
const response = query({
prompt: prompt || '生成需求文档',
options
});
let currentClaudeSessionId = existingClaudeSessionId;
for await (const message of response) {
console.log('\n[SDK] ────────────────────────────');
console.log('[SDK] Message type:', message.type);
if (message.subtype) console.log('[SDK] Subtype:', message.subtype);
// 详细日志
if (message.type === 'assistant' && message.message?.content) {
const content = message.message.content;
if (Array.isArray(content)) {
content.forEach((block, i) => {
if (block.type === 'text') {
const text = block.text.substring(0, 200);
console.log(`[SDK] Content[${i}] text:`, text + (block.text.length > 200 ? '...' : ''));
} else if (block.type === 'tool_use') {
console.log(`[SDK] Content[${i}] tool_use:`, block.name);
console.log('[SDK] Input:', JSON.stringify(block.input, null, 2).substring(0, 500));
}
});
} else if (typeof content === 'string') {
console.log('[SDK] Content:', content.substring(0, 200));
}
}
if (message.type === 'user' && message.message?.content) {
const content = message.message.content;
if (Array.isArray(content)) {
content.forEach((block, i) => {
if (block.type === 'tool_result') {
const result = typeof block.content === 'string'
? block.content.substring(0, 300)
: JSON.stringify(block.content).substring(0, 300);
console.log(`[SDK] ToolResult[${i}]:`, result + '...');
}
});
}
}
if (message.type === 'system' && message.subtype === 'init') {
currentClaudeSessionId = message.session_id;
console.log('[SDK] Session ID:', currentClaudeSessionId);
const clientSessionId = sessionId || currentClaudeSessionId;
sessions.set(clientSessionId, currentClaudeSessionId);
sendEvent('session', {
clientSessionId,
claudeSessionId: currentClaudeSessionId,
isNewSession: !existingClaudeSessionId
});
}
else if (message.type === 'stream_event') {
const event = message.event || {};
if (event.type === 'content_block_delta') {
const delta = event.delta;
let text = '';
if (typeof delta === 'string') {
text = delta;
} else if (delta?.text) {
text = delta.text;
}
if (text) {
sendEvent('delta', { text });
}
}
else if (event.type === 'content_block_start') {
const block = event.content_block;
if (block?.type === 'tool_use') {
sendEvent('tool_start', {
toolName: block.name,
toolId: block.id
});
}
}
}
else if (message.type === 'assistant') {
let content = '';
const toolCalls = [];
if (typeof message.message?.content === 'string') {
content = message.message.content;
} else if (Array.isArray(message.message?.content)) {
for (const block of message.message.content) {
if (block.type === 'text') {
content += block.text;
} else if (block.type === 'tool_use') {
toolCalls.push({
name: block.name,
id: block.id,
input: block.input
});
}
}
}
if (content) {
sendEvent('assistant', { content });
}
if (toolCalls.length > 0) {
sendEvent('tool_calls', { tools: toolCalls });
}
}
else if (message.type === 'result') {
sendEvent('result', {
subtype: message.subtype,
sessionId: currentClaudeSessionId
});
}
}
sendEvent('done', {
message: '执行完成',
sessionId: currentClaudeSessionId
});
} catch (error) {
console.error('[Server] 错误:', error);
sendEvent('error', { message: error.message });
} finally {
pendingQuestions.delete(requestId);
res.end();
}
});
/**
* 提交用户回答
* POST /api/answer
*/
app.post('/api/answer', (req, res) => {
const { requestId, answers } = req.body;
console.log('[Server] 收到用户回答:', { requestId, answers });
const resolver = pendingQuestions.get(requestId);
if (resolver) {
resolver(answers);
pendingQuestions.delete(requestId);
res.json({ success: true });
} else {
res.status(404).json({ error: '未找到待处理的问题' });
}
});
/**
* 获取文档内容
* GET /api/document?type=final|draft
*/
app.get('/api/document', (req, res) => {
const type = req.query.type || 'final';
// 优先查找 requirement_final.md否则 requirement.md
const finalPath = path.join(SKILLS_PROJECT_DIR, 'requirement_final.md');
const draftPath = path.join(SKILLS_PROJECT_DIR, 'requirement.md');
let filePath;
let docType;
if (type === 'final' && fs.existsSync(finalPath)) {
filePath = finalPath;
docType = 'final';
} else if (fs.existsSync(draftPath)) {
filePath = draftPath;
docType = 'draft';
} else {
return res.status(404).json({ error: '文档不存在' });
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
res.json({
type: docType,
filename: path.basename(filePath),
content
});
} catch (error) {
res.status(500).json({ error: '读取文档失败: ' + error.message });
}
});
/**
* 下载文档
* GET /api/download?type=final|draft
*/
app.get('/api/download', (req, res) => {
const type = req.query.type || 'final';
const finalPath = path.join(SKILLS_PROJECT_DIR, 'requirement_final.md');
const draftPath = path.join(SKILLS_PROJECT_DIR, 'requirement.md');
let filePath;
if (type === 'final' && fs.existsSync(finalPath)) {
filePath = finalPath;
} else if (fs.existsSync(draftPath)) {
filePath = draftPath;
} else {
return res.status(404).json({ error: '文档不存在' });
}
res.download(filePath);
});
/**
* 获取会话列表
*/
app.get('/api/sessions', (req, res) => {
const sessionList = [];
for (const [clientId, claudeId] of sessions.entries()) {
sessionList.push({ clientId, claudeId });
}
res.json({ sessions: sessionList });
});
app.listen(PORT, () => {
console.log(`
========================================
需求文档生成器 Web 服务已启动
地址: http://localhost:${PORT}
========================================
项目目录: ${SKILLS_PROJECT_DIR}
Skill: requirement-generator-v1
使用前请确保已安装依赖: npm install
`);
});