const http = require('http'); const fs = require('fs'); const path = require('path'); const { URL } = require('url'); const { outputsRoot, categoryDir } = require('../core/output-paths'); const { resolveWorkspaceRoot, workspaceIdentity } = require('../core/runtime-context'); const { qiweiSyncExternalGroups, qiweiListExternalGroups, qiweiAnalyzeGroupMembers, qiweiConfirmExternalGroup, qiweiAddExternalGroup, qiweiConfigureGroupKeywords, qiweiRejectExternalGroup, qiweiSyncGroupMessages } = require('../tools/qiwei-group-management-run'); const { qiweiGroupOpsGetContext, qiweiGroupOpsGeneratePlan, qiweiGroupOpsBatchGeneratePlans, qiweiGroupOpsReviewMessages, qiweiGroupOpsManagePlaybook, qiweiGroupOpsExecuteItem, qiweiGroupOpsUpdateFinding, qiweiGroupOpsInsights, qiweiGroupOpsManageTask, qiweiGroupOpsAutomation } = require('../tools/qiwei-group-operations-run'); const { qiweiLoginStatus, qiweiLoginStart, qiweiLoginCheck, qiweiLoginVerify } = require('../tools/qiwei-login-run'); const { qiweiSubscriptionStatus } = require('../tools/qiwei-subscription-run'); const { createAccountConnectionMonitor } = require('../core/account-connection-monitor'); const { qiweiBatchAddFriends, qiweiCheckFriendStatus, qiweiGetCustomerProfile, qiweiAutoCreateGroup, qiweiUpdateCustomerInfo } = require('../tools/qiwei-customer-ops-run'); const { qiweiUpdateCustomerPortrait, qiweiSaveCustomerPortrait, qiweiBatchUpdateCustomerPortrait, qiweiExportCustomerPortraits, qiweiListCustomerTags, qiweiListAllTags, qiweiAddCustomerTags, qiweiRemoveCustomerTags, qiweiSyncPersonalLabels, qiweiCreatePersonalLabel, qiweiUpdatePersonalLabel, qiweiDeletePersonalLabel, qiweiApplyPersonalLabels } = require('../tools/qiwei-portrait-tags-run'); const { qiweiPreviewTransferPackage, qiweiExecuteTransfer } = require('../tools/qiwei-customer-transfer-run'); const { getStateSection, rebuildStateFromOutputs } = require('../core/dashboard-state'); const { switchActiveAccount, getAgentStatus, getAllowlistCandidates, updateAllowlist, getConversations, getResponseMonitor, getGroupAgents, changeGroupMode, generateGroupReply, approveGroupDraft, rejectGroupDraft, regenerateGroupDraft, syncConversations, changeGlobalMode, changeConversationMode, approveReply, approveDraft, rejectDraft, regenerateDraft, generateLatestDraft, manualSend, getVoiceStatus, enrollVoice, revokeVoiceProfile, sendClonedVoice, getSentVoiceAudio, updateCustomerTask, syncCustomerTaskToOfficialTodo, updateCustomerAlert, getAudit, startListener, stopListener } = require('./agent-service'); const { listKnowledgeTree, readKnowledgeFile, listProperties, getProperty, listSkillRegistry, getSkillDetail } = require('./workspace-library-service'); const { getMeetingKnowledgeHub, syncMeetingKnowledge, analyzeMeetingKnowledge, getMeetingKnowledge } = require('./meeting-knowledge-service'); const { getDocumentKnowledgeHub, importDocumentKnowledge, createDocumentKnowledge, analyzeDocumentKnowledge, refreshDocumentKnowledge, getDocumentKnowledge, getTodoKnowledgeHub, searchTodoUsers, syncTodoKnowledge, refreshTodoKnowledgeDetails, createTodoKnowledge, completeTodoKnowledge } = require('./official-office-knowledge-service'); const { getUnifiedTaskHub, createUnifiedLocalTask, createUnifiedDiagnosisCandidate, updateUnifiedDiagnosisOutcome, updateUnifiedTask } = require('./unified-task-service'); const { getCustomerMasterHub, updateCustomerMaster, updateCustomerRecommendationFeedback } = require('./customer-master-service'); const { qiweiBusinessDiagnosis } = require('../tools/qiwei-business-diagnosis-run'); const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320; const STATIC_DIR = path.join(__dirname); const TMP_DIR = path.join(outputsRoot(), 'tmp'); const WORKSPACE_ID = workspaceIdentity(resolveWorkspaceRoot()); const jobs = new Map(); const accountConnectionMonitor = createAccountConnectionMonitor({ checkStatus: () => qiweiLoginStatus({}), recoverLogin: () => qiweiLoginCheck({ manual: true, persistConfig: false }), // 后台每 15 秒主动探测一次;页面读取复用最近结果,避免再次阻塞远端登录状态接口。 cacheTtlMs: 20000 }); accountConnectionMonitor.start(); void accountConnectionMonitor.getStatus().catch(() => {}); const SUBSCRIPTION_STATUS_CACHE_TTL_MS = 2 * 60 * 1000; let subscriptionStatusCache = null; let subscriptionStatusCachedAt = 0; let subscriptionStatusInFlight = null; async function getCachedSubscriptionStatus({ force = false } = {}) { const fresh = subscriptionStatusCache && Date.now() - subscriptionStatusCachedAt < SUBSCRIPTION_STATUS_CACHE_TTL_MS; if (!force && fresh) return subscriptionStatusCache; if (subscriptionStatusInFlight) return subscriptionStatusInFlight; subscriptionStatusInFlight = qiweiSubscriptionStatus({}) .then(result => { subscriptionStatusCache = result; subscriptionStatusCachedAt = Date.now(); return result; }) .finally(() => { subscriptionStatusInFlight = null; }); return subscriptionStatusInFlight; } // Dashboard 启动后立即预热较慢的远程订阅状态,用户进入账号页时通常可直接命中缓存。 void getCachedSubscriptionStatus().catch(() => {}); const CUSTOMER_DIRECTORY_CACHE_TTL_MS = 60 * 1000; let customerDirectoryCache = null; let customerDirectoryCachedAt = 0; let customerDirectoryInFlight = null; async function buildDashboardCustomerDirectory() { const merged = new Map(); const mergeCustomer = customer => { const externalUserId = String(customer.externalUserId || customer.customerId || '').trim(); if (!externalUserId) return; const current = merged.get(externalUserId) || {}; merged.set(externalUserId, { ...current, ...customer, externalUserId, customerId: customer.customerId || current.customerId || externalUserId, name: customer.name || customer.displayName || current.name || '', phone: customer.phone || current.phone || '', tags: Array.isArray(customer.tags) ? customer.tags : (current.tags || []), hasPortrait: customer.hasPortrait !== undefined ? Boolean(customer.hasPortrait) : Boolean((customer.fields || current.fields || []).length || current.hasPortrait), }); }; try { const allowlist = await getAllowlistCandidates(); for (const contact of allowlist.data?.contacts || []) { mergeCustomer({ externalUserId: contact.id, name: contact.displayName, selected: contact.selected === true, friendRequestStatus: 'ACCEPTED', source: 'qiwei-contact', }); } } catch { // Local and conversation-backed customers remain available during a remote outage. } const local = getStateSection('customers'); for (const customer of Object.values(local.customers || {})) mergeCustomer(customer); const master = getCustomerMasterHub(); for (const customer of master.data?.customers || []) { mergeCustomer({ ...customer, name: customer.displayName, hasPortrait: Array.isArray(customer.fields) && customer.fields.length > 0, }); } return Array.from(merged.values()); } async function getDashboardCustomerDirectory({ force = false } = {}) { const fresh = customerDirectoryCache && Date.now() - customerDirectoryCachedAt < CUSTOMER_DIRECTORY_CACHE_TTL_MS; if (!force && fresh) return customerDirectoryCache; if (customerDirectoryInFlight) return customerDirectoryInFlight; customerDirectoryInFlight = buildDashboardCustomerDirectory() .then(customers => { customerDirectoryCache = customers; customerDirectoryCachedAt = Date.now(); return customers; }) .finally(() => { customerDirectoryInFlight = null; }); return customerDirectoryInFlight; } // 预热共享客户目录,画像、交接和客户选择器进入时可直接复用。 void getDashboardCustomerDirectory().catch(() => {}); function ensureTmpDir() { fs.mkdirSync(TMP_DIR, { recursive: true }); } function readBody(req, maxBytes = 30 * 1024 * 1024) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; let exceeded = false; req.on('data', chunk => { if (exceeded) return; size += chunk.length; if (size > maxBytes) { exceeded = true; reject(new Error('请求内容过大')); return; } chunks.push(chunk); }); req.on('end', () => { if (exceeded) return; try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch { resolve({}); } }); req.on('error', reject); }); } function json(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); } function createJob(runFn) { const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const job = { id, status: 'running', progress: 0, result: null, error: null, startedAt: Date.now() }; jobs.set(id, job); runFn() .then(r => { job.status = 'done'; job.progress = 100; job.result = r; }) .catch(e => { job.status = 'error'; job.error = e.message || String(e); }); return id; } function getJob(id) { const job = jobs.get(id); if (!job) return null; if (job.status === 'running') { const elapsed = Date.now() - job.startedAt; job.progress = Math.min(95, Math.floor((elapsed / 30000) * 100)); } return job; } function serveStatic(req, res, filePath, contentType) { fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end('not found'); return; } res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store' }); res.end(data); }); } function saveUploadBody(body) { const base64 = String(body.data || ''); const requestedName = path.basename(String(body.name || `upload-${Date.now()}`)).replace(/[^A-Za-z0-9._\-\u4e00-\u9fa5]/g, '_'); const name = !requestedName || requestedName === '.' || requestedName === '..' ? `upload-${Date.now()}` : requestedName; if (!base64) throw new Error('缺少文件数据'); const maxBytes = 20 * 1024 * 1024; if (base64.length > Math.ceil(maxBytes / 3) * 4 + 16) throw new Error('上传文件不能超过 20MB'); ensureTmpDir(); const buffer = Buffer.from(base64, 'base64'); if (buffer.length > maxBytes) throw new Error('上传文件不能超过 20MB'); const filePath = path.join(TMP_DIR, name); fs.writeFileSync(filePath, buffer); return { path: filePath }; } async function handleUpload(req) { return saveUploadBody(await readBody(req)); } function handleOutputsDownload(req, res, query) { const requested = String(query.path || ''); if (!requested) { json(res, 400, { status: 'error', message: '缺少 path 参数' }); return; } const resolved = path.resolve(requested); const root = path.resolve(outputsRoot()); if (!resolved.startsWith(root)) { json(res, 403, { status: 'error', message: '禁止访问 outputs 目录之外的文件' }); return; } if (!fs.existsSync(resolved)) { json(res, 404, { status: 'error', message: '文件不存在' }); return; } const data = fs.readFileSync(resolved); const ext = path.extname(resolved).toLowerCase(); const contentType = { '.json': 'application/json', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.xls': 'application/vnd.ms-excel', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4' }[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${path.basename(resolved)}"` }); res.end(data); } function serveSentVoiceAudio(req, res, requested) { const resolved = path.resolve(String(requested || '')); const root = path.resolve(categoryDir('voice')); const relative = path.relative(root, resolved); if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || path.basename(resolved).toLowerCase() !== 'speech.wav' || !relative.split(path.sep).some(part => /^\d{6}-clone-[a-z0-9-]+$/i.test(part)) || !fs.existsSync(resolved)) { json(res, 404, { status: 'error', message: '已发送语音文件不存在' }); return; } const size = fs.statSync(resolved).size; const range = String(req.headers.range || '').match(/^bytes=(\d*)-(\d*)$/); let start = 0; let end = size - 1; if (range) { if (range[1]) start = Number(range[1]); if (range[2]) end = Number(range[2]); if (!range[1] && range[2]) start = Math.max(0, size - Number(range[2])); if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) { res.writeHead(416, { 'Content-Range': `bytes */${size}` }); res.end(); return; } end = Math.min(end, size - 1); } const headers = { 'Content-Type': 'audio/wav', 'Content-Length': end - start + 1, 'Accept-Ranges': 'bytes', 'Cache-Control': 'private, no-store', 'Content-Disposition': 'inline; filename="speech.wav"', }; if (range) headers['Content-Range'] = `bytes ${start}-${end}/${size}`; res.writeHead(range ? 206 : 200, headers); fs.createReadStream(resolved, { start, end }).pipe(res); } function getContentType(filePath) { const ext = path.extname(filePath).toLowerCase(); const map = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml' }; return map[ext] || 'application/octet-stream'; } async function combinedStatus({ fast = false } = {}) { const statusPromise = Promise.all([ accountConnectionMonitor.getStatus(), getCachedSubscriptionStatus() ]); let login; let subscription; if (fast) { const timeout = Symbol('status-timeout'); const result = await Promise.race([ statusPromise, new Promise(resolve => setTimeout(() => resolve(timeout), 180)) ]); if (result === timeout) { login = accountConnectionMonitor.state.lastOutput || { status: 'pending', summary: {}, data: {} }; subscription = subscriptionStatusCache || { status: 'pending', summary: {}, data: {} }; } else { [login, subscription] = result; } } else { [login, subscription] = await statusPromise; } const loginPending = login?.status === 'pending'; const subscriptionPending = subscription?.status === 'pending'; return { status: 'ok', summary: { authConfigured: loginPending ? null : login.status !== 'needs_auth', online: loginPending ? null : !!login.summary?.online, subscribed: subscriptionPending ? null : !!subscription.summary?.subscribed, loginPending, subscriptionPending }, data: { login, subscription } }; } async function handleRequest(req, res) { const url = new URL(req.url, 'http://localhost'); const pathname = url.pathname; try { if (pathname === '/' || pathname === '/index.html') { serveStatic(req, res, path.join(STATIC_DIR, 'index.html'), 'text/html; charset=utf-8'); return; } if (pathname.startsWith('/dashboard/')) { const fileName = pathname.slice('/dashboard/'.length).replace(/\.{2,}/g, ''); const filePath = path.join(STATIC_DIR, fileName); if (!filePath.startsWith(STATIC_DIR)) { json(res, 403, { status: 'error', message: '禁止访问' }); return; } serveStatic(req, res, filePath, getContentType(filePath)); return; } if (pathname === '/api/health') { json(res, 200, { status: 'ok', data: { workspaceId: WORKSPACE_ID, port: Number(DASHBOARD_PORT) } }); return; } if (pathname === '/api/status' && req.method === 'GET') { json(res, 200, await combinedStatus({ fast: url.searchParams.get('fast') === 'true' })); return; } if (pathname === '/api/skills' && req.method === 'GET') { json(res, 200, listSkillRegistry()); return; } if (pathname === '/api/knowledge/tree' && req.method === 'GET') { json(res, 200, listKnowledgeTree()); return; } if (pathname === '/api/knowledge/file' && req.method === 'GET') { json(res, 200, readKnowledgeFile(url.searchParams.get('id'))); return; } if (pathname === '/api/knowledge/meetings' && req.method === 'GET') { json(res, 200, await getMeetingKnowledgeHub()); return; } if (pathname === '/api/knowledge/meetings/sync' && req.method === 'POST') { json(res, 200, await syncMeetingKnowledge(await readBody(req))); return; } const meetingAnalyzeRoute = pathname.match(/^\/api\/knowledge\/meetings\/([^/]+)\/analyze$/); if (meetingAnalyzeRoute && req.method === 'POST') { json(res, 200, await analyzeMeetingKnowledge(decodeURIComponent(meetingAnalyzeRoute[1]))); return; } const meetingDetailRoute = pathname.match(/^\/api\/knowledge\/meetings\/([^/]+)$/); if (meetingDetailRoute && req.method === 'GET') { json(res, 200, getMeetingKnowledge(decodeURIComponent(meetingDetailRoute[1]))); return; } if (pathname === '/api/knowledge/docs' && req.method === 'GET') { json(res, 200, await getDocumentKnowledgeHub()); return; } if (pathname === '/api/knowledge/docs/import' && req.method === 'POST') { json(res, 200, await importDocumentKnowledge(await readBody(req))); return; } if (pathname === '/api/knowledge/docs/create' && req.method === 'POST') { json(res, 200, await createDocumentKnowledge(await readBody(req))); return; } const documentAnalyzeRoute = pathname.match(/^\/api\/knowledge\/docs\/([^/]+)\/analyze$/); if (documentAnalyzeRoute && req.method === 'POST') { json(res, 200, await analyzeDocumentKnowledge(decodeURIComponent(documentAnalyzeRoute[1]))); return; } const documentRefreshRoute = pathname.match(/^\/api\/knowledge\/docs\/([^/]+)\/refresh$/); if (documentRefreshRoute && req.method === 'POST') { json(res, 200, await refreshDocumentKnowledge(decodeURIComponent(documentRefreshRoute[1]))); return; } const documentDetailRoute = pathname.match(/^\/api\/knowledge\/docs\/([^/]+)$/); if (documentDetailRoute && req.method === 'GET') { json(res, 200, getDocumentKnowledge(decodeURIComponent(documentDetailRoute[1]))); return; } if (pathname === '/api/knowledge/todos' && req.method === 'GET') { json(res, 200, await getTodoKnowledgeHub()); return; } if (pathname === '/api/knowledge/tasks' && req.method === 'GET') { json(res, 200, getUnifiedTaskHub()); return; } if (pathname === '/api/knowledge/tasks/create' && req.method === 'POST') { json(res, 200, createUnifiedLocalTask(await readBody(req))); return; } if (pathname === '/api/knowledge/tasks/diagnosis-candidate' && req.method === 'POST') { json(res, 200, createUnifiedDiagnosisCandidate(await readBody(req))); return; } if (pathname === '/api/knowledge/tasks/diagnosis-feedback' && req.method === 'POST') { json(res, 200, updateUnifiedDiagnosisOutcome(await readBody(req))); return; } if (pathname === '/api/knowledge/tasks/update' && req.method === 'POST') { json(res, 200, await updateUnifiedTask(await readBody(req))); return; } if (pathname === '/api/knowledge/todos/search-users' && req.method === 'POST') { json(res, 200, await searchTodoUsers(await readBody(req))); return; } if (pathname === '/api/knowledge/todos/sync' && req.method === 'POST') { json(res, 200, await syncTodoKnowledge(await readBody(req))); return; } if (pathname === '/api/knowledge/todos/details' && req.method === 'POST') { json(res, 200, await refreshTodoKnowledgeDetails(await readBody(req))); return; } if (pathname === '/api/knowledge/todos/create' && req.method === 'POST') { json(res, 200, await createTodoKnowledge(await readBody(req))); return; } const todoCompleteRoute = pathname.match(/^\/api\/knowledge\/todos\/([^/]+)\/complete$/); if (todoCompleteRoute && req.method === 'POST') { json(res, 200, await completeTodoKnowledge(decodeURIComponent(todoCompleteRoute[1]))); return; } if (pathname === '/api/knowledge/properties' && req.method === 'GET') { json(res, 200, listProperties(Object.fromEntries(url.searchParams.entries()))); return; } const propertyRoute = pathname.match(/^\/api\/knowledge\/properties\/([^/]+)$/); if (propertyRoute && req.method === 'GET') { json(res, 200, getProperty(decodeURIComponent(propertyRoute[1]))); return; } const skillRoute = pathname.match(/^\/api\/skills\/(.+)$/); if (skillRoute && req.method === 'GET') { json(res, 200, getSkillDetail(decodeURIComponent(skillRoute[1]))); return; } if (pathname === '/api/agent/status' && req.method === 'GET') { json(res, 200, await getAgentStatus()); return; } if (pathname === '/api/agent/voice/status' && req.method === 'GET') { json(res, 200, getVoiceStatus()); return; } if (pathname === '/api/agent/voice/profile' && req.method === 'POST') { const body = await readBody(req); const upload = saveUploadBody(body); try { json(res, 200, await enrollVoice({ filePath: upload.path, originalName: body.name, mime: body.type, })); } finally { fs.rmSync(upload.path, { force: true }); } return; } if (pathname === '/api/agent/voice/profile' && req.method === 'DELETE') { json(res, 200, revokeVoiceProfile()); return; } if (pathname === '/api/agent/allowlist' && req.method === 'GET') { json(res, 200, await getAllowlistCandidates()); return; } if (pathname === '/api/agent/allowlist' && req.method === 'POST') { json(res, 200, updateAllowlist(await readBody(req))); return; } if (pathname === '/api/accounts/switch' && req.method === 'POST') { json(res, 200, await switchActiveAccount(await readBody(req))); return; } if (pathname === '/api/agent/conversations' && req.method === 'GET') { json(res, 200, getConversations()); return; } if (pathname === '/api/agent/response-monitor' && req.method === 'GET') { json(res, 200, getResponseMonitor()); return; } if (pathname === '/api/agent/groups' && req.method === 'GET') { json(res, 200, getGroupAgents()); return; } const groupAgentModeRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/mode$/); if (groupAgentModeRoute && req.method === 'POST') { const body = await readBody(req); json(res, 200, changeGroupMode(decodeURIComponent(groupAgentModeRoute[1]), body.mode, body.confirmation)); return; } const groupAgentGenerateRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/generate$/); if (groupAgentGenerateRoute && req.method === 'POST') { json(res, 200, await generateGroupReply(decodeURIComponent(groupAgentGenerateRoute[1]), await readBody(req))); return; } const groupAgentDraftRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/drafts\/([^/]+)\/(approve|reject|regenerate)$/); if (groupAgentDraftRoute && req.method === 'POST') { const [, roomId, draftId, action] = groupAgentDraftRoute; const body = await readBody(req); if (action === 'approve') json(res, 200, await approveGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.content)); else if (action === 'reject') json(res, 200, rejectGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.reason)); else json(res, 200, await regenerateGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId))); return; } if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') { json(res, 200, await syncConversations()); return; } if (pathname === '/api/agent/audit' && req.method === 'GET') { json(res, 200, getAudit(url.searchParams.get('limit'))); return; } if (pathname === '/api/agent/mode' && req.method === 'POST') { const body = await readBody(req); json(res, 200, changeGlobalMode(body.mode)); return; } if (pathname === '/api/agent/listener/start' && req.method === 'POST') { json(res, 200, await startListener()); return; } if (pathname === '/api/agent/listener/stop' && req.method === 'POST') { json(res, 200, stopListener()); return; } const customerTaskSyncRoute = pathname.match(/^\/api\/agent\/tasks\/([^/]+)\/sync-official$/); if (customerTaskSyncRoute && req.method === 'POST') { const body = await readBody(req); json(res, 200, await syncCustomerTaskToOfficialTodo(customerTaskSyncRoute[1], body)); return; } const customerTaskRoute = pathname.match(/^\/api\/agent\/tasks\/([^/]+)$/); if (customerTaskRoute && req.method === 'POST') { const body = await readBody(req); json(res, 200, await updateCustomerTask(customerTaskRoute[1], body)); return; } const customerAlertRoute = pathname.match(/^\/api\/agent\/alerts\/([^/]+)$/); if (customerAlertRoute && req.method === 'POST') { const body = await readBody(req); json(res, 200, updateCustomerAlert(customerAlertRoute[1], body)); return; } const agentConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/(takeover|resume|pause|auto|approve-reply|generate|manual-send)$/); if (agentConversationRoute && req.method === 'POST') { const [, conversationId, action] = agentConversationRoute; const body = await readBody(req); if (action === 'takeover') json(res, 200, changeConversationMode(conversationId, 'human')); else if (action === 'resume') json(res, 200, changeConversationMode(conversationId, 'review')); else if (action === 'pause') json(res, 200, changeConversationMode(conversationId, 'paused')); else if (action === 'auto') json(res, 200, changeConversationMode(conversationId, 'auto')); else if (action === 'generate') json(res, 200, await generateLatestDraft(conversationId)); else if (action === 'manual-send') json(res, 200, await manualSend(conversationId, body.content)); else json(res, 200, await approveReply(conversationId, body.content)); return; } const voiceConversationRoute = pathname.match(/^\/api\/agent\/conversations\/([^/]+)\/voice-send$/); if (voiceConversationRoute && req.method === 'POST') { const [, conversationId] = voiceConversationRoute; const body = await readBody(req); json(res, 200, await sendClonedVoice(conversationId, body)); return; } const voiceAudioRoute = pathname.match(/^\/api\/agent\/messages\/([^/]+)\/voice-audio$/); if (voiceAudioRoute && req.method === 'GET') { const audio = getSentVoiceAudio(decodeURIComponent(voiceAudioRoute[1])); serveSentVoiceAudio(req, res, audio.filePath); return; } const agentDraftRoute = pathname.match(/^\/api\/agent\/drafts\/([^/]+)\/(approve|reject|regenerate)$/); if (agentDraftRoute && req.method === 'POST') { const [, draftId, action] = agentDraftRoute; const body = await readBody(req); if (action === 'approve') json(res, 200, await approveDraft(draftId, body.content)); else if (action === 'reject') json(res, 200, rejectDraft(draftId, body.reason)); else json(res, 200, await regenerateDraft(draftId)); return; } if (pathname === '/api/dashboard/summary' && req.method === 'GET') { json(res, 200, { status: 'ok', data: getStateSection('summary') }); return; } if (pathname === '/api/business-diagnosis' && req.method === 'POST') { json(res, 200, await qiweiBusinessDiagnosis(await readBody(req))); return; } if (pathname === '/api/dashboard/customers/search' && req.method === 'GET') { const keyword = String(url.searchParams.get('keyword') || '').trim().toLowerCase(); const hasPortrait = url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined; const scope = String(url.searchParams.get('scope') || '').trim(); const forceRefresh = url.searchParams.get('refresh') === '1'; let customers = (await getDashboardCustomerDirectory({ force: forceRefresh })).filter(customer => { if (scope === 'portrait' && customer.selected !== true && customer.discoveredFromGroups !== true && customer.groupStatus !== 'IN_GROUP') return false; if (keyword) { const text = `${customer.name || ''} ${customer.phone || ''} ${customer.externalUserId || ''}`.toLowerCase(); if (!text.includes(keyword)) return false; } if (hasPortrait !== undefined && Boolean(customer.hasPortrait) !== hasPortrait) return false; return true; }); const sortBy = url.searchParams.get('sortBy') || 'default'; if (sortBy === 'lastActive') { customers.sort((a, b) => String(b.lastSeenInGroupAt || b.updatedAt || '').localeCompare(String(a.lastSeenInGroupAt || a.updatedAt || ''))); } else if (sortBy === 'portraitDesc') { customers.sort((a, b) => Number(Boolean(b.hasPortrait)) - Number(Boolean(a.hasPortrait))); } else if (sortBy === 'portraitAsc') { customers.sort((a, b) => Number(Boolean(a.hasPortrait)) - Number(Boolean(b.hasPortrait))); } else if (sortBy === 'nameAsc') { customers.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN')); } const total = customers.length; const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 20)); const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0); customers = customers.slice(offset, offset + limit); json(res, 200, { status: 'ok', data: { customers, total, limit, offset } }); return; } if (pathname === '/api/dashboard/customers' && req.method === 'GET') { const filter = { keyword: url.searchParams.get('keyword') || '', hasPortrait: url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined, friendRequestStatus: url.searchParams.get('friendRequestStatus') || '', tag: url.searchParams.get('tag') || '' }; json(res, 200, { status: 'ok', data: getStateSection('customers', { filter }) }); return; } if (pathname === '/api/dashboard/portraits' && req.method === 'GET') { json(res, 200, { status: 'ok', data: getStateSection('portraits') }); return; } if (pathname === '/api/dashboard/tags' && req.method === 'GET') { json(res, 200, { status: 'ok', data: getStateSection('tags') }); return; } if (pathname === '/api/dashboard/transfers' && req.method === 'GET') { json(res, 200, { status: 'ok', data: getStateSection('transfers', { limit: url.searchParams.get('limit') }) }); return; } if (pathname === '/api/dashboard/groups' && req.method === 'GET') { json(res, 200, { status: 'ok', data: getStateSection('groups') }); return; } if (pathname === '/api/dashboard/operations' && req.method === 'GET') { json(res, 200, { status: 'ok', data: getStateSection('operations', { limit: url.searchParams.get('limit') }) }); return; } if (pathname === '/api/dashboard/rebuild' && req.method === 'POST') { const state = rebuildStateFromOutputs(); json(res, 200, { status: 'ok', data: { message: '索引已重建', summary: getStateSection('summary'), updatedAt: state.updatedAt } }); return; } if (pathname === '/api/login/start' && req.method === 'POST') { const body = await readBody(req); const result = await qiweiLoginStart({ flowUi: false, openBrowser: false, ...body }); json(res, 200, result); return; } if (pathname === '/api/login/check' && req.method === 'POST') { const body = await readBody(req); const result = await qiweiLoginCheck(body); json(res, 200, result); return; } if (pathname === '/api/login/verify' && req.method === 'POST') { const body = await readBody(req); const result = await qiweiLoginVerify(body); json(res, 200, result); return; } if (pathname === '/api/groups/sync' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiSyncExternalGroups(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/groups/list' && req.method === 'GET') { const params = {}; if (url.searchParams.has('keyword')) params.keyword = url.searchParams.get('keyword'); if (url.searchParams.has('status')) params.status = url.searchParams.get('status'); if (url.searchParams.has('source')) params.source = url.searchParams.get('source'); if (url.searchParams.has('includeRejected')) params.includeRejected = url.searchParams.get('includeRejected') === 'true'; json(res, 200, await qiweiListExternalGroups(params)); return; } if (pathname === '/api/groups/analyze' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiAnalyzeGroupMembers(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/groups/confirm' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiConfirmExternalGroup(body)); return; } if (pathname === '/api/groups/add' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiAddExternalGroup(body)); return; } if (pathname === '/api/groups/reject' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiRejectExternalGroup(body)); return; } if (pathname === '/api/groups/keywords' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiConfigureGroupKeywords(body)); return; } if (pathname === '/api/groups/messages/sync' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiSyncGroupMessages(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } // Group Operations if (pathname === '/api/group-ops/overview' && req.method === 'GET') { json(res, 200, await qiweiGroupOpsGetContext({ accountKey: url.searchParams.get('accountKey') || undefined, date: url.searchParams.get('date') || undefined })); return; } if (pathname === '/api/group-ops/playbooks' && req.method === 'GET') { json(res, 200, await qiweiGroupOpsManagePlaybook({ accountKey: url.searchParams.get('accountKey') || undefined, action: 'list' })); return; } if (pathname === '/api/group-ops/playbooks' && req.method === 'POST') { json(res, 200, await qiweiGroupOpsManagePlaybook({ ...(await readBody(req)), action: 'create' })); return; } const groupOpsPreviewRoute = pathname.match(/^\/api\/group-ops\/playbooks\/([^/]+)\/(preview|versions)$/); if (groupOpsPreviewRoute && req.method === 'GET') { json(res, 200, await qiweiGroupOpsManagePlaybook({ accountKey: url.searchParams.get('accountKey') || undefined, playbookId: groupOpsPreviewRoute[1], version: url.searchParams.get('version') ? Number(url.searchParams.get('version')) : undefined, action: groupOpsPreviewRoute[2] })); return; } const groupOpsVersionRoute = pathname.match(/^\/api\/group-ops\/playbooks\/([^/]+)\/versions$/); if (groupOpsVersionRoute && req.method === 'POST') { json(res, 200, await qiweiGroupOpsManagePlaybook({ ...(await readBody(req)), playbookId: groupOpsVersionRoute[1], action: 'new_version' })); return; } const groupOpsPublishRoute = pathname.match(/^\/api\/group-ops\/playbooks\/([^/]+)\/(publish|rollback)$/); if (groupOpsPublishRoute && req.method === 'POST') { json(res, 200, await qiweiGroupOpsManagePlaybook({ ...(await readBody(req)), playbookId: groupOpsPublishRoute[1], action: groupOpsPublishRoute[2] })); return; } const groupOpsPlanRoute = pathname.match(/^\/api\/group-ops\/groups\/([^/]+)\/generate-plan$/); if (groupOpsPlanRoute && req.method === 'POST') { json(res, 200, await qiweiGroupOpsGeneratePlan({ ...(await readBody(req)), roomId: decodeURIComponent(groupOpsPlanRoute[1]) })); return; } if (pathname === '/api/group-ops/plans/batch' && req.method === 'POST') { json(res, 200, await qiweiGroupOpsBatchGeneratePlans(await readBody(req))); return; } const groupOpsReviewRoute = pathname.match(/^\/api\/group-ops\/groups\/([^/]+)\/review$/); if (groupOpsReviewRoute && req.method === 'POST') { json(res, 200, await qiweiGroupOpsReviewMessages({ ...(await readBody(req)), roomId: decodeURIComponent(groupOpsReviewRoute[1]) })); return; } const groupOpsItemRoute = pathname.match(/^\/api\/group-ops\/plan-items\/([^/]+)\/(approve|reject|skip|mark-sent)$/); if (groupOpsItemRoute && req.method === 'POST') { const action = groupOpsItemRoute[2] === 'mark-sent' ? 'mark_sent' : groupOpsItemRoute[2]; json(res, 200, await qiweiGroupOpsExecuteItem({ ...(await readBody(req)), itemId: groupOpsItemRoute[1], action })); return; } const groupOpsFindingRoute = pathname.match(/^\/api\/group-ops\/findings\/([^/]+)$/); if (groupOpsFindingRoute && req.method === 'POST') { json(res, 200, await qiweiGroupOpsUpdateFinding({ ...(await readBody(req)), findingId: groupOpsFindingRoute[1] })); return; } if (pathname === '/api/group-ops/quality-settings' && req.method === 'POST') { json(res, 200, await qiweiGroupOpsInsights({ ...(await readBody(req)), action: 'update_settings' })); return; } if (pathname === '/api/group-ops/tasks' && req.method === 'GET') { json(res, 200, await qiweiGroupOpsManageTask({ accountKey: url.searchParams.get('accountKey') || undefined, status: url.searchParams.get('status') || undefined, action: 'list' })); return; } if (pathname === '/api/group-ops/tasks' && req.method === 'POST') { json(res, 200, await qiweiGroupOpsManageTask({ ...(await readBody(req)), action: 'create' })); return; } const groupOpsTaskRoute = pathname.match(/^\/api\/group-ops\/tasks\/([^/]+)\/(update|sync-official)$/); if (groupOpsTaskRoute && req.method === 'POST') { json(res, 200, await qiweiGroupOpsManageTask({ ...(await readBody(req)), taskId: groupOpsTaskRoute[1], action: groupOpsTaskRoute[2] === 'sync-official' ? 'sync_official' : 'update' })); return; } if (pathname === '/api/group-ops/automation' && req.method === 'POST') { json(res, 200, await qiweiGroupOpsAutomation(await readBody(req))); return; } // Customer Operations if (pathname === '/api/customers' && req.method === 'GET') { json(res, 200, getCustomerMasterHub()); return; } const customerMasterRoute = pathname.match(/^\/api\/customers\/([^/]+)\/profile$/); if (customerMasterRoute && req.method === 'POST') { json(res, 200, updateCustomerMaster(decodeURIComponent(customerMasterRoute[1]), await readBody(req))); return; } const customerRecommendationRoute = pathname.match(/^\/api\/customers\/([^/]+)\/recommendations\/([^/]+)$/); if (customerRecommendationRoute && req.method === 'POST') { json(res, 200, updateCustomerRecommendationFeedback(decodeURIComponent(customerRecommendationRoute[1]), decodeURIComponent(customerRecommendationRoute[2]), await readBody(req))); return; } if (pathname === '/api/customer-ops/batch-add-friends' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiBatchAddFriends(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/customer-ops/check-friend-status' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiCheckFriendStatus(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/customer-ops/customer-profile' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiGetCustomerProfile(body)); return; } if (pathname === '/api/customer-ops/update' && req.method === 'POST') { json(res, 200, await qiweiUpdateCustomerInfo(await readBody(req))); return; } if (pathname === '/api/customer-ops/auto-create-group' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiAutoCreateGroup(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } // Portraits & Tags if (pathname === '/api/portraits/update' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiUpdateCustomerPortrait(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/portraits/save' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiSaveCustomerPortrait(body)); return; } if (pathname === '/api/portraits/batch' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiBatchUpdateCustomerPortrait(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/portraits/export' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiExportCustomerPortraits(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/tags/list' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiListCustomerTags(body)); return; } if (pathname === '/api/tags/all' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiListAllTags(body)); return; } if (pathname === '/api/tags/add' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiAddCustomerTags(body)); return; } if (pathname === '/api/tags/remove' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiRemoveCustomerTags(body)); return; } if (pathname === '/api/personal-labels/sync' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiSyncPersonalLabels(body)); return; } if (pathname === '/api/personal-labels/create' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiCreatePersonalLabel(body)); return; } if (pathname === '/api/personal-labels/update' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiUpdatePersonalLabel(body)); return; } if (pathname === '/api/personal-labels/delete' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiDeletePersonalLabel(body)); return; } if (pathname === '/api/personal-labels/apply' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiApplyPersonalLabels(body)); return; } // Customer Transfers if (pathname === '/api/transfers/preview' && req.method === 'POST') { const body = await readBody(req); json(res, 200, await qiweiPreviewTransferPackage(body)); return; } if (pathname === '/api/transfers/execute' && req.method === 'POST') { const body = await readBody(req); const id = createJob(() => qiweiExecuteTransfer(body)); json(res, 200, { status: 'ok', data: { jobId: id } }); return; } if (pathname === '/api/upload' && req.method === 'POST') { const result = await handleUpload(req); json(res, 200, { status: 'ok', data: result }); return; } if (pathname === '/api/outputs' && req.method === 'GET') { handleOutputsDownload(req, res, url.searchParams); return; } if (pathname.startsWith('/api/jobs/') && req.method === 'GET') { const id = pathname.slice('/api/jobs/'.length); const job = getJob(id); if (!job) { json(res, 404, { status: 'error', message: '任务不存在' }); return; } json(res, 200, { status: 'ok', data: job }); return; } json(res, 404, { status: 'error', message: 'not found' }); } catch (error) { json(res, 500, { status: 'error', message: error.message || String(error) }); } } function startServer(port = DASHBOARD_PORT) { const server = http.createServer(handleRequest); return new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => { console.log(`Qiwei Dashboard 已启动:http://127.0.0.1:${port}/`); resolve({ server, url: `http://127.0.0.1:${port}/`, port }); }); }); } if (require.main === module) { startServer().catch(error => { console.error('Dashboard 启动失败:', error); process.exit(1); }); } module.exports = { startServer };