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
|
|||
|
|
`);
|
|||
|
|
});
|