351 lines
10 KiB
JavaScript
351 lines
10 KiB
JavaScript
/**
|
||
* 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
|
||
`);
|
||
});
|