Files
闫旭隆 e0aff02e31 20260109
2026-01-09 13:36:01 +08:00

351 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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