open-customer-session.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { DatabaseSync } = require('node:sqlite');
  6. const spawn = require('cross-spawn');
  7. const PROJECT_ROOT = path.resolve(__dirname, '..');
  8. function loadEnvFile(filePath) {
  9. if (!fs.existsSync(filePath)) return;
  10. for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
  11. const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
  12. if (!match || process.env[match[1]] !== undefined) continue;
  13. let value = match[2].trim();
  14. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  15. value = value.slice(1, -1);
  16. }
  17. process.env[match[1]] = value;
  18. }
  19. }
  20. function maskContact(value) {
  21. const text = String(value || '');
  22. if (text.length <= 4) return '***';
  23. return `${text.slice(0, 2)}***${text.slice(-2)}`;
  24. }
  25. function safeLabel(value) {
  26. return String(value || '企微客户')
  27. .trim()
  28. .replace(/[\\/:*?"<>|\r\n]+/g, '-')
  29. .replace(/\s+/g, '-')
  30. .replace(/-+/g, '-')
  31. .replace(/^-|-$/g, '')
  32. .slice(0, 24) || '企微客户';
  33. }
  34. function parseArgs(argv) {
  35. const args = { list: false, dryRun: false, customer: '', index: 0 };
  36. for (let i = 0; i < argv.length; i += 1) {
  37. if (argv[i] === '--list') args.list = true;
  38. else if (argv[i] === '--dry-run') args.dryRun = true;
  39. else if (argv[i] === '--customer') args.customer = String(argv[++i] || '').trim();
  40. else if (argv[i] === '--index') args.index = Number(argv[++i] || 0);
  41. else if (argv[i] === '--help' || argv[i] === '-h') args.help = true;
  42. }
  43. return args;
  44. }
  45. function readCustomerSessions({ sessionFile, dbPath }) {
  46. if (!fs.existsSync(sessionFile)) throw new Error('尚未创建客户 Claude Code Session');
  47. if (!fs.existsSync(dbPath)) throw new Error('企微 Workbench 数据库不存在');
  48. const state = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
  49. const db = new DatabaseSync(dbPath, { readOnly: true });
  50. try {
  51. return Object.entries(state.sessions || {})
  52. .filter(([, session]) => session.role === 'customer-agent')
  53. .map(([conversationId, session]) => {
  54. const conversation = db.prepare('SELECT contact_name, contact_id FROM conversations WHERE id=?').get(conversationId);
  55. return {
  56. conversationId,
  57. session,
  58. customerName: conversation?.contact_name || session.customerName || '未命名客户',
  59. maskedContact: maskContact(conversation?.contact_id),
  60. projectRoot: state.project?.projectRoot || PROJECT_ROOT,
  61. };
  62. })
  63. .sort((a, b) => String(a.customerName).localeCompare(String(b.customerName), 'zh-CN'));
  64. } finally {
  65. db.close();
  66. }
  67. }
  68. function printList(rows) {
  69. if (!rows.length) {
  70. process.stdout.write('当前没有客户 Claude Code Session。\n');
  71. return;
  72. }
  73. process.stdout.write('可查看的客户 Claude Code Session:\n');
  74. rows.forEach((row, index) => {
  75. const status = row.session.initialized ? '已有对话' : '尚未初始化';
  76. process.stdout.write(`${index + 1}. ${row.customerName}(${row.maskedContact})· ${status}\n`);
  77. });
  78. }
  79. function selectSession(rows, args) {
  80. if (args.index) return rows[args.index - 1] || null;
  81. if (args.customer) {
  82. const exact = rows.filter(row => row.customerName === args.customer);
  83. if (exact.length === 1) return exact[0];
  84. const fuzzy = rows.filter(row => row.customerName.includes(args.customer));
  85. if (fuzzy.length === 1) return fuzzy[0];
  86. return null;
  87. }
  88. return rows.length === 1 ? rows[0] : null;
  89. }
  90. function findTranscript(sessionId) {
  91. const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
  92. if (!fs.existsSync(projectsRoot)) return false;
  93. const target = `${sessionId}.jsonl`;
  94. const queue = [projectsRoot];
  95. while (queue.length) {
  96. const current = queue.shift();
  97. for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
  98. const fullPath = path.join(current, entry.name);
  99. if (entry.isDirectory()) queue.push(fullPath);
  100. else if (entry.name === target) return true;
  101. }
  102. }
  103. return false;
  104. }
  105. function resolveClaudeCommand() {
  106. const candidates = [
  107. process.env.CLAUDE_CODE_EXECUTABLE,
  108. path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  109. process.platform === 'win32' ? 'claude.cmd' : 'claude',
  110. ].filter(Boolean);
  111. return candidates.find(candidate => !path.isAbsolute(candidate) || fs.existsSync(candidate)) || candidates.at(-1);
  112. }
  113. async function main() {
  114. loadEnvFile(path.join(PROJECT_ROOT, '.env.local'));
  115. const args = parseArgs(process.argv.slice(2));
  116. if (args.help) {
  117. process.stdout.write([
  118. '查看客户 Claude Code Session:',
  119. ' npm run agent:session:list',
  120. ' npm run agent:session -- --customer <客户名称>',
  121. '',
  122. '打开时会自动 fork 一份审阅会话;查看和追问不会污染生产客户 Session。',
  123. '',
  124. ].join('\n'));
  125. return;
  126. }
  127. const outputsRoot = path.resolve(process.env.QIWEI_OUTPUTS_DIR || path.join(PROJECT_ROOT, 'outputs'));
  128. const sessionFile = path.resolve(process.env.CLAUDE_CODE_SESSION_FILE || path.join(outputsRoot, 'messages', 'claude-code-sessions.json'));
  129. const dbPath = path.resolve(process.env.QIWEI_AGENT_DB_PATH || path.join(outputsRoot, 'messages', 'agent-workbench.db'));
  130. const rows = readCustomerSessions({ sessionFile, dbPath });
  131. if (args.list) {
  132. printList(rows);
  133. return;
  134. }
  135. const selected = selectSession(rows, args);
  136. if (!selected) {
  137. printList(rows);
  138. throw new Error('没有唯一匹配的客户,请通过 --customer <客户名称> 或 --index <序号> 选择');
  139. }
  140. if (!selected.session.initialized) throw new Error('该客户 Session 尚未产生 Claude Code 对话');
  141. const reviewName = `审阅-${safeLabel(selected.customerName)}`;
  142. const transcriptFound = findTranscript(selected.session.id);
  143. if (args.dryRun) {
  144. process.stdout.write(`${JSON.stringify({
  145. status: 'ok',
  146. customer: selected.customerName,
  147. maskedContact: selected.maskedContact,
  148. transcriptFound,
  149. openMode: 'forked-review',
  150. productionSessionProtected: true,
  151. }, null, 2)}\n`);
  152. return;
  153. }
  154. if (!transcriptFound) throw new Error('没有找到该客户的本地 Claude Code 会话记录');
  155. process.stdout.write(`正在打开 ${selected.customerName}(${selected.maskedContact})的 Claude Code 审阅会话。\n`);
  156. process.stdout.write('系统会先 fork 审阅副本;你在其中查看或追问,不会污染生产客户 Session。\n');
  157. const child = spawn(resolveClaudeCommand(), [
  158. '--resume', selected.session.id,
  159. '--fork-session',
  160. '--name', reviewName,
  161. ], {
  162. cwd: fs.existsSync(selected.projectRoot) ? selected.projectRoot : PROJECT_ROOT,
  163. stdio: 'inherit',
  164. });
  165. child.on('error', error => {
  166. process.stderr.write(`无法打开 Claude Code:${error.message}\n`);
  167. process.exitCode = 1;
  168. });
  169. child.on('exit', code => { process.exitCode = Number(code || 0); });
  170. }
  171. if (require.main === module) {
  172. main().catch(error => {
  173. process.stderr.write(`${error.message}\n`);
  174. process.exitCode = 1;
  175. });
  176. }
  177. module.exports = { maskContact, parseArgs, readCustomerSessions, selectSession };