diff --git a/codechat/.port b/codechat/.port index afe55a2..50464a5 100644 --- a/codechat/.port +++ b/codechat/.port @@ -1 +1 @@ -34689:1773367134016_22200_768qlmindc7g:1773378240856 \ No newline at end of file +34689:1773710589395_12316_18r6tb53xr5k:1773716299978 \ No newline at end of file diff --git a/codechat/.token b/codechat/.token index a2c6038..7b1019f 100644 --- a/codechat/.token +++ b/codechat/.token @@ -1,7 +1,7 @@ { - "token": "47c96fae6ababba95306064d77f8cb9d0ab937523d5c08825e3061a7617b902d", - "expires_at": 1773378566, + "token": "3a894ee27cf51fedbd1a75833a8f2fd16e58ff41a3795eba2c4a402543d044fe", + "expires_at": 1773717943, "card_type": 7, "card_expires_at": 1773883128, - "created_at": 1773376766 + "created_at": 1773716143 } \ No newline at end of file diff --git a/mcp-server-ssh/check_current.cjs b/mcp-server-ssh/check_current.cjs new file mode 100644 index 0000000..9fc9e0c --- /dev/null +++ b/mcp-server-ssh/check_current.cjs @@ -0,0 +1,18 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== LAST 200 LINES server-out.log ==='", + "tail -200 /var/log/bigwo/server-out.log", + "echo ''", + "echo '=== LAST 50 LINES server-error.log ==='", + "tail -50 /var/log/bigwo/server-error.log 2>/dev/null || echo 'no error log'", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_double_msg.cjs b/mcp-server-ssh/check_double_msg.cjs new file mode 100644 index 0000000..e8900d9 --- /dev/null +++ b/mcp-server-ssh/check_double_msg.cjs @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== LATEST SESSION FULL EVENTS ==='", + "LAST_SID=$(grep 'processReply start' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -v 'partial' | head -60", + "echo ''", + "echo '=== 公司产品 SESSION EVENTS ==='", + "grep '我们公司的产品\\|公司的产品' /var/log/bigwo/server-out.log | tail -5", + "echo ''", + "echo '=== EVENT 550/559/351 FOR LATEST SESSION ==='", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -E 'upstream assistant|flush|persistAssistant|subtitle.*assistant' | tail -20", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_dual_reply.cjs b/mcp-server-ssh/check_dual_reply.cjs new file mode 100644 index 0000000..bed929d --- /dev/null +++ b/mcp-server-ssh/check_dual_reply.cjs @@ -0,0 +1,11 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + c.exec('grep -E "processReply|upstream assistant|persistAssistant|SayHello|polishForSpeech|local_tts|delivery|blockUpstream|suppress" /var/log/bigwo/server-out.log | tail -50', (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_dual_reply2.cjs b/mcp-server-ssh/check_dual_reply2.cjs new file mode 100644 index 0000000..e457d76 --- /dev/null +++ b/mcp-server-ssh/check_dual_reply2.cjs @@ -0,0 +1,17 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== LATEST SESSION FULL LOG ==='", + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | head -80", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_dual_reply3.cjs b/mcp-server-ssh/check_dual_reply3.cjs new file mode 100644 index 0000000..08693a5 --- /dev/null +++ b/mcp-server-ssh/check_dual_reply3.cjs @@ -0,0 +1,16 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -E 'processReply|upstream assistant|persistAssist|suppress|blockUpstream|delivery|local_tts|unblock|tts_event|event.*550|event.*351|event.*559|barge|chunk' | tail -40", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_dual_reply4.cjs b/mcp-server-ssh/check_dual_reply4.cjs new file mode 100644 index 0000000..91e7d1f --- /dev/null +++ b/mcp-server-ssh/check_dual_reply4.cjs @@ -0,0 +1,16 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -v 'upstream partial' | tail -40", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_env.cjs b/mcp-server-ssh/check_env.cjs new file mode 100644 index 0000000..d2ed324 --- /dev/null +++ b/mcp-server-ssh/check_env.cjs @@ -0,0 +1,24 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== S2S ENV ==='", + "grep -E '^VOLC_S2S|^VOLC_DIALOG' /www/wwwroot/demo.tensorgrove.com.cn/server/.env | sed 's/=.*/=***MASKED***/'", + "echo ''", + "echo '=== ALL VOLC ENV KEYS ==='", + "grep '^VOLC_' /www/wwwroot/demo.tensorgrove.com.cn/server/.env | sed 's/=.*/=.../'", + "echo ''", + "echo '=== SESSIONS AFTER DEPLOY (17:06) ==='", + "awk '/17:0[6-9]|17:[1-5]/' /var/log/bigwo/server-out.log | grep -E 'upstream ready|upstream error|upstream closed|quota' | tail -10", + "echo ''", + "echo '=== HEALTH ==='", + "curl -s http://127.0.0.1:3012/api/health 2>&1", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_greeting.cjs b/mcp-server-ssh/check_greeting.cjs new file mode 100644 index 0000000..f48c863 --- /dev/null +++ b/mcp-server-ssh/check_greeting.cjs @@ -0,0 +1,11 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + c.exec('tail -200 /var/log/bigwo/server-out.log | grep -iE "sendGreeting|replayGreeting|sendSpeechText|upstream ready|tts_event|upstream closed|greeting|ready"', (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_greeting2.cjs b/mcp-server-ssh/check_greeting2.cjs new file mode 100644 index 0000000..edd194b --- /dev/null +++ b/mcp-server-ssh/check_greeting2.cjs @@ -0,0 +1,11 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + c.exec('grep -E "sendGreeting|replayGreeting|upstream ready|greeting|readySent" /var/log/bigwo/server-out.log | tail -30', (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_greeting3.cjs b/mcp-server-ssh/check_greeting3.cjs new file mode 100644 index 0000000..c6265a6 --- /dev/null +++ b/mcp-server-ssh/check_greeting3.cjs @@ -0,0 +1,11 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + c.exec('grep "mmsuckxe" /var/log/bigwo/server-out.log | head -60', (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_greeting4.cjs b/mcp-server-ssh/check_greeting4.cjs new file mode 100644 index 0000000..49c3fb7 --- /dev/null +++ b/mcp-server-ssh/check_greeting4.cjs @@ -0,0 +1,21 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + // Get the last session's full greeting timeline + audio forwarding evidence + const cmd = [ + "echo '=== GREETING LOGS ==='", + "grep -E 'sendGreeting|replayGreeting|sendSpeechText|tts_event|tts_reset|audio block|upstream ready' /var/log/bigwo/server-out.log | tail -30", + "echo ''", + "echo '=== LAST SESSION FULL LOG (first 40 lines) ==='", + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | head -40", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_health.cjs b/mcp-server-ssh/check_health.cjs new file mode 100644 index 0000000..79f73d5 --- /dev/null +++ b/mcp-server-ssh/check_health.cjs @@ -0,0 +1,21 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== HEALTH CHECK ==='", + "curl -s http://127.0.0.1:3012/api/health 2>&1", + "echo ''", + "echo '=== PM2 STATUS ==='", + "pm2 list 2>/dev/null", + "echo ''", + "echo '=== LAST 5 LINES ==='", + "tail -5 /var/log/bigwo/server-out.log", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_kb_env.cjs b/mcp-server-ssh/check_kb_env.cjs new file mode 100644 index 0000000..876dbf2 --- /dev/null +++ b/mcp-server-ssh/check_kb_env.cjs @@ -0,0 +1,24 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== ENV KB CONFIG ==='", + "grep -E 'VOLC_ARK_ENDPOINT_ID|VOLC_ARK_KNOWLEDGE|VOLC_ARK_API_KEY|VOLC_ACCESS_KEY' /www/wwwroot/demo.tensorgrove.com.cn/server/.env | sed 's/=.\\{8\\}/=***REDACTED/g'", + "echo ''", + "echo '=== RECENT KB LOGS (last 500 lines) ==='", + "tail -500 /var/log/bigwo/server-out.log | grep -E 'ToolExecutor|searchKnowledge|Ark KB|knowledge|processReply start|processReply handoff|external_rag|tts_reset|blockUpstream|unblock|barge-in|upstream assistant|upstream final|upstream partial'", + "echo ''", + "echo '=== RECENT ERROR LOGS ==='", + "tail -100 /var/log/bigwo/server-error.log 2>/dev/null | grep -E 'ToolExecutor|Ark|knowledge|timeout|quota' | tail -20", + "echo ''", + "echo '=== PM2 STATUS ==='", + "pm2 list 2>/dev/null || echo 'pm2 not found'", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_kb_issue.cjs b/mcp-server-ssh/check_kb_issue.cjs new file mode 100644 index 0000000..92be2cd --- /dev/null +++ b/mcp-server-ssh/check_kb_issue.cjs @@ -0,0 +1,21 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== RECENT KB RELATED LOGS ==='", + "grep -E 'searchKnowledge|Ark KB|rewritten|classifyKnowledge|tryKnowledge|shouldForce|Chat.*User|Chat.*Assistant|基础三合一|三合一|胡辣汤' /var/log/bigwo/server-out.log | tail -80", + "echo ''", + "echo '=== RECENT CHAT LOGS ==='", + "grep -E '\\[Chat\\]' /var/log/bigwo/server-out.log | tail -30", + "echo ''", + "echo '=== RECENT ERRORS ==='", + "grep -iE 'error|failed|timeout' /var/log/bigwo/server-out.log | tail -20", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_kb_issue2.cjs b/mcp-server-ssh/check_kb_issue2.cjs new file mode 100644 index 0000000..2b0ee60 --- /dev/null +++ b/mcp-server-ssh/check_kb_issue2.cjs @@ -0,0 +1,27 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== LAST 50 CHAT LOGS ==='", + "grep '\\[Chat\\]' /var/log/bigwo/server-out.log | tail -50", + "echo ''", + "echo '=== LAST 30 TOOLEXECUTOR LOGS ==='", + "grep '\\[ToolExecutor\\]' /var/log/bigwo/server-out.log | tail -30", + "echo ''", + "echo '=== LAST 20 COZE LOGS ==='", + "grep '\\[CozeChat\\]' /var/log/bigwo/server-out.log | tail -20", + "echo ''", + "echo '=== LAST 20 ARK LOGS ==='", + "grep '\\[ArkChat\\]' /var/log/bigwo/server-out.log | tail -20", + "echo ''", + "echo '=== .env KB CONFIG ==='", + "grep -E 'VOLC_ARK_KNOWLEDGE|VOLC_ARK_ENDPOINT|VOLC_ARK_API_KEY|COZE_' /www/wwwroot/demo.tensorgrove.com.cn/server/.env | sed 's/=.\\{12\\}/=***REDACTED/g'", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_kb_issue3.cjs b/mcp-server-ssh/check_kb_issue3.cjs new file mode 100644 index 0000000..0155063 --- /dev/null +++ b/mcp-server-ssh/check_kb_issue3.cjs @@ -0,0 +1,24 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== LAST 100 LINES WITH CHAT/KB ==='", + "grep -E '\\[Chat\\]|\\[ToolExecutor\\]|\\[CozeChat\\]|\\[ArkChat\\]|\\[POST\\]|\\[GET\\]' /var/log/bigwo/server-out.log | tail -100", + "echo ''", + "echo '=== SEARCH FOR 胡辣汤 ==='", + "grep -i '胡辣汤' /var/log/bigwo/server-out.log | tail -10", + "echo ''", + "echo '=== SEARCH FOR 基础三合一 ==='", + "grep -i '基础三合一\\|基础套装' /var/log/bigwo/server-out.log | tail -20", + "echo ''", + "echo '=== MOST RECENT 50 LINES ==='", + "tail -50 /var/log/bigwo/server-out.log", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_latency.cjs b/mcp-server-ssh/check_latency.cjs new file mode 100644 index 0000000..a445c96 --- /dev/null +++ b/mcp-server-ssh/check_latency.cjs @@ -0,0 +1,16 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -v 'upstream partial' | grep -v 'upstream assistant chunk'", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_latency2.cjs b/mcp-server-ssh/check_latency2.cjs new file mode 100644 index 0000000..a445c96 --- /dev/null +++ b/mcp-server-ssh/check_latency2.cjs @@ -0,0 +1,16 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -v 'upstream partial' | grep -v 'upstream assistant chunk'", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_logs.cjs b/mcp-server-ssh/check_logs.cjs new file mode 100644 index 0000000..fa458fc --- /dev/null +++ b/mcp-server-ssh/check_logs.cjs @@ -0,0 +1,12 @@ +const { Client } = require("ssh2"); +const c = new Client(); +c.on("ready", () => { + // 搜索包含"产品"的日志行,以及 processReply/handoff 相关行 + c.exec('tail -50 /var/log/bigwo/server-out.log | grep -E "greeting|upstream|tts_event|assistant|rag|ready|error"', (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ""; + s.on("data", (d) => (o += d)); + s.stderr.on("data", (d) => (o += d)); + s.on("close", () => { console.log(o); c.end(); }); + }); +}).connect({ host: "119.45.10.34", port: 22, username: "root", password: "#xyzh%CS#2512@28" }); diff --git a/mcp-server-ssh/check_quota.cjs b/mcp-server-ssh/check_quota.cjs new file mode 100644 index 0000000..272f59d --- /dev/null +++ b/mcp-server-ssh/check_quota.cjs @@ -0,0 +1,30 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== ALL QUOTA ERRORS ==='", + "grep -i 'quota exceeded' /var/log/bigwo/server-out.log | tail -20", + "echo ''", + "echo '=== QUOTA ERROR DETAILS (full payload) ==='", + "grep -i 'quota exceeded' /var/log/bigwo/server-out.log | tail -5 | grep -oP 'payload=\\K.*'", + "echo ''", + "echo '=== S2S ENV CONFIG ==='", + "grep -E 'VOLC_S2S|VOLC_DIALOG|VOLC_RTC|VOLC_ACCESS' /www/wwwroot/demo.tensorgrove.com.cn/server/.env", + "echo ''", + "echo '=== RECENT UPSTREAM CONNECTIONS ==='", + "grep -E 'upstream ready|upstream closed|upstream error|createUpstream' /var/log/bigwo/server-out.log | tail -20", + "echo ''", + "echo '=== LAST ERROR LOG TIMESTAMPS ==='", + "grep -i 'quota exceeded' /var/log/bigwo/server-error.log | tail -5", + "echo ''", + "echo '=== TRY FRESH CONNECTION TEST ==='", + "curl -s http://127.0.0.1:3012/api/health 2>&1", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_quota2.cjs b/mcp-server-ssh/check_quota2.cjs new file mode 100644 index 0000000..6820d94 --- /dev/null +++ b/mcp-server-ssh/check_quota2.cjs @@ -0,0 +1,30 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== S2S ENV CONFIG (full) ==='", + "grep -E 'VOLC_S2S|VOLC_DIALOG|VOLC_RTC|VOLC_ACCESS' /www/wwwroot/demo.tensorgrove.com.cn/server/.env", + "echo ''", + "echo '=== ALL QUOTA ERRORS in server-error.log ==='", + "grep -i 'quota' /var/log/bigwo/server-error.log 2>/dev/null | tail -10", + "echo ''", + "echo '=== ALL QUOTA ERRORS in server-out.log ==='", + "grep -i 'quota' /var/log/bigwo/server-out.log 2>/dev/null | tail -10", + "echo ''", + "echo '=== LAST 20 UPSTREAM EVENTS ==='", + "grep -E 'upstream ready|upstream closed|upstream error' /var/log/bigwo/server-out.log 2>/dev/null | tail -20", + "echo ''", + "echo '=== MOST RECENT ERROR LOG ==='", + "tail -20 /var/log/bigwo/server-error.log 2>/dev/null", + "echo ''", + "echo '=== MOST RECENT OUT LOG ==='", + "tail -20 /var/log/bigwo/server-out.log 2>/dev/null", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_speaker.cjs b/mcp-server-ssh/check_speaker.cjs new file mode 100644 index 0000000..7faa7c0 --- /dev/null +++ b/mcp-server-ssh/check_speaker.cjs @@ -0,0 +1,21 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== .env speaker/s2s config ==='", + "grep -i 'speaker\\|s2s\\|tts\\|voice\\|model' /www/wwwroot/demo.tensorgrove.com.cn/server/.env 2>/dev/null || echo 'no match'", + "echo ''", + "echo '=== Latest session start payload ==='", + "grep -i 'buildStartSession\\|speaker\\|system_role\\|speaking_style\\|model.*version\\|session.*start' /var/log/bigwo/server-out.log | tail -10", + "echo ''", + "echo '=== Current nativeVoiceGateway speaker line ==='", + "grep -n 'speaker' /www/wwwroot/demo.tensorgrove.com.cn/server/services/nativeVoiceGateway.js | head -5", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', d => o += d); + s.stderr.on('data', d => o += d); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_voice.cjs b/mcp-server-ssh/check_voice.cjs new file mode 100644 index 0000000..fd11be0 --- /dev/null +++ b/mcp-server-ssh/check_voice.cjs @@ -0,0 +1,20 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== SPEAKER ENV ==='", + "grep -oP 'VOLC_S2S_SPEAKER_ID=\\K.*' /www/wwwroot/demo.tensorgrove.com.cn/server/.env", + "echo ''", + "echo '=== LATEST SESSION CONFIG ==='", + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Session: $LAST_SID\"", + "grep -E 'speaker|SayHello|tts_event|ttsType|voice|config' /var/log/bigwo/server-out.log | tail -20", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_voice2.cjs b/mcp-server-ssh/check_voice2.cjs new file mode 100644 index 0000000..342672b --- /dev/null +++ b/mcp-server-ssh/check_voice2.cjs @@ -0,0 +1,21 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== .env SPEAKER lines ==='", + "grep -i 'speaker' /www/wwwroot/demo.tensorgrove.com.cn/server/.env || echo 'NO SPEAKER in .env'", + "echo ''", + "echo '=== .env S2S lines ==='", + "grep -i 's2s' /www/wwwroot/demo.tensorgrove.com.cn/server/.env || echo 'NO S2S in .env'", + "echo ''", + "echo '=== Session startup log (speaker) ==='", + "grep -i 'speaker\\|tts.*config\\|session.*start\\|StartSession' /var/log/bigwo/server-out.log | tail -10", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/check_voice_issue.cjs b/mcp-server-ssh/check_voice_issue.cjs new file mode 100644 index 0000000..4e91002 --- /dev/null +++ b/mcp-server-ssh/check_voice_issue.cjs @@ -0,0 +1,29 @@ +const { Client } = require('ssh2'); +const c = new Client(); +c.on('ready', () => { + const cmd = [ + "echo '=== LAST SESSION VOICE LOGS ==='", + "LAST_SID=$(grep 'upstream ready' /var/log/bigwo/server-out.log | tail -1 | grep -oP 'session=\\K[^ ]+')", + "echo \"Last session: $LAST_SID\"", + "grep \"$LAST_SID\" /var/log/bigwo/server-out.log | grep -v 'partial' | tail -40", + "echo ''", + "echo '=== START SESSION PAYLOAD EVIDENCE ==='", + "grep -E 'buildStartSession|StartSession|model.*version|modelVersion|system_role' /var/log/bigwo/server-out.log | tail -10", + "echo ''", + "echo '=== UPSTREAM ASSISTANT TEXT ==='", + "grep 'upstream assistant' /var/log/bigwo/server-out.log | tail -10", + "echo ''", + "echo '=== 旅游/江西 RELATED ==='", + "grep -i '旅游\\|江西\\|景点' /var/log/bigwo/server-out.log | tail -10", + "echo ''", + "echo '=== processReply LOGS ==='", + "grep 'processReply' /var/log/bigwo/server-out.log | tail -15", + ].join(' && '); + c.exec(cmd, (err, s) => { + if (err) { console.error(err); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log(o); c.end(); }); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/deploy_asr_alias_fix.cjs b/mcp-server-ssh/deploy_asr_alias_fix.cjs new file mode 100644 index 0000000..4514f09 --- /dev/null +++ b/mcp-server-ssh/deploy_asr_alias_fix.cjs @@ -0,0 +1,93 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const SSH_CONFIG = { + host: '119.45.10.34', + port: 22, + username: 'root', + password: '#xyzh%CS#2512@28', + readyTimeout: 10000, +}; + +const PROJECT = '/www/wwwroot/demo.tensorgrove.com.cn'; +const LOCAL_BASE = path.join(__dirname, '..', 'test2'); +const filesToDeploy = [ + { + local: 'server/services/realtimeDialogRouting.js', + remote: `${PROJECT}/server/services/realtimeDialogRouting.js`, + }, + { + local: 'server/services/nativeVoiceGateway.js', + remote: `${PROJECT}/server/services/nativeVoiceGateway.js`, + }, +]; + +function sshExec(command, timeout = 30000) { + return new Promise((resolve, reject) => { + const conn = new Client(); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + conn.end(); + resolve({ stdout, stderr: stderr + '\n[TIMEOUT]', code: -1 }); + }, timeout); + conn.on('ready', () => { + conn.exec(command, (err, stream) => { + if (err) { clearTimeout(timer); conn.end(); return reject(err); } + stream.on('close', (code) => { clearTimeout(timer); conn.end(); resolve({ stdout, stderr, code }); }); + stream.on('data', (d) => { stdout += d.toString(); }); + stream.stderr.on('data', (d) => { stderr += d.toString(); }); + }); + }).on('error', (err) => { clearTimeout(timer); reject(err); }).connect(SSH_CONFIG); + }); +} + +function sshUpload(localPath, remotePath) { + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.on('ready', () => { + conn.sftp((err, sftp) => { + if (err) { conn.end(); return reject(err); } + const content = fs.readFileSync(localPath); + const ws = sftp.createWriteStream(remotePath); + ws.on('close', () => { conn.end(); resolve(); }); + ws.on('error', (e) => { conn.end(); reject(e); }); + ws.write(content); + ws.end(); + }); + }).on('error', (err) => reject(err)).connect(SSH_CONFIG); + }); +} + +async function main() { + console.log('=== 部署一成系统 ASR 别名修复 ==='); + const backupDir = `${PROJECT}/server/_backup_${Date.now()}`; + await sshExec(`mkdir -p ${backupDir} && cp ${PROJECT}/server/services/realtimeDialogRouting.js ${PROJECT}/server/services/nativeVoiceGateway.js ${backupDir}/`); + console.log('备份目录:', backupDir); + + for (const item of filesToDeploy) { + const localPath = path.join(LOCAL_BASE, item.local.replace(/\//g, path.sep)); + await sshUpload(localPath, item.remote); + console.log('上传完成:', item.remote); + } + + const restart = await sshExec('pm2 restart bigwo-server 2>&1'); + console.log(restart.stdout); + + await new Promise((r) => setTimeout(r, 3000)); + + const health = await sshExec('curl -s http://127.0.0.1:3012/api/health 2>&1'); + console.log('Health:', health.stdout); + + const pm2 = await sshExec('pm2 list 2>&1'); + console.log(pm2.stdout); + + const verify = await sshExec(`wc -l ${PROJECT}/server/services/realtimeDialogRouting.js ${PROJECT}/server/services/nativeVoiceGateway.js 2>&1`); + console.log(verify.stdout); +} + +main().catch((e) => { + console.error('Fatal:', e.message); + process.exit(1); +}); diff --git a/mcp-server-ssh/deploy_chat_switch_fix.cjs b/mcp-server-ssh/deploy_chat_switch_fix.cjs new file mode 100644 index 0000000..a8ba925 --- /dev/null +++ b/mcp-server-ssh/deploy_chat_switch_fix.cjs @@ -0,0 +1,82 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const SSH_CONFIG = { + host: '119.45.10.34', + port: 22, + username: 'root', + password: '#xyzh%CS#2512@28', + readyTimeout: 10000, +}; + +const PROJECT = '/www/wwwroot/demo.tensorgrove.com.cn'; +const LOCAL_FILE = path.join(__dirname, '..', 'test2', 'server', 'services', 'realtimeDialogRouting.js'); +const REMOTE_FILE = `${PROJECT}/server/services/realtimeDialogRouting.js`; + +function sshExec(command, timeout = 30000) { + return new Promise((resolve, reject) => { + const conn = new Client(); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + conn.end(); + resolve({ stdout, stderr: stderr + '\n[TIMEOUT]', code: -1 }); + }, timeout); + conn.on('ready', () => { + conn.exec(command, (err, stream) => { + if (err) { clearTimeout(timer); conn.end(); return reject(err); } + stream.on('close', (code) => { clearTimeout(timer); conn.end(); resolve({ stdout, stderr, code }); }); + stream.on('data', (d) => { stdout += d.toString(); }); + stream.stderr.on('data', (d) => { stderr += d.toString(); }); + }); + }).on('error', (err) => { clearTimeout(timer); reject(err); }).connect(SSH_CONFIG); + }); +} + +function sshUpload(localPath, remotePath) { + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.on('ready', () => { + conn.sftp((err, sftp) => { + if (err) { conn.end(); return reject(err); } + const content = fs.readFileSync(localPath); + const ws = sftp.createWriteStream(remotePath); + ws.on('close', () => { conn.end(); resolve(); }); + ws.on('error', (e) => { conn.end(); reject(e); }); + ws.write(content); + ws.end(); + }); + }).on('error', (err) => reject(err)).connect(SSH_CONFIG); + }); +} + +async function main() { + console.log('=== 部署闲聊切换修复 ==='); + const backupDir = `${PROJECT}/server/_backup_${Date.now()}`; + const backup = await sshExec(`mkdir -p ${backupDir} && cp ${REMOTE_FILE} ${backupDir}/`); + console.log('备份目录:', backupDir); + if (backup.stderr) console.log(backup.stderr); + + await sshUpload(LOCAL_FILE, REMOTE_FILE); + console.log('上传完成:', REMOTE_FILE); + + const restart = await sshExec('pm2 restart bigwo-server 2>&1'); + console.log(restart.stdout); + + await new Promise((r) => setTimeout(r, 3000)); + + const health = await sshExec('curl -s http://127.0.0.1:3012/api/health 2>&1'); + console.log('Health:', health.stdout); + + const pm2 = await sshExec('pm2 list 2>&1'); + console.log(pm2.stdout); + + const verify = await sshExec(`wc -l ${REMOTE_FILE} 2>&1`); + console.log(verify.stdout); +} + +main().catch((e) => { + console.error('Fatal:', e.message); + process.exit(1); +}); diff --git a/mcp-server-ssh/deploy_fix.cjs b/mcp-server-ssh/deploy_fix.cjs new file mode 100644 index 0000000..cfe561f --- /dev/null +++ b/mcp-server-ssh/deploy_fix.cjs @@ -0,0 +1,51 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const c = new Client(); +const localFile = path.join(__dirname, '..', 'test2', 'server', 'services', 'toolExecutor.js'); +const remotePath = '/www/wwwroot/demo.tensorgrove.com.cn/server/services/toolExecutor.js'; + +const content = fs.readFileSync(localFile, 'utf8'); + +c.on('ready', () => { + console.log('SSH connected, deploying toolExecutor.js...'); + + // Use SFTP to write the file + c.sftp((err, sftp) => { + if (err) { + console.error('SFTP error:', err.message); + c.end(); + return; + } + + const writeStream = sftp.createWriteStream(remotePath); + writeStream.on('close', () => { + console.log('File uploaded successfully.'); + + // Restart PM2 + c.exec('pm2 restart bigwo-server && sleep 2 && pm2 logs bigwo-server --nostream --lines 10', (err, s) => { + if (err) { + console.error('Restart error:', err.message); + c.end(); + return; + } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { + console.log('PM2 restart output:\n' + o); + c.end(); + }); + }); + }); + + writeStream.on('error', (err) => { + console.error('Write error:', err.message); + c.end(); + }); + + writeStream.write(content); + writeStream.end(); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/deploy_fixes.cjs b/mcp-server-ssh/deploy_fixes.cjs new file mode 100644 index 0000000..e302bda --- /dev/null +++ b/mcp-server-ssh/deploy_fixes.cjs @@ -0,0 +1,55 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const c = new Client(); +const REMOTE_BASE = '/www/wwwroot/demo.tensorgrove.com.cn/server/services'; + +// Read local files +const gatewayPath = path.join(__dirname, '..', 'test2', 'server', 'services', 'nativeVoiceGateway.js'); +const toolExecPath = path.join(__dirname, '..', 'test2', 'server', 'services', 'toolExecutor.js'); + +const gatewayContent = fs.readFileSync(gatewayPath, 'utf8'); +const toolExecContent = fs.readFileSync(toolExecPath, 'utf8'); + +console.log(`Gateway file size: ${gatewayContent.length} bytes`); +console.log(`ToolExecutor file size: ${toolExecContent.length} bytes`); + +c.on('ready', () => { + console.log('SSH connected, starting SFTP...'); + c.sftp((err, sftp) => { + if (err) { console.error('SFTP error:', err); c.end(); return; } + + let done = 0; + const total = 2; + function checkDone() { + done++; + if (done >= total) { + console.log('All files uploaded. Restarting PM2...'); + c.exec('cd /www/wwwroot/demo.tensorgrove.com.cn && pm2 restart bigwo-server && sleep 2 && pm2 logs bigwo-server --lines 20 --nostream', (err2, s) => { + if (err2) { console.error('Restart error:', err2); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { + console.log('=== PM2 Restart Output ==='); + console.log(o); + c.end(); + }); + }); + } + } + + // Upload nativeVoiceGateway.js + const ws1 = sftp.createWriteStream(`${REMOTE_BASE}/nativeVoiceGateway.js`); + ws1.on('close', () => { console.log('✓ nativeVoiceGateway.js uploaded'); checkDone(); }); + ws1.on('error', (e) => { console.error('Upload gateway error:', e); }); + ws1.end(gatewayContent); + + // Upload toolExecutor.js + const ws2 = sftp.createWriteStream(`${REMOTE_BASE}/toolExecutor.js`); + ws2.on('close', () => { console.log('✓ toolExecutor.js uploaded'); checkDone(); }); + ws2.on('error', (e) => { console.error('Upload toolExec error:', e); }); + ws2.end(toolExecContent); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/deploy_voice_fix.cjs b/mcp-server-ssh/deploy_voice_fix.cjs new file mode 100644 index 0000000..2a84b75 --- /dev/null +++ b/mcp-server-ssh/deploy_voice_fix.cjs @@ -0,0 +1,30 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const c = new Client(); +const localFile = path.join(__dirname, '..', 'test2', 'server', 'services', 'nativeVoiceGateway.js'); +const remotePath = '/www/wwwroot/demo.tensorgrove.com.cn/server/services/nativeVoiceGateway.js'; + +const content = fs.readFileSync(localFile, 'utf8'); + +c.on('ready', () => { + console.log('SSH connected, deploying nativeVoiceGateway.js...'); + c.sftp((err, sftp) => { + if (err) { console.error('SFTP error:', err.message); c.end(); return; } + const ws = sftp.createWriteStream(remotePath); + ws.on('close', () => { + console.log('File uploaded successfully.'); + c.exec('pm2 restart bigwo-server && sleep 2 && pm2 logs bigwo-server --nostream --lines 5', (err, s) => { + if (err) { console.error('Restart error:', err.message); c.end(); return; } + let o = ''; + s.on('data', (d) => (o += d)); + s.stderr.on('data', (d) => (o += d)); + s.on('close', () => { console.log('PM2 restart:\n' + o); c.end(); }); + }); + }); + ws.on('error', (err) => { console.error('Write error:', err.message); c.end(); }); + ws.write(content); + ws.end(); + }); +}).connect({ host: '119.45.10.34', port: 22, username: 'root', password: '#xyzh%CS#2512@28' }); diff --git a/mcp-server-ssh/deploy_voice_kb_fix.cjs b/mcp-server-ssh/deploy_voice_kb_fix.cjs new file mode 100644 index 0000000..54f87b5 --- /dev/null +++ b/mcp-server-ssh/deploy_voice_kb_fix.cjs @@ -0,0 +1,141 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const SSH_CONFIG = { + host: '119.45.10.34', + port: 22, + username: 'root', + password: '#xyzh%CS#2512@28', + readyTimeout: 10000, +}; + +const PROJECT = '/www/wwwroot/demo.tensorgrove.com.cn'; +const LOCAL_BASE = path.join(__dirname, '..', 'test2'); + +function sshExec(command, timeout = 30000) { + return new Promise((resolve, reject) => { + const conn = new Client(); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + conn.end(); + resolve({ stdout, stderr: stderr + '\n[TIMEOUT]', code: -1 }); + }, timeout); + conn.on('ready', () => { + conn.exec(command, (err, stream) => { + if (err) { clearTimeout(timer); conn.end(); return reject(err); } + stream.on('close', (code) => { clearTimeout(timer); conn.end(); resolve({ stdout, stderr, code }); }); + stream.on('data', (d) => { stdout += d.toString(); }); + stream.stderr.on('data', (d) => { stderr += d.toString(); }); + }); + }).on('error', (err) => { clearTimeout(timer); reject(err); }).connect(SSH_CONFIG); + }); +} + +function sshUpload(localPath, remotePath) { + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.on('ready', () => { + conn.sftp((err, sftp) => { + if (err) { conn.end(); return reject(err); } + const content = fs.readFileSync(localPath); + const ws = sftp.createWriteStream(remotePath); + ws.on('close', () => { conn.end(); resolve(); }); + ws.on('error', (e) => { conn.end(); reject(e); }); + ws.write(content); + ws.end(); + }); + }).on('error', (err) => reject(err)).connect(SSH_CONFIG); + }); +} + +const filesToDeploy = [ + // 服务端文件 + { local: 'server/services/toolExecutor.js', remote: `${PROJECT}/server/services/toolExecutor.js`, desc: '知识库回答精准度修复' }, + { local: 'server/services/nativeVoiceGateway.js', remote: `${PROJECT}/server/services/nativeVoiceGateway.js`, desc: '语音连接提前ready+超时兜底' }, + // 客户端构建产物 + { local: 'client/dist/index.html', remote: `${PROJECT}/client/dist/index.html`, desc: '客户端入口' }, + { local: 'client/dist/assets/index-DFs3zFyd.css', remote: `${PROJECT}/client/dist/assets/index-DFs3zFyd.css`, desc: '客户端样式' }, + { local: 'client/dist/assets/index-DiJ8zsnj.js', remote: `${PROJECT}/client/dist/assets/index-DiJ8zsnj.js`, desc: '客户端JS(含getUserMedia并行化+超时)' }, +]; + +async function main() { + console.log('=== 语音通话延迟修复 + 知识库精准度修复 部署 ===\n'); + + // 1. 备份 + console.log('--- 1. 备份服务器文件 ---'); + const backupDir = `${PROJECT}/server/_backup_${Date.now()}`; + const backupCmd = [ + `mkdir -p ${backupDir}`, + `cp ${PROJECT}/server/services/toolExecutor.js ${backupDir}/`, + `cp ${PROJECT}/server/services/nativeVoiceGateway.js ${backupDir}/`, + `mkdir -p ${backupDir}/client_dist_assets`, + `cp -r ${PROJECT}/client/dist/assets/* ${backupDir}/client_dist_assets/ 2>/dev/null || true`, + `cp ${PROJECT}/client/dist/index.html ${backupDir}/client_dist_index.html 2>/dev/null || true`, + ].join(' && '); + const backupResult = await sshExec(backupCmd); + console.log(`备份目录: ${backupDir}`); + if (backupResult.stderr && !backupResult.stderr.includes('true')) console.log(backupResult.stderr); + + // 2. 确保远程目录存在 + console.log('\n--- 2. 确保目录存在 ---'); + await sshExec(`mkdir -p ${PROJECT}/client/dist/assets`); + console.log('OK'); + + // 3. 清理旧的客户端资源文件 + console.log('\n--- 3. 清理旧客户端资源 ---'); + const cleanResult = await sshExec(`rm -f ${PROJECT}/client/dist/assets/index-*.js ${PROJECT}/client/dist/assets/index-*.css 2>&1`); + console.log(cleanResult.stdout || 'OK'); + + // 4. 上传文件 + console.log('\n--- 4. 上传文件 ---'); + for (const { local, remote, desc } of filesToDeploy) { + const localPath = path.join(LOCAL_BASE, local.replace(/\//g, path.sep)); + try { + if (!fs.existsSync(localPath)) { + console.log(`⚠️ 跳过 ${local} (本地文件不存在)`); + continue; + } + await sshUpload(localPath, remote); + const size = fs.statSync(localPath).size; + console.log(`✅ ${local} → ${remote} (${size}B) [${desc}]`); + } catch (e) { + console.error(`❌ ${local}: ${e.message}`); + } + } + + // 5. 重启 PM2 + console.log('\n--- 5. 重启 PM2 ---'); + const restart = await sshExec('pm2 restart bigwo-server 2>&1'); + console.log(restart.stdout); + + // 6. 等待检查 + console.log('--- 6. 等待3秒后检查 ---'); + await new Promise(r => setTimeout(r, 3000)); + + const health = await sshExec('curl -s http://127.0.0.1:3012/api/health 2>&1'); + console.log('Health:', health.stdout); + + const pm2 = await sshExec('pm2 list 2>&1'); + console.log(pm2.stdout); + + // 7. 检查启动日志 + console.log('--- 7. 启动日志 ---'); + const logs = await sshExec('pm2 logs bigwo-server --nostream --lines 15 2>&1'); + console.log(logs.stdout); + if (logs.stderr) console.log(logs.stderr); + + // 8. 验证文件 + console.log('--- 8. 验证文件行数 ---'); + const wc = await sshExec(`wc -l ${PROJECT}/server/services/toolExecutor.js ${PROJECT}/server/services/nativeVoiceGateway.js 2>&1`); + console.log(wc.stdout); + + const lsAssets = await sshExec(`ls -la ${PROJECT}/client/dist/assets/ 2>&1`); + console.log('客户端资源文件:'); + console.log(lsAssets.stdout); + + console.log('\n=== 部署完成 ==='); +} + +main().catch(e => { console.error('Fatal:', e.message); process.exit(1); }); diff --git a/mcp-server-ssh/e2e_test.cjs b/mcp-server-ssh/e2e_test.cjs new file mode 100644 index 0000000..af6a619 --- /dev/null +++ b/mcp-server-ssh/e2e_test.cjs @@ -0,0 +1,87 @@ +const https = require('https'); + +const BASE = 'https://demo.tensorgrove.com.cn'; + +function post(path, body) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE); + const data = JSON.stringify(body); + const req = https.request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, + timeout: 60000, + }, (res) => { + let chunks = ''; + res.on('data', (d) => chunks += d); + res.on('end', () => { + try { resolve(JSON.parse(chunks)); } catch { resolve(chunks); } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + req.write(data); + req.end(); + }); +} + +const TESTS = [ + { q: '我们公司的产品有哪些', expect: 'kb', desc: '泛产品查询' }, + { q: '基础三合一是什么', expect: 'kb', desc: '基础三合一' }, + { q: '火炉原理是什么', expect: 'kb', desc: '火炉原理' }, + { q: '一程系统的基础三合一是什么', expect: 'kb', desc: '一程系统基础三合一' }, + { q: '我们公司卖手机和平板吗', expect: 'chat', desc: '手机平板(应走Coze)' }, +]; + +async function runTests() { + // Create a session first + let sessionId; + try { + const startRes = await post('/api/chat/start', { userId: 'e2e_test_' + Date.now() }); + sessionId = startRes.sessionId; + console.log('Session:', sessionId); + } catch (e) { + console.error('Failed to create session:', e.message); + return; + } + + const results = []; + for (const test of TESTS) { + try { + const sid = 'e2e_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); + await post('/api/chat/start', { sessionId: sid }); + const res = await post('/api/chat/send', { sessionId: sid, message: test.q }); + const reply = (res.data && res.data.content) || res.reply || ''; + const replyShort = reply.slice(0, 100); + + // Check for hallucination indicators + const hasHallucination = /(手机|平板|笔记本|智能手表|护肤品|彩妆|香水|化妆品|电视|冰箱|洗衣机)/i.test(reply); + const hasPMContent = /(PM|FitLine|细胞营养|Activize|Basics|Restorate|NTC|火炉|阿育吠陀|一成系统|基础套装|大白|小红|小白|儿童倍适|营养)/i.test(reply); + + let status = '✅'; + if (test.expect === 'kb' && hasHallucination) status = '❌幻觉'; + else if (test.expect === 'kb' && !hasPMContent) status = '⚠️无PM内容'; + + results.push({ desc: test.desc, q: test.q, status, reply: replyShort }); + console.log(`${status} [${test.desc}] ${test.q} → ${replyShort}`); + } catch (e) { + results.push({ desc: test.desc, q: test.q, status: '❌ERROR', reply: e.message }); + console.log(`❌ [${test.desc}] ${test.q} → ERROR: ${e.message}`); + } + } + + console.log('\n=== 测试总结 ==='); + const pass = results.filter(r => r.status === '✅').length; + const warn = results.filter(r => r.status.startsWith('⚠️')).length; + const fail = results.filter(r => r.status.startsWith('❌')).length; + console.log(`通过: ${pass}, 警告: ${warn}, 失败: ${fail}, 总计: ${results.length}`); + + if (warn + fail > 0) { + console.log('\n=== 问题详情 ==='); + results.filter(r => r.status !== '✅').forEach(r => { + console.log(`${r.status} [${r.desc}] ${r.q}`); + console.log(` 回答: ${r.reply}`); + }); + } +} + +runTests().catch(e => console.error('Fatal:', e)); diff --git a/mcp-server-ssh/verify_scenarios.cjs b/mcp-server-ssh/verify_scenarios.cjs new file mode 100644 index 0000000..d93c117 --- /dev/null +++ b/mcp-server-ssh/verify_scenarios.cjs @@ -0,0 +1,156 @@ +/** + * 本地模拟多场景验证:toolExecutor.js 修复后的路由和分类逻辑 + */ +const path = require('path'); + +// 模拟环境变量(模拟服务器配置) +process.env.VOLC_ARK_ENDPOINT_ID = 'ep-xxx'; +process.env.VOLC_ARK_API_KEY = 'fake-key'; +process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS = 'kb-f94cc30193b3b707,kb-a69b0928e1714de7,kb-a6c238e38ca81478,kb-149300b22195d2bf,kb-c7e7cd2bf0580fa0,kb-177fb1d978d88e0e'; +process.env.VOLC_ARK_KNOWLEDGE_BASE_ROUTING = JSON.stringify([ + {"name":"product","dataset_ids":["kb-f94cc30193b3b707"],"keywords":["pm产品","fitline","pm-fitline","产品","细胞营养素","activize","oxyplus","basics","restorate","儿童倍适","ntc","营养保送","小红产品","大白产品","小白产品","火炉原理","阿育吠陀","成分","功效","吃法","用法","服用","好转反应","活动","促销","分数"]}, + {"name":"company","dataset_ids":["kb-a69b0928e1714de7"],"keywords":["pm公司","德国pm公司","公司","地址","电话","联系方式","实力","背景","成立","总部","分公司"]}, + {"name":"training","dataset_ids":["kb-a6c238e38ca81478"],"keywords":["培训","新人","起步三关","精品会议","会议组织","成长上总裁","团队培训","新人入门"]}, + {"name":"investment","dataset_ids":["kb-149300b22195d2bf"],"keywords":["招商","代理","代理商","合作","加盟","招募","事业机会","创业","招商稿"]}, + {"name":"system","dataset_ids":["kb-c7e7cd2bf0580fa0"],"keywords":["一成系统","ai众享","数字化工作室","盛咖学愿","系统","邀约话术","文化解析","ai赋能","团队发展"]}, + {"name":"general","dataset_ids":["kb-177fb1d978d88e0e"],"keywords":["一成ai","ai落地","转观念","对比","综合知识库"]} +]); + +// 加载修复后的 toolExecutor +const ToolExecutor = require(path.join(__dirname, '..', 'test2', 'server', 'services', 'toolExecutor')); + +console.log('====== 多场景逻辑验证 ======\n'); + +// 场景定义 +const scenarios = [ + { + name: '场景1: 德国PM基础三合一(截图问题)', + query: '给我介绍一下德国PM的基础三合一吧?', + context: [], + expectRoute: 'product', + expectDeterministic: '德国PM细胞营养素 基础套装 大白 小红 小白', + }, + { + name: '场景2: 我们公司的产品(上下文有基础套装)', + query: '介绍一下我们公司的产品。', + context: [ + { role: 'user', content: '基础三合一是什么?' }, + { role: 'assistant', content: '德国PM细胞营养素基础套装包括大白、小红、小白三款产品。' }, + ], + expectRoute: 'product', + expectDeterministic: '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适', + }, + { + name: '场景3: 德国PM公司介绍(纯公司查询)', + query: '介绍一下德国PM公司', + context: [], + expectRoute: 'company', + }, + { + name: '场景4: 德国PM产品(产品+德国PM混合)', + query: '德国PM的产品有哪些', + context: [], + expectRoute: 'product', + }, + { + name: '场景5: 一成系统(系统优先路由)', + query: '一成系统是什么', + context: [], + expectRoute: 'system', + }, + { + name: '场景6: 河南胡辣汤(闲聊,不走知识库)', + query: '河南胡辣汤', + context: [], + expectRoute: 'default_or_none', + }, + { + name: '场景7: 追问"这个怎么用"(上下文有小红产品)', + query: '这个怎么用', + context: [ + { role: 'user', content: '小红产品是什么?' }, + { role: 'assistant', content: 'Activize Oxyplus 是...' }, + ], + expectDeterministic: 'Fitline小红产品提升能量原理', + }, + { + name: '场景8: classifyKnowledgeAnswer - 未命中模式1', + query: 'test', + classifyContent: '关于PM细胞营养素基础套装,目前我这边没有找到这三个产品的具体成分和功效说明。建议你直接查看产品包装上的详细信息,或联系官方客服获取准确的使用指导。', + expectHit: false, + }, + { + name: '场景9: classifyKnowledgeAnswer - 未命中模式2', + query: 'test', + classifyContent: '我没有找到德国PM细胞营养素基础三合一的相关介绍。你是想了解它的成分、功效,还是使用方法呢?', + expectHit: false, + }, + { + name: '场景10: classifyKnowledgeAnswer - 正常命中', + query: 'test', + classifyContent: 'PM细胞营养素基础套装包含大白Basics、小红Activize和小白Restorate三款产品,通过NTC营养保送系统发挥协同作用。', + expectHit: true, + }, + { + name: '场景11: 招商合作', + query: '怎么加盟代理', + context: [], + expectRoute: 'investment', + }, + { + name: '场景12: 新人培训', + query: '新人起步三关是什么', + context: [], + expectRoute: 'training', + }, +]; + +let passed = 0; +let failed = 0; + +for (const s of scenarios) { + console.log(`--- ${s.name} ---`); + + // 测试 classifyKnowledgeAnswer + if (s.classifyContent !== undefined) { + const result = ToolExecutor.classifyKnowledgeAnswer(s.query, s.classifyContent); + const ok = result.hit === s.expectHit; + console.log(` classify hit=${result.hit} reason=${result.reason} ${ok ? '✅' : '❌ EXPECTED hit=' + s.expectHit}`); + if (ok) passed++; else failed++; + console.log(''); + continue; + } + + // 测试路由 + const context = s.context || []; + const normalizedQuery = ToolExecutor.normalizeKnowledgeQueryAlias(s.query); + const anchored = ToolExecutor.applyKnowledgeQueryAnchor(normalizedQuery); + const deterministicQuery = ToolExecutor.buildDeterministicKnowledgeQuery(anchored, context); + const effectiveQuery = deterministicQuery || anchored; + const kbTarget = ToolExecutor.selectKnowledgeBaseTargets(effectiveQuery, context); + + console.log(` normalized: "${normalizedQuery}"`); + console.log(` deterministic: "${deterministicQuery}"`); + console.log(` effective: "${effectiveQuery}"`); + console.log(` routes: [${kbTarget.matchedRoutes.join(', ')}]`); + console.log(` datasets: [${kbTarget.datasetIds.join(', ')}]`); + + if (s.expectDeterministic) { + const ok = deterministicQuery === s.expectDeterministic; + console.log(` deterministic check: ${ok ? '✅' : '❌ EXPECTED "' + s.expectDeterministic + '"'}`); + if (ok) passed++; else failed++; + } + + if (s.expectRoute) { + const ok = s.expectRoute === 'default_or_none' + ? kbTarget.matchedRoutes.includes('default') || kbTarget.matchedRoutes.length === 0 + : kbTarget.matchedRoutes.includes(s.expectRoute); + console.log(` route check: ${ok ? '✅' : '❌ EXPECTED route=' + s.expectRoute}`); + if (ok) passed++; else failed++; + } + + console.log(''); +} + +console.log(`\n====== 结果:${passed} 通过, ${failed} 失败 ======`); +if (failed > 0) process.exit(1); diff --git a/test2/client/src/hooks/useNativeVoiceChat.js b/test2/client/src/hooks/useNativeVoiceChat.js index 59b6cda..99c6e8a 100644 --- a/test2/client/src/hooks/useNativeVoiceChat.js +++ b/test2/client/src/hooks/useNativeVoiceChat.js @@ -11,28 +11,19 @@ export function useNativeVoiceChat() { const [duration, setDuration] = useState(0); const sessionRef = useRef(null); const timerRef = useRef(null); - const greetingUtteranceRef = useRef(null); + const greetingFallbackTimerRef = useRef(null); + const greetingAudioDetectedRef = useRef(false); - const stopGreeting = useCallback(() => { - if (typeof window !== 'undefined' && 'speechSynthesis' in window) { - window.speechSynthesis.cancel(); + const clearGreetingFallback = useCallback(() => { + if (greetingFallbackTimerRef.current) { + clearTimeout(greetingFallbackTimerRef.current); + greetingFallbackTimerRef.current = null; } - greetingUtteranceRef.current = null; }, []); - const playGreeting = useCallback((text) => { - const greetingText = String(text || '').trim(); - if (!greetingText || typeof window === 'undefined' || !('speechSynthesis' in window) || typeof window.SpeechSynthesisUtterance === 'undefined') { - return; - } - stopGreeting(); - const utterance = new window.SpeechSynthesisUtterance(greetingText); - utterance.lang = 'zh-CN'; - utterance.rate = 1; - utterance.pitch = 1; - greetingUtteranceRef.current = utterance; - window.speechSynthesis.speak(utterance); - }, [stopGreeting]); + const stopGreeting = useCallback(() => { + clearGreetingFallback(); + }, [clearGreetingFallback]); useEffect(() => { nativeVoiceService.on('onSubtitle', (subtitle) => { @@ -45,21 +36,60 @@ export function useNativeVoiceChat() { const finals = prev.filter((s) => s.isFinal); return [...finals, subtitle]; }); + if (subtitle?.role === 'assistant' && subtitle?.isFinal && /^greeting_/.test(String(subtitle.sequence || ''))) { + clearGreetingFallback(); + greetingAudioDetectedRef.current = false; + greetingFallbackTimerRef.current = setTimeout(() => { + if (!greetingAudioDetectedRef.current) { + nativeVoiceService.requestGreetingReplay(); + } + greetingFallbackTimerRef.current = null; + }, 4000); + } }); nativeVoiceService.on('onConnectionStateChange', setConnectionState); nativeVoiceService.on('onError', (err) => setError(err?.message || 'Native voice error')); + nativeVoiceService.on('onDiagnostic', (diag) => { + if (!diag) return; + if (diag.type === 'audio_chunk') { + greetingAudioDetectedRef.current = true; + clearGreetingFallback(); + return; + } + if (diag.type === 'ws_message' && diag.payload?.type === 'tts_event' && diag.payload?.payload?.tts_type === 'chat_tts_text') { + greetingAudioDetectedRef.current = true; + clearGreetingFallback(); + } + }); + nativeVoiceService.on('onIdleTimeout', (timeout) => { + const mins = Math.round((timeout || 300000) / 60000); + console.log(`[useNativeVoiceChat] Idle timeout (${mins}min), auto disconnecting`); + setError(`通话已空闲${mins}分钟,已自动退出`); + nativeVoiceService.disconnect(); + setIsActive(false); + setIsMuted(false); + setConnectionState('disconnected'); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + sessionRef.current = null; + }); return () => { stopGreeting(); nativeVoiceService.disconnect(); if (timerRef.current) clearInterval(timerRef.current); }; - }, [stopGreeting]); + }, [clearGreetingFallback, stopGreeting]); const start = useCallback(async (options = {}) => { setError(null); setIsConnecting(true); + greetingAudioDetectedRef.current = false; + clearGreetingFallback(); + stopGreeting(); try { const userId = `user_${Date.now().toString(36)}`; @@ -74,12 +104,12 @@ export function useNativeVoiceChat() { speakingStyle: options.speakingStyle, modelVersion: options.modelVersion, speaker: options.speaker, + greetingText: options.greetingText, }); setIsActive(true); setSubtitles([]); setDuration(0); - playGreeting(options.greetingText); timerRef.current = setInterval(() => { setDuration((d) => d + 1); }, 1000); @@ -93,7 +123,7 @@ export function useNativeVoiceChat() { } finally { setIsConnecting(false); } - }, []); + }, [clearGreetingFallback, stopGreeting]); const stop = useCallback(async () => { let result = { sessionId: null, subtitles: [] }; diff --git a/test2/client/src/services/nativeVoiceService.js b/test2/client/src/services/nativeVoiceService.js index f6d514f..f84cf95 100644 --- a/test2/client/src/services/nativeVoiceService.js +++ b/test2/client/src/services/nativeVoiceService.js @@ -18,6 +18,7 @@ class NativeVoiceService { onError: null, onAssistantPending: null, onDiagnostic: null, + onIdleTimeout: null, }; } @@ -76,7 +77,7 @@ class NativeVoiceService { } } - async connect({ sessionId, userId, botName, systemRole, speakingStyle, modelVersion, speaker }) { + async connect({ sessionId, userId, botName, systemRole, speakingStyle, modelVersion, speaker, greetingText }) { await this.disconnect(); const wsUrl = this.resolveWebSocketUrl(sessionId, userId); this.emitConnectionState('connecting'); @@ -86,6 +87,22 @@ class NativeVoiceService { } this.playbackTime = this.playbackContext.currentTime; + // 并行: 同时预获取麦克风和建立WS连接,节省500ms+ + const micPromise = navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + video: false, + }).catch((err) => { + console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.message); + return null; + }); + + const CONNECTION_TIMEOUT_MS = 12000; + await new Promise((resolve, reject) => { this.readyResolver = resolve; this.readyRejector = reject; @@ -93,6 +110,18 @@ class NativeVoiceService { ws.binaryType = 'arraybuffer'; this.ws = ws; + // 超时兜底:避免无限等待 + const timeoutId = setTimeout(() => { + if (this.readyResolver) { + console.warn(`[NativeVoice] Connection timeout (${CONNECTION_TIMEOUT_MS}ms), forcing ready`); + this.readyResolver(); + this.readyResolver = null; + this.readyRejector = null; + } + }, CONNECTION_TIMEOUT_MS); + + const clearTimeoutOnSettle = () => clearTimeout(timeoutId); + ws.onopen = () => { this.emitConnectionState('connected'); ws.send(JSON.stringify({ @@ -104,10 +133,12 @@ class NativeVoiceService { speakingStyle, modelVersion, speaker, + greetingText, })); }; ws.onerror = () => { + clearTimeoutOnSettle(); const error = new Error('WebSocket connection failed'); this.callbacks.onError?.(error); this.readyRejector?.(error); @@ -117,6 +148,7 @@ class NativeVoiceService { }; ws.onclose = () => { + clearTimeoutOnSettle(); this.emitConnectionState('disconnected'); if (this.readyRejector) { this.readyRejector(new Error('WebSocket closed before ready')); @@ -127,14 +159,20 @@ class NativeVoiceService { ws.onmessage = (event) => { if (typeof event.data === 'string') { - this.handleJsonMessage(event.data); + const peek = event.data; + if (peek.includes('"ready"')) { + clearTimeoutOnSettle(); + } + this.handleJsonMessage(peek); return; } this.handleAudioMessage(event.data); }; }); - await this.startCapture(); + // 使用预获取的mediaStream(已并行获取),避免重复申请 + const preFetchedStream = await micPromise; + await this.startCapture(preFetchedStream); } handleJsonMessage(raw) { @@ -164,6 +202,14 @@ class NativeVoiceService { this.callbacks.onAssistantPending?.(!!msg.active); return; } + if (msg.type === 'idle_timeout') { + this.callbacks.onIdleTimeout?.(msg.timeout || 300000); + return; + } + if (msg.type === 'upstream_closed') { + this.callbacks.onError?.(new Error('语音服务已断开,请重新开始通话')); + return; + } if (msg.type === 'error') { this.callbacks.onError?.(new Error(msg.error || 'native voice error')); return; @@ -206,8 +252,8 @@ class NativeVoiceService { this.emitDiagnostic('audio_chunk', { samples: pcm16.length, duration: audioBuffer.duration }); } - async startCapture() { - this.mediaStream = await navigator.mediaDevices.getUserMedia({ + async startCapture(preFetchedStream) { + this.mediaStream = preFetchedStream || await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, noiseSuppression: true, @@ -274,6 +320,13 @@ class NativeVoiceService { }); } + requestGreetingReplay() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'replay_greeting' })); + this.emitDiagnostic('replay_greeting', { sent: true }); + } + } + async disconnect() { if (this.captureProcessor) { this.captureProcessor.disconnect(); diff --git a/test2/server/app.js b/test2/server/app.js index 46364fe..e4a555e 100644 --- a/test2/server/app.js +++ b/test2/server/app.js @@ -109,7 +109,7 @@ app.get('/api/health', (req, res) => { }); // 静态文件服务 -app.use(express.static('../client/dist')); +app.use(express.static(path.join(__dirname, '../client/dist'))); // 处理单页应用路由 app.get('*', (req, res) => { diff --git a/test2/server/db/index.js b/test2/server/db/index.js index d69c173..36d0719 100644 --- a/test2/server/db/index.js +++ b/test2/server/db/index.js @@ -9,10 +9,26 @@ async function ensureColumnExists(tableName, columnName, definitionSql) { } } +async function columnMatchesType(tableName, columnName, expectedType) { + const dbName = process.env.MYSQL_DATABASE || 'bigwo_chat'; + const [rows] = await pool.query( + `SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?`, + [dbName, tableName, columnName] + ); + if (!rows.length) return false; + return rows[0].COLUMN_TYPE.toLowerCase().includes(expectedType.toLowerCase()); +} + async function migrateSchema() { - await pool.execute("ALTER TABLE `sessions` MODIFY COLUMN `mode` ENUM('voice', 'chat') DEFAULT 'chat'"); - await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `role` ENUM('user', 'assistant', 'tool', 'system') NOT NULL"); - await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `source` ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot') NOT NULL"); + if (!(await columnMatchesType('sessions', 'mode', "'chat'"))) { + await pool.execute("ALTER TABLE `sessions` MODIFY COLUMN `mode` ENUM('voice', 'chat') DEFAULT 'chat'"); + } + if (!(await columnMatchesType('messages', 'role', "'system'"))) { + await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `role` ENUM('user', 'assistant', 'tool', 'system') NOT NULL"); + } + if (!(await columnMatchesType('messages', 'source', "'chat_bot'"))) { + await pool.execute("ALTER TABLE `messages` MODIFY COLUMN `source` ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot') NOT NULL"); + } await ensureColumnExists('messages', 'tool_name', '`tool_name` VARCHAR(64) NULL AFTER `source`'); await ensureColumnExists('messages', 'meta_json', '`meta_json` JSON NULL AFTER `tool_name`'); await ensureColumnExists('messages', 'created_at', '`created_at` BIGINT NULL AFTER `tool_name`'); diff --git a/test2/server/routes/chat.js b/test2/server/routes/chat.js index ffcbf0f..76aa073 100644 --- a/test2/server/routes/chat.js +++ b/test2/server/routes/chat.js @@ -9,8 +9,11 @@ const db = require('../db'); // 存储文字对话的会话状态(sessionId -> session) const chatSessions = new Map(); +const BRAND_HARMFUL_PATTERN = /传销|骗局|骗子公司|非法集资|非法经营|不正规|不合法|庞氏骗局|老鼠会|拉人头的|割韭菜/; +const BRAND_SAFE_REPLY = '德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家和地区。如果你想了解更多,可以问我关于PM公司的详细介绍哦。'; + function normalizeAssistantText(text) { - return String(text || '') + let result = String(text || '') .replace(/\r/g, ' ') .replace(/\n{2,}/g, '。') .replace(/\n/g, ' ') @@ -19,6 +22,11 @@ function normalizeAssistantText(text) { .replace(/([。!?;,])\s*([。!?;,])/g, '$2') .replace(/\s+/g, ' ') .trim(); + if (BRAND_HARMFUL_PATTERN.test(result)) { + console.warn(`[Chat][SafeGuard] blocked harmful content: ${JSON.stringify(result.slice(0, 200))}`); + return BRAND_SAFE_REPLY; + } + return result; } async function loadHandoffMessages(sessionId, voiceSubtitles = []) { @@ -77,7 +85,13 @@ function buildInitialContextMessages(session) { } async function buildKnowledgeContextMessages(sessionId, session) { - const dbHistory = await db.getHistoryForLLM(sessionId, 20).catch(() => []); + const recentMessages = await db.getRecentMessages(sessionId, 20).catch(() => []); + const scopedMessages = session?.fromVoice && session?.handoffSummaryUsed + ? recentMessages.filter((item) => !/^voice_/i.test(String(item?.source || ''))) + : recentMessages; + const dbHistory = scopedMessages + .filter((item) => item && (item.role === 'user' || item.role === 'assistant')) + .map((item) => ({ role: item.role, content: item.content })); const summary = String(session?.handoffSummary || '').trim(); if (!summary || session?.handoffSummaryUsed) { return dbHistory; @@ -98,6 +112,14 @@ function extractKnowledgeReply(result) { return typeof result === 'string' ? result : ''; } +function buildFastGreetingReply(message) { + const text = String(message || '').trim(); + if (!/^(喂|你好|您好|嗨|哈喽|hello|hi|在吗|在不在|早上好|中午好|下午好|晚上好|早安|晚安)[,,!。??~~\s]*[啊呀吧呢哦嗯嘛哈的了]*[!。??~~]*$/i.test(text)) { + return ''; + } + return '你好😊!我是大沃智能助手。你可以直接问我一成系统、德国PM产品、招商合作、营养科普等问题,我会尽量快速给你准确回复。'; +} + async function tryKnowledgeReply(sessionId, session, message) { const text = String(message || '').trim(); if (!text) return null; @@ -106,6 +128,9 @@ async function tryKnowledgeReply(sessionId, session, message) { return null; } const result = await ToolExecutor.execute('search_knowledge', { query: text }, context); + if (!result?.hit) { + return null; + } const content = normalizeAssistantText(extractKnowledgeReply(result)); if (!content) { return null; @@ -120,6 +145,8 @@ async function tryKnowledgeReply(sessionId, session, message) { source: result?.source || null, original_query: result?.original_query || text, rewritten_query: result?.rewritten_query || null, + selected_dataset_ids: result?.selected_dataset_ids || null, + selected_kb_routes: result?.selected_kb_routes || null, hit: typeof result?.hit === 'boolean' ? result.hit : null, reason: result?.reason || null, error_type: result?.errorType || null, @@ -188,6 +215,17 @@ router.post('/send', async (req, res) => { // 写入数据库:用户消息 db.addMessage(sessionId, 'user', message, 'chat_user').catch(e => console.warn('[DB] addMessage failed:', e.message)); + const fastGreetingReply = buildFastGreetingReply(message); + if (fastGreetingReply) { + db.addMessage(sessionId, 'assistant', fastGreetingReply, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message)); + return res.json({ + success: true, + data: { + content: fastGreetingReply, + }, + }); + } + const knowledgeReply = await tryKnowledgeReply(sessionId, session, message); if (knowledgeReply) { session.handoffSummaryUsed = true; @@ -283,15 +321,21 @@ router.post('/send-stream', async (req, res) => { res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); - const knowledgeReply = await tryKnowledgeReply(sessionId, session, message); - if (knowledgeReply) { - session.handoffSummaryUsed = true; - db.addMessage(sessionId, 'assistant', knowledgeReply.content, 'chat_bot', 'search_knowledge', knowledgeReply.meta).catch(e => console.warn('[DB] addMessage failed:', e.message)); - res.write(`data: ${JSON.stringify({ type: 'done', content: knowledgeReply.content })}\n\n`); + const fastGreetingReply = buildFastGreetingReply(message); + if (fastGreetingReply) { + db.addMessage(sessionId, 'assistant', fastGreetingReply, 'chat_bot').catch(e => console.warn('[DB] addMessage failed:', e.message)); + res.write(`data: ${JSON.stringify({ type: 'done', content: fastGreetingReply })}\n\n`); return res.end(); } try { + const knowledgeReply = await tryKnowledgeReply(sessionId, session, message); + if (knowledgeReply) { + session.handoffSummaryUsed = true; + db.addMessage(sessionId, 'assistant', knowledgeReply.content, 'chat_bot', 'search_knowledge', knowledgeReply.meta).catch(e => console.warn('[DB] addMessage failed:', e.message)); + res.write(`data: ${JSON.stringify({ type: 'done', content: knowledgeReply.content })}\n\n`); + return res.end(); + } // 首次对话时注入语音历史作为上下文 const extraMessages = !session.conversationId ? buildInitialContextMessages(session) : []; diff --git a/test2/server/routes/voice.js b/test2/server/routes/voice.js index c9557e6..36850ba 100644 --- a/test2/server/routes/voice.js +++ b/test2/server/routes/voice.js @@ -122,6 +122,8 @@ router.post('/direct/query', async (req, res) => { source: result?.source || null, original_query: result?.original_query || cleanQuery, rewritten_query: result?.rewritten_query || null, + selected_dataset_ids: result?.selected_dataset_ids || null, + selected_kb_routes: result?.selected_kb_routes || null, hit: typeof result?.hit === 'boolean' ? result.hit : null, reason: result?.reason || null, error_type: result?.errorType || null, diff --git a/test2/server/services/arkChatService.js b/test2/server/services/arkChatService.js index 4c3ea0d..71e13f5 100644 --- a/test2/server/services/arkChatService.js +++ b/test2/server/services/arkChatService.js @@ -9,17 +9,21 @@ class ArkChatService { return process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID; } - _isMockMode() { + isMockMode() { const ep = process.env.VOLC_ARK_ENDPOINT_ID; return !ep || ep === 'your_ark_endpoint_id'; } + _isMockMode() { + return this.isMockMode(); + } + /** * 获取方舟知识库配置(如果已配置) * @returns {object|null} 知识库 metadata 配置 */ - _getKnowledgeBaseConfig() { - const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS; + _getKnowledgeBaseConfig(kbIdsOverride = null) { + const kbIds = kbIdsOverride || process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS; if (!kbIds || kbIds === 'your_knowledge_base_dataset_id') return null; const datasetIds = kbIds.split(',').map(id => id.trim()).filter(Boolean); @@ -83,12 +87,14 @@ class ArkChatService { /** * 非流式调用方舟 LLM */ - async chat(messages, tools = []) { + async chat(messages, tools = [], options = {}) { if (this._isMockMode()) { console.warn('[ArkChat] EndPointId not configured, returning mock response'); return this._mockChat(messages); } + const { useKnowledgeBase = false, knowledgeBaseIds = null } = options || {}; + const body = { model: process.env.VOLC_ARK_ENDPOINT_ID, messages, @@ -96,8 +102,7 @@ class ArkChatService { }; if (tools.length > 0) body.tools = tools; - // 注入方舟私域知识库配置 - const kbConfig = this._getKnowledgeBaseConfig(); + const kbConfig = useKnowledgeBase ? this._getKnowledgeBaseConfig(knowledgeBaseIds) : null; if (kbConfig) { body.metadata = { knowledge_base: kbConfig }; console.log('[ArkChat] Knowledge base enabled:', kbConfig.dataset_ids); @@ -138,7 +143,7 @@ class ArkChatService { * @param {function} onToolCall - (toolCalls: Array) => void * @param {function} onDone - (fullContent: string) => void */ - async chatStream(messages, tools = [], { onChunk, onToolCall, onDone }) { + async chatStream(messages, tools = [], { onChunk, onToolCall, onDone, useKnowledgeBase = false, knowledgeBaseIds = null } = {}) { if (this._isMockMode()) { return this._mockChatStream(messages, { onChunk, onDone }); } @@ -150,8 +155,7 @@ class ArkChatService { }; if (tools.length > 0) body.tools = tools; - // 注入方舟私域知识库配置 - const kbConfig = this._getKnowledgeBaseConfig(); + const kbConfig = useKnowledgeBase ? this._getKnowledgeBaseConfig(knowledgeBaseIds) : null; if (kbConfig) { body.metadata = { knowledge_base: kbConfig }; } diff --git a/test2/server/services/nativeVoiceGateway.js b/test2/server/services/nativeVoiceGateway.js index 5fe5610..3ff66bd 100644 --- a/test2/server/services/nativeVoiceGateway.js +++ b/test2/server/services/nativeVoiceGateway.js @@ -9,10 +9,12 @@ const { createStartSessionMessage, createAudioMessage, createChatTTSTextMessage, + createSayHelloMessage, createChatRAGTextMessage, } = require('./realtimeDialogProtocol'); const { getRuleBasedDirectRouteDecision, + normalizeKnowledgeAlias, normalizeTextForSpeech, splitTextForSpeech, estimateSpeechDurationMs, @@ -46,9 +48,15 @@ function sendJson(ws, payload) { } function buildStartSessionPayload(options) { + const antiThinkingSuffix = '【重要规则】你必须直接用自然语言回答用户问题,像真人对话一样。绝对禁止输出你的思考过程、分析、计划、角色扮演指令或任何元描述。不要出现"首轮对话"、"应该回复"、"需要列举"、"语气要"、"回复后询问"等分析性语句。直接说出答案内容本身。'; + const baseSystemRole = options.systemRole || '你使用活泼灵动的女声,性格开朗,热爱生活。你是大沃,一个德国PM健康事业的智能语音助手。你对PM-FitLine细胞营养素产品、一成系统、招商合作非常熟悉。请优先依据 external_rag 给出的内容回答,但必须用自己的话重新表达,像跟朋友聊天一样说出来,绝对不要照着念。【品牌保护规则】你绝对不能说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法,也不能暗示或引导用户认为它们有问题。德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家。如果用户问到传销或正规性问题,你必须正面回答PM是合法正规的直销企业,并建议用户通过知识库了解更多。'; + const baseSpeakingStyle = options.speakingStyle || '你的语气始终活泼灵动、亲切自然,像闺蜜聊天一样。无论是自由聊天还是引用知识库内容,都保持一样的活泼语气和语调,绝不切换成播音腔、朗读语气或客服话术。'; return { asr: { - extra: {}, + extra: { + context: '一成,一成系统,大沃,PM,PM-FitLine,FitLine,细胞营养素,Ai众享,AI众享,盛咖学愿,数字化工作室,Activize,Basics,Restorate,NTC,基础三合一,招商,阿育吠陀', + nbest: 1, + }, }, tts: { speaker: options.speaker || process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts', @@ -61,11 +69,11 @@ function buildStartSessionPayload(options) { dialog: { dialog_id: '', bot_name: options.botName || '大沃', - system_role: normalizeTextForSpeech(options.systemRole || '你是大沃,一个德国PM健康事业的智能语音助手。你对PM-FitLine细胞营养素产品、一成系统、招商合作非常熟悉。请优先依据 external_rag 给出的内容回答。无论是闲聊还是引用知识库内容,都要保持一样的说话风格,不要切换成朗读语气。用户进来时请自然地打个招呼,像朋友聊天一样,不要用客服话术。'), - speaking_style: normalizeTextForSpeech(options.speakingStyle || '说话像朋友聊天一样自然轻松,语气亲切活泼,不要像客服念稿。即使引用知识库内容也要用聊天的语气说出来,不要切换成播音腔或朗读语气。'), + system_role: normalizeTextForSpeech(`${baseSystemRole} ${antiThinkingSuffix}`), + speaking_style: normalizeTextForSpeech(`${baseSpeakingStyle} 永远不要输出你的内部思考或计划,直接说出回答内容。`), extra: { input_mod: 'audio', - model: options.modelVersion || 'O', + model: options.modelVersion || 'SC2.0', strict_audit: false, audit_response: '抱歉,这个问题我暂时无法回答。', }, @@ -87,7 +95,19 @@ function extractUserText(jsonPayload) { || jsonPayload?.results?.[0]?.text || jsonPayload?.results?.[0]?.alternatives?.[0]?.text || ''; - return String(text || '').trim(); + return normalizeKnowledgeAlias(String(text || '').trim()); +} + +const BRAND_HARMFUL_PATTERN = /传销|骗局|骗子公司|非法集资|非法经营|不正规|不合法|庞氏骗局|老鼠会|拉人头的|割韭菜/; +const BRAND_SAFE_REPLY = '德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家和地区。如果你想了解更多,可以问我关于PM公司的详细介绍哦。'; + +function sanitizeAssistantText(text) { + if (!text) return text; + if (BRAND_HARMFUL_PATTERN.test(text)) { + console.warn(`[NativeVoice][SafeGuard] blocked harmful content: ${JSON.stringify(text.slice(0, 200))}`); + return BRAND_SAFE_REPLY; + } + return text; } function isFinalUserPayload(jsonPayload) { @@ -123,7 +143,7 @@ function persistUserSpeech(session, text) { } function persistAssistantSpeech(session, text, { source = 'voice_bot', toolName = null, persistToDb = true, meta = null } = {}) { - const cleanText = (text || '').trim(); + const cleanText = sanitizeAssistantText((text || '').trim()); if (!cleanText) return false; const now = Date.now(); if (session.lastPersistedAssistantText === cleanText && now - (session.lastPersistedAssistantAt || 0) < 5000) { @@ -250,21 +270,34 @@ async function sendSpeechText(session, speechText) { persistAssistantSpeech(session, greetingText, { source: 'voice_bot' }); clearTimeout(session.greetingTimer); clearTimeout(session.readyTimer); - session.greetingTimer = setTimeout(() => { - session.greetingTimer = null; - sendSpeechText(session, greetingText) - .then(() => { - session.readyTimer = setTimeout(() => { - session.readyTimer = null; - sendReady(session); - }, Math.max(1200, Math.min(estimateSpeechDurationMs(greetingText) + 300, 8000))); - }) - .catch((error) => { - session.hasSentGreeting = false; - sendReady(session); - console.warn('[NativeVoice] greeting speech failed:', error.message); - }); - }, 800); + session.greetingSentAt = Date.now(); + try { + session.upstream.send(createSayHelloMessage(session.sessionId, greetingText)); + console.log(`[NativeVoice] sendSayHello event=300 session=${session.sessionId}`); + } catch (error) { + session.hasSentGreeting = false; + console.warn('[NativeVoice] SayHello failed:', error.message); + } + sendReady(session); +} + +async function replayGreeting(session) { + const greetingText = String(session.greetingText || '').trim(); + if (!greetingText || !session.upstream || session.upstream.readyState !== WebSocket.OPEN) { + return; + } + if (session.greetingSentAt && Date.now() - session.greetingSentAt < 6000) { + console.log(`[NativeVoice] replayGreeting skipped (too soon) session=${session.sessionId}`); + return; + } + console.log(`[NativeVoice] replayGreeting session=${session.sessionId} text=${JSON.stringify(greetingText.slice(0, 80))}`); + session.greetingSentAt = Date.now(); + session.directSpeakUntil = Date.now() + estimateSpeechDurationMs(greetingText) + 800; + try { + session.upstream.send(createSayHelloMessage(session.sessionId, greetingText)); + } catch (error) { + console.warn('[NativeVoice] replayGreeting SayHello failed:', error.message); + } } async function sendExternalRag(session, items) { @@ -278,6 +311,31 @@ async function sendExternalRag(session, items) { session.upstream.send(createChatRAGTextMessage(session.sessionId, JSON.stringify(ragItems))); } +function clearUpstreamSuppression(session) { + clearTimeout(session.suppressReplyTimer); + session.suppressReplyTimer = null; + session.suppressUpstreamUntil = 0; + session.awaitingUpstreamReply = false; + session.pendingAssistantSource = null; + session.pendingAssistantToolName = null; + session.pendingAssistantMeta = null; + session.blockUpstreamAudio = false; + sendJson(session.client, { type: 'assistant_pending', active: false }); +} + +function suppressUpstreamReply(session, durationMs) { + clearTimeout(session.suppressReplyTimer); + session.awaitingUpstreamReply = true; + session.suppressUpstreamUntil = Date.now() + Math.max(1000, durationMs); + session.suppressReplyTimer = setTimeout(() => { + session.suppressReplyTimer = null; + if ((session.suppressUpstreamUntil || 0) > Date.now()) { + return; + } + clearUpstreamSuppression(session); + }, Math.max(300, session.suppressUpstreamUntil - Date.now())); +} + async function processReply(session, text) { const cleanText = (text || '').trim(); if (!cleanText) return; @@ -296,6 +354,8 @@ async function processReply(session, text) { sendJson(session.client, { type: 'assistant_pending', active: true }); const isKnowledgeCandidate = shouldForceKnowledgeRoute(cleanText); if (isKnowledgeCandidate) { + session.blockUpstreamAudio = true; + suppressUpstreamReply(session, 30000); sendJson(session.client, { type: 'tts_reset', reason: 'processing' }); } console.log(`[NativeVoice] processReply start session=${session.sessionId} text=${JSON.stringify(cleanText.slice(0, 120))} blocked=${session.blockUpstreamAudio} kbCandidate=${isKnowledgeCandidate}`); @@ -304,6 +364,7 @@ async function processReply(session, text) { if (delivery === 'upstream_chat') { if (isKnowledgeCandidate) { console.log(`[NativeVoice] processReply kb-nohit retrigger session=${session.sessionId}`); + session.discardNextAssistantResponse = true; await sendExternalRag(session, [{ title: '用户问题', content: cleanText }]); } else { session.blockUpstreamAudio = false; @@ -318,14 +379,20 @@ async function processReply(session, text) { if (delivery === 'external_rag') { if (!session.blockUpstreamAudio) { session.blockUpstreamAudio = true; - sendJson(session.client, { type: 'tts_reset', reason: 'knowledge_hit' }); } - session.awaitingUpstreamReply = true; - session.pendingAssistantSource = source; - session.pendingAssistantToolName = toolName; - session.pendingAssistantMeta = responseMeta; - console.log(`[NativeVoice] processReply handoff session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=external_rag items=${Array.isArray(ragItems) ? ragItems.length : 0}`); - await sendExternalRag(session, ragItems); + sendJson(session.client, { type: 'tts_reset', reason: 'knowledge_hit' }); + const kbText = (ragItems || []).map((item) => item?.content || '').filter(Boolean).join('\n').trim(); + console.log(`[NativeVoice] processReply handoff session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=external_rag→local_tts items=${Array.isArray(ragItems) ? ragItems.length : 0} textLen=${kbText.length}`); + if (kbText) { + session.directSpeakUntil = Date.now() + estimateSpeechDurationMs(kbText) + 800; + suppressUpstreamReply(session, estimateSpeechDurationMs(kbText) + 1800); + persistAssistantSpeech(session, kbText, { source, toolName, meta: responseMeta }); + await sendSpeechText(session, kbText); + } else { + console.log(`[NativeVoice] processReply external_rag empty content, fallback to upstream session=${session.sessionId}`); + session.blockUpstreamAudio = false; + clearUpstreamSuppression(session); + } return; } if (!speechText) { @@ -334,12 +401,11 @@ async function processReply(session, text) { session.chatTTSUntil = 0; return; } - console.log(`[NativeVoice] processReply resolved session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=local_rag source=${source} tool=${toolName || 'chat'} speechLen=${speechText.length}`); - session.awaitingUpstreamReply = true; - session.pendingAssistantSource = source; - session.pendingAssistantToolName = toolName; - session.pendingAssistantMeta = responseMeta; - await sendExternalRag(session, [{ title: '回复内容', content: speechText }]); + console.log(`[NativeVoice] processReply resolved session=${session.sessionId} route=${routeDecision?.route || 'unknown'} delivery=local_tts source=${source} tool=${toolName || 'chat'} speechLen=${speechText.length}`); + session.directSpeakUntil = Date.now() + estimateSpeechDurationMs(speechText) + 800; + suppressUpstreamReply(session, estimateSpeechDurationMs(speechText) + 1800); + persistAssistantSpeech(session, speechText, { source, toolName, meta: responseMeta }); + await sendSpeechText(session, speechText); } catch (error) { console.error('[NativeVoice] processReply failed:', error.message); sendJson(session.client, { type: 'error', error: error.message }); @@ -386,7 +452,8 @@ function handleUpstreamMessage(session, data) { } if (message.type === MsgType.AUDIO_ONLY_SERVER) { - if (session.blockUpstreamAudio) { + const isSuppressingUpstreamAudio = (session.suppressUpstreamUntil || 0) > Date.now() && session.currentTtsType === 'default'; + if (session.blockUpstreamAudio || isSuppressingUpstreamAudio) { if (!session._audioBlockLogOnce) { session._audioBlockLogOnce = true; console.log(`[NativeVoice] audio blocked (blockUpstream) session=${session.sessionId} ttsType=${session.currentTtsType}`); @@ -419,6 +486,11 @@ function handleUpstreamMessage(session, data) { return; } + if (message.event === 300) { + console.log(`[NativeVoice] SayHello response session=${session.sessionId}`); + return; + } + if (message.event === 350) { session.currentTtsType = payload?.tts_type || ''; if (payload?.tts_type === 'chat_tts_text' && session.pendingGreetingAck) { @@ -428,7 +500,10 @@ function handleUpstreamMessage(session, data) { } if (session.blockUpstreamAudio && payload?.tts_type && payload.tts_type !== 'default') { session.blockUpstreamAudio = false; - console.log(`[NativeVoice] unblock audio on ttsType=${payload.tts_type} session=${session.sessionId}`); + session.suppressUpstreamUntil = 0; + clearTimeout(session.suppressReplyTimer); + session.suppressReplyTimer = null; + console.log(`[NativeVoice] unblock audio+suppress on ttsType=${payload.tts_type} session=${session.sessionId}`); } console.log(`[NativeVoice] upstream tts_event session=${session.sessionId} ttsType=${payload?.tts_type || ''}`); sendJson(session.client, { type: 'tts_event', payload }); @@ -436,13 +511,21 @@ function handleUpstreamMessage(session, data) { } const isLocalChatTTSTextActive = !!session.isSendingChatTTSText && (session.chatTTSUntil || 0) > Date.now(); + const isSuppressingUpstreamReply = (session.suppressUpstreamUntil || 0) > Date.now(); if (message.event === 351) { - if (isLocalChatTTSTextActive || session.blockUpstreamAudio) { + if (isLocalChatTTSTextActive || session.blockUpstreamAudio || isSuppressingUpstreamReply) { session.assistantStreamBuffer = ''; session.assistantStreamReplyId = ''; return; } + if (session.discardNextAssistantResponse) { + session.discardNextAssistantResponse = false; + session.assistantStreamBuffer = ''; + session.assistantStreamReplyId = ''; + console.log(`[NativeVoice] discarded stale assistant response (kb-nohit retrigger) session=${session.sessionId}`); + return; + } const pendingAssistantSource = session.pendingAssistantSource || 'voice_bot'; const pendingAssistantToolName = session.pendingAssistantToolName || null; const pendingAssistantMeta = session.pendingAssistantMeta || null; @@ -472,7 +555,7 @@ function handleUpstreamMessage(session, data) { } if (message.event === 550) { - if (isLocalChatTTSTextActive || session.blockUpstreamAudio) { + if (isLocalChatTTSTextActive || session.blockUpstreamAudio || isSuppressingUpstreamReply || session.discardNextAssistantResponse) { return; } if (session.awaitingUpstreamReply) { @@ -487,7 +570,7 @@ function handleUpstreamMessage(session, data) { } if (message.event === 559) { - if (isLocalChatTTSTextActive) { + if (isLocalChatTTSTextActive || isSuppressingUpstreamReply) { session.assistantStreamBuffer = ''; session.assistantStreamReplyId = ''; return; @@ -498,6 +581,13 @@ function handleUpstreamMessage(session, data) { console.log(`[NativeVoice] blocked response ended (559), keeping block session=${session.sessionId}`); return; } + if (session.discardNextAssistantResponse) { + session.discardNextAssistantResponse = false; + session.assistantStreamBuffer = ''; + session.assistantStreamReplyId = ''; + console.log(`[NativeVoice] discarded stale stream end (559, kb-nohit retrigger) session=${session.sessionId}`); + return; + } session.awaitingUpstreamReply = false; session.blockUpstreamAudio = false; sendJson(session.client, { type: 'assistant_pending', active: false }); @@ -517,19 +607,23 @@ function handleUpstreamMessage(session, data) { if (text) { console.log(`[NativeVoice] upstream partial session=${session.sessionId} text=${JSON.stringify(text.slice(0, 120))}`); session.latestUserText = text; - // 用户开口说话时立即打断 AI 播放 - if (session.directSpeakUntil && Date.now() < session.directSpeakUntil) { - console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId}`); + // 用户开口说话时立即打断所有 AI 播放(包括 S2S 默认 TTS) + const now = Date.now(); + const isDirectSpeaking = session.directSpeakUntil && now < session.directSpeakUntil; + const isChatTTSSpeaking = session.isSendingChatTTSText && (session.chatTTSUntil || 0) > now; + if (isDirectSpeaking || isChatTTSSpeaking) { + console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId} direct=${isDirectSpeaking} chatTTS=${isChatTTSSpeaking}`); session.directSpeakUntil = 0; session.isSendingChatTTSText = false; session.chatTTSUntil = 0; clearTimeout(session.chatTTSTimer); - sendJson(session.client, { type: 'tts_reset', reason: 'user_bargein' }); - } else if (session.isSendingChatTTSText && (session.chatTTSUntil || 0) > Date.now()) { - console.log(`[NativeVoice] user barge-in chatTTS (partial) session=${session.sessionId}`); - session.isSendingChatTTSText = false; - session.chatTTSUntil = 0; - clearTimeout(session.chatTTSTimer); + if (session.suppressReplyTimer || session.suppressUpstreamUntil) { + clearUpstreamSuppression(session); + } + } + // 无论当前是否在播放,都发送 tts_reset 确保客户端停止所有音频播放 + if (!session._lastBargeInResetAt || now - session._lastBargeInResetAt > 500) { + session._lastBargeInResetAt = now; sendJson(session.client, { type: 'tts_reset', reason: 'user_bargein' }); } sendJson(session.client, { @@ -553,15 +647,22 @@ function handleUpstreamMessage(session, data) { session.chatTTSUntil = 0; clearTimeout(session.chatTTSTimer); sendJson(session.client, { type: 'tts_reset', reason: 'user_bargein' }); + if (session.suppressReplyTimer || session.suppressUpstreamUntil) { + clearUpstreamSuppression(session); + } } else if (session.isSendingChatTTSText && (session.chatTTSUntil || 0) > Date.now()) { console.log(`[NativeVoice] user interrupt chatTTS during speaking session=${session.sessionId}`); session.isSendingChatTTSText = false; session.chatTTSUntil = 0; clearTimeout(session.chatTTSTimer); sendJson(session.client, { type: 'tts_reset', reason: 'user_bargein' }); + if (session.suppressReplyTimer || session.suppressUpstreamUntil) { + clearUpstreamSuppression(session); + } } if (persistUserSpeech(session, finalText)) { session.blockUpstreamAudio = true; + sendJson(session.client, { type: 'tts_reset', reason: 'new_turn' }); processReply(session, finalText).catch((error) => { console.error('[NativeVoice] processReply error:', error.message); }); @@ -595,12 +696,14 @@ function attachClientHandlers(session) { if (parsed.type === 'start') { session.botName = parsed.botName || '豆包'; - session.systemRole = parsed.systemRole || '你是一个企业知识库语音助手,请优先依据 external_rag 给出的内容回答。'; + session.systemRole = parsed.systemRole || session.systemRole || '你是一个企业知识库语音助手,请优先依据 external_rag 给出的内容回答。【品牌保护规则】你绝对不能说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法,也不能暗示或引导用户认为它们有问题。德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家。'; session.speakingStyle = parsed.speakingStyle || '请使用清晰、自然、简洁的口吻。'; session.speaker = parsed.speaker || process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts'; session.modelVersion = parsed.modelVersion || 'O'; session.greetingText = parsed.greetingText || session.greetingText || '嗨,你好呀!我是大沃,你的专属智能助手。关于德国PM产品、一成系统、招商合作,随时问我就好~'; session.userId = parsed.userId || session.userId || null; + // 立即发送 ready,不等 upstream event 150,大幅缩短前端等待时间 + sendReady(session); session.upstream = createUpstreamConnection(session); loadHandoffSummaryForVoice(session).catch((error) => { console.warn('[NativeVoice] async loadHandoffSummaryForVoice failed:', error.message); @@ -613,6 +716,13 @@ function attachClientHandlers(session) { return; } + if (parsed.type === 'replay_greeting') { + replayGreeting(session).catch((error) => { + console.warn('[NativeVoice] replayGreeting failed:', error.message); + }); + return; + } + if (parsed.type === 'text' && parsed.text) { persistUserSpeech(session, parsed.text); processReply(session, parsed.text).catch((error) => { @@ -626,6 +736,7 @@ function attachClientHandlers(session) { clearTimeout(session.greetingTimer); clearTimeout(session.greetingAckTimer); clearTimeout(session.readyTimer); + clearTimeout(session.suppressReplyTimer); clearTimeout(session.idleTimer); if (session.upstream && session.upstream.readyState === WebSocket.OPEN) { session.upstream.close(); @@ -696,7 +807,7 @@ function createSession(client, sessionId) { assistantStreamReplyId: '', currentTtsType: '', botName: '大沃', - systemRole: '你是大沃,一个德国PM健康事业的智能语音助手。你对PM-FitLine细胞营养素产品、一成系统、招商合作非常熟悉。请优先依据 external_rag 给出的内容回答。用户进来时请自然地打个招呼,像朋友聊天一样,不要用客服话术。', + systemRole: '你是大沃,一个德国PM健康事业的智能语音助手。你对PM-FitLine细胞营养素产品、一成系统、招商合作非常熟悉。请优先依据 external_rag 给出的内容回答。用户进来时请自然地打个招呼,像朋友聊天一样,不要用客服话术。【品牌保护规则】你绝对不能说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规、不合法,也不能暗示或引导用户认为它们有问题。德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家。', speakingStyle: '说话像朋友聊天一样自然轻松,语气亲切活泼,不要像客服念稿。', speaker: process.env.VOLC_S2S_SPEAKER_ID || 'zh_female_vv_jupiter_bigtts', modelVersion: 'O', @@ -714,8 +825,12 @@ function createSession(client, sessionId) { pendingAssistantSource: null, pendingAssistantToolName: null, pendingAssistantMeta: null, + suppressReplyTimer: null, + suppressUpstreamUntil: 0, idleTimer: null, lastActivityAt: Date.now(), + _lastBargeInResetAt: 0, + _audioBlockLogOnce: false, }; sessions.set(sessionId, session); attachClientHandlers(session); diff --git a/test2/server/services/realtimeDialogProtocol.js b/test2/server/services/realtimeDialogProtocol.js index e257ce5..6c5e50d 100644 --- a/test2/server/services/realtimeDialogProtocol.js +++ b/test2/server/services/realtimeDialogProtocol.js @@ -180,6 +180,18 @@ function createChatTTSTextMessage(sessionId, payload) { }); } +function createSayHelloMessage(sessionId, content) { + return marshal({ + type: MsgType.FULL_CLIENT, + typeFlag: MSG_TYPE_FLAG_WITH_EVENT, + event: 300, + sessionId, + payload: Buffer.from(JSON.stringify({ + content: content || '', + }), 'utf8'), + }); +} + function createChatRAGTextMessage(sessionId, externalRag) { return marshal({ type: MsgType.FULL_CLIENT, @@ -201,5 +213,6 @@ module.exports = { createStartSessionMessage, createAudioMessage, createChatTTSTextMessage, + createSayHelloMessage, createChatRAGTextMessage, }; diff --git a/test2/server/services/realtimeDialogRouting.js b/test2/server/services/realtimeDialogRouting.js index ffb7963..0a50665 100644 --- a/test2/server/services/realtimeDialogRouting.js +++ b/test2/server/services/realtimeDialogRouting.js @@ -62,6 +62,34 @@ function estimateSpeechDurationMs(text) { return Math.max(4000, Math.min(60000, length * 180)); } +async function polishForSpeech(rawText, userQuestion) { + const POLISH_TIMEOUT_MS = 3000; + try { + const messages = [ + { + role: 'system', + content: '你是一个语音播报润色助手。请将下面的知识库回答改写为自然、亲切的口语风格,像朋友聊天一样。要求:1) 保留所有关键信息和数据,不得编造;2) 去掉"根据知识库信息"等机械前缀;3) 适合语音朗读,简洁流畅;4) 控制在120字以内;5) 只输出改写后的文本,不要加引号或解释。', + }, + { + role: 'user', + content: `用户问题:${userQuestion}\n\n原始回答:${rawText}`, + }, + ]; + const result = await Promise.race([ + arkChatService.chat(messages, [], { useKnowledgeBase: false }), + new Promise((_, reject) => setTimeout(() => reject(new Error('polish timeout')), POLISH_TIMEOUT_MS)), + ]); + const polished = (result?.content || '').trim(); + if (polished && polished.length >= 10) { + console.log(`[RealtimeRouting] polishForSpeech ok len=${polished.length} original=${rawText.length}`); + return polished; + } + } catch (err) { + console.warn('[RealtimeRouting] polishForSpeech failed:', err.message); + } + return null; +} + function buildDirectRouteMessages(session, context, userText) { const messages = []; const systemPrompt = [ @@ -110,12 +138,29 @@ function buildDirectChatMessages(session, context, userText) { return messages; } +function normalizeKnowledgeAlias(text) { + return String(text || '') + .replace(/X{2}系统/gi, '一成系统') + .replace(/一城系统|逸城系统|一程系统|易成系统|一诚系统|亦成系统|艺成系统|溢成系统|义成系统|毅成系统|怡成系统|以成系统|已成系统|亿成系统|忆成系统|益成系统/g, '一成系统') + .replace(/(? 0) { + let speechText = normalizeTextForSpeech(replyText); session.handoffSummaryUsed = true; + if (toolName === 'search_knowledge' && speechText) { + const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, ''); + return { + delivery: 'external_rag', + speechText: '', + ragItems: [{ title: '知识库结果', content: cleanedText || speechText }], + source, + toolName, + routeDecision, + responseMeta, + }; + } return { delivery: 'external_rag', speechText: '', @@ -292,6 +353,19 @@ async function resolveReply(sessionId, session, text) { if (toolName === 'search_knowledge' && !toolResult?.hit) { session.handoffSummaryUsed = true; + // 敏感问题(传销/正规性)知识库未命中时,不交给S2S自由发挥,直接返回安全回复 + if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/.test(originalText)) { + const safeReply = '德国PM是一家1993年成立于德国的合法直销公司,获得邓白氏AAA+认证,业务覆盖100多个国家和地区。它不是传销,是正规的直销企业哦。如果你想了解更多,可以问我关于PM公司或产品的详细介绍。'; + return { + delivery: 'external_rag', + speechText: '', + ragItems: [{ title: '品牌保护', content: safeReply }], + source: 'voice_tool', + toolName: 'search_knowledge', + routeDecision, + responseMeta: { ...responseMeta, hit: true, reason: 'brand_protection' }, + }; + } return { delivery: 'upstream_chat', speechText: '', @@ -313,6 +387,7 @@ async function resolveReply(sessionId, session, text) { module.exports = { getRuleBasedDirectRouteDecision, + normalizeKnowledgeAlias, normalizeTextForSpeech, splitTextForSpeech, estimateSpeechDurationMs, diff --git a/test2/server/services/toolExecutor.js b/test2/server/services/toolExecutor.js index 4ade8af..6e47d90 100644 --- a/test2/server/services/toolExecutor.js +++ b/test2/server/services/toolExecutor.js @@ -6,6 +6,181 @@ class ToolExecutor { return /(一成系统|PM-FitLine|PM细胞营养素|NTC营养保送系统|Activize Oxyplus|小红产品|Basics|大白产品|Restorate|小白产品|儿童倍适|火炉原理|阿育吠陀)/i.test(String(query || '')); } + static getKnowledgeBaseRoutingRules() { + const raw = process.env.VOLC_ARK_KNOWLEDGE_BASE_ROUTING || process.env.VOLC_ARK_KNOWLEDGE_BASE_MAP; + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + const entries = Array.isArray(parsed) + ? parsed + : Object.entries(parsed).map(([name, config]) => ({ name, ...(config || {}) })); + return entries + .map((item) => ({ + name: String(item.name || '').trim(), + dataset_ids: Array.isArray(item.dataset_ids) + ? item.dataset_ids.map((id) => String(id || '').trim()).filter(Boolean) + : String(item.dataset_ids || item.datasetIds || '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + keywords: Array.isArray(item.keywords) + ? item.keywords.map((keyword) => String(keyword || '').trim()).filter(Boolean) + : String(item.keywords || '') + .split(',') + .map((keyword) => keyword.trim()) + .filter(Boolean), + })) + .filter((item) => item.name && item.dataset_ids.length > 0 && item.keywords.length > 0); + } catch (error) { + console.warn('[ToolExecutor] parse knowledge base routing failed:', error.message); + return []; + } + } + + static selectKnowledgeBaseTargets(query, context = []) { + const defaultDatasetIds = String(process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS || '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + const rules = this.getKnowledgeBaseRoutingRules(); + if (!rules.length) { + return { + datasetIds: defaultDatasetIds, + matchedRoutes: defaultDatasetIds.length ? ['default'] : [], + }; + } + + const recentContextText = (Array.isArray(context) ? context : []) + .slice(-6) + .map((item) => String(item?.content || '').trim()) + .filter(Boolean) + .join('\n'); + const haystack = `${String(query || '').trim()}\n${recentContextText}`.toLowerCase(); + + const priorityRouteNames = []; + const hasSystemIntent = /(一成系统|ai众享|数字化工作室|盛咖学愿|赋能工具|四大ai生态|三大平台)/i.test(haystack); + const hasCompanyIntent = /(pm公司|德国pm(?!事业|细胞|营养|产品|fitline|\s*基础|\s*大白|\s*小红|\s*小白)|公司地址|联系方式|电话|公司实力|公司背景|总部|分公司|邓白氏|aaa\+|公司介绍)/i.test(haystack); + const hasProductIntent = /(细胞营养素|基础套装|基础三合一|三合一|大白产品|小红产品|小白产品|activize|basics|restorate|fitline|儿童倍适|ntc营养保送|火炉原理|阿育吠陀|产品.*介绍|介绍.*产品|产品有哪些|产品列表)/i.test(haystack); + if (hasSystemIntent) { + priorityRouteNames.push('system'); + } + if (hasCompanyIntent && !hasSystemIntent && !hasProductIntent) { + priorityRouteNames.push('company'); + } + if (priorityRouteNames.length > 0) { + const priorityRules = rules.filter((rule) => priorityRouteNames.includes(rule.name)); + const priorityDatasetIds = [...new Set(priorityRules.flatMap((rule) => rule.dataset_ids).filter(Boolean))]; + if (priorityDatasetIds.length > 0) { + return { + datasetIds: priorityDatasetIds, + matchedRoutes: [...new Set(priorityRules.map((rule) => rule.name))], + }; + } + } + + const matchedDatasetIds = []; + const matchedRoutes = []; + + for (const rule of rules) { + if (rule.keywords.some((keyword) => haystack.includes(keyword.toLowerCase()))) { + matchedRoutes.push(rule.name); + matchedDatasetIds.push(...rule.dataset_ids); + } + } + + const datasetIds = [...new Set((matchedDatasetIds.length ? matchedDatasetIds : defaultDatasetIds).filter(Boolean))]; + return { + datasetIds, + matchedRoutes: matchedRoutes.length ? [...new Set(matchedRoutes)] : (datasetIds.length ? ['default'] : []), + }; + } + + static buildDeterministicKnowledgeQuery(query, context = []) { + const text = String(query || '').trim(); + const recentContextText = (Array.isArray(context) ? context : []) + .slice(-6) + .map((item) => String(item?.content || '').trim()) + .filter(Boolean) + .join('\n'); + const haystack = `${text}\n${recentContextText}`; + + // 第一层:当前查询文本中有明确产品/系统/主题关键词 → 直接改写(不依赖上下文) + if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白'; + if (/(一成系统|Ai众享|数字化工作室|盛咖学愿)/i.test(text)) { + if (/(邀约|话术)/i.test(haystack)) return '一成系统 邀约话术'; + if (/文化/i.test(haystack)) return '一成系统 文化解析'; + if (/(赋能团队|团队发展|AI赋能|ai赋能)/i.test(haystack)) return '一成系统用AI赋能团队发展'; + return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态'; + } + if (/(PM公司|德国PM|公司地址|联系方式|电话|公司实力|公司背景|总部|分公司)/i.test(text)) { + if (/(产品|细胞营养素|基础套装|基础三合一|小红|大白|小白|activize|basics|restorate|fitline|儿童倍适)/i.test(text)) { + return '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适'; + } + if (/(地址|电话|联系方式)/i.test(text)) return '德国PM 日本 美国 加拿大 香港 地址 电话'; + if (/(实力|背景)/i.test(text)) return '德国PM 公司实力介绍 邓白氏 99分 AAA+'; + return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍'; + } + if (/儿童倍适/i.test(text)) return '儿童倍适'; + if (/(小红产品|小红|Activize Oxyplus|Activize)/i.test(text)) return 'Fitline小红产品提升能量原理'; + if (/(大白产品|大白|倍适|Basics)/i.test(text)) return '德国PM细胞营养素 大白 Basics'; + if (/(小白产品|小白|维适多|Restorate)/i.test(text)) return '德国PM细胞营养素 小白'; + if (/(NTC营养保送系统|Nutrient Transport Concept)/i.test(text)) return 'NTC营养保送系统'; + if (/火炉原理/i.test(text)) return '火炉原理'; + if (/(阿育吠陀|Ayurveda)/i.test(text)) return '阿育吠陀医学原理'; + if (/(PM-FitLine|PM细胞营养素)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白'; + if (/(我们公司.*产品|公司.*产品|产品.*推荐|推荐.*产品|产品有哪些|产品介绍|产品列表)/i.test(text)) return '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适'; + if (/(新人起步三关|起步三关)/i.test(text)) return '培训新人起步三关'; + if (/(精品会议|会议组织)/i.test(text)) return '培训打造精品会议具体如下'; + if (/成长上总裁/i.test(text)) return '培训成长上总裁'; + if (/(招商|代理|加盟|合作|事业机会|招商稿|代理政策)/i.test(text)) return '招商与代理'; + if (/(一成AI|AI落地|ai落地|转观念|落地对比)/i.test(text)) return '2026一成Ai落地对比与转观念'; + if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销'; + if (/(好转反应|整应反应|排毒反应|副作用|不良反应|皮肤发痒)/i.test(text)) return 'PM产品整应反应好转反应解析'; + if (/(促销活动|促销|优惠|打折|活动分数|5\+1)/i.test(text)) return '促销活动 5+1活动分数'; + if (/暖炉原理/i.test(text)) return '火炉原理'; + if (/(CC套装|CC胶囊)/i.test(text)) return 'CC套装 CC胶囊'; + if (/(IB5|口腔免疫喷雾)/i.test(text)) return 'IB5口腔免疫喷雾'; + if (/(Q10|辅酵素|氧修护)/i.test(text)) return 'Q10辅酵素氧修护'; + if (/Women\+/i.test(text)) return 'Women+'; + if (/乐活/i.test(text)) return '乐活'; + if (/(乳清蛋白|蛋白粉)/i.test(text)) return '乳清蛋白粉'; + if (/(乳酪煲|乳酪饮品|乳酪)/i.test(text)) return '乳酪煲 乳酪饮品'; + if (/(基础二合一|二合一)/i.test(text)) return '基础二合一'; + if (/倍力健/i.test(text)) return '倍力健'; + if (/(关节套装|关节舒缓)/i.test(text)) return '关节套装 关节舒缓膏'; + if (/(男士乳霜|男士护肤)/i.test(text)) return '全效男士乳霜'; + if (/(去角质|面膜)/i.test(text)) return '去角质面膜'; + if (/发宝/i.test(text)) return '发宝'; + if (/叶黄素/i.test(text)) return '叶黄素'; + if (/(奶昔)/i.test(text)) return '奶昔'; + if (/(健康饮品)/i.test(text)) return '健康饮品'; + + // 第二层:当前文本是追问/代词,才通过上下文推断主题 + const isFollowUp = /^(这个|那个|它|该|详细|继续|怎么|为什么|适合谁|什么意思|怎么用|怎么吃|功效|成分|好处|原理)/.test(text); + if (isFollowUp) { + if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(recentContextText)) return '德国PM细胞营养素 基础套装 大白 小红 小白'; + if (/(一成系统|Ai众享|数字化工作室|盛咖学愿)/i.test(recentContextText)) return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态'; + if (/(小红产品|小红|Activize)/i.test(recentContextText)) return 'Fitline小红产品提升能量原理'; + if (/(大白产品|大白|Basics)/i.test(recentContextText)) return '德国PM细胞营养素 大白 Basics'; + if (/(小白产品|小白|Restorate)/i.test(recentContextText)) return '德国PM细胞营养素 小白'; + if (/儿童倍适/i.test(recentContextText)) return '儿童倍适'; + if (/火炉原理/i.test(recentContextText)) return '火炉原理'; + if (/(阿育吠陀|Ayurveda)/i.test(recentContextText)) return '阿育吠陀医学原理'; + if (/(NTC营养保送系统)/i.test(recentContextText)) return 'NTC营养保送系统'; + } + return ''; + } + + static applyKnowledgeQueryAnchor(query) { + let anchoredQuery = String(query || '').trim(); + if (/一成系统/.test(anchoredQuery) && !/(德国PM|PM事业|赋能工具|Ai众享|数字化工作室|盛咖学愿)/i.test(anchoredQuery)) { + anchoredQuery = anchoredQuery.replace(/一成系统/g, '一成系统 德国PM事业赋能工具'); + } + return anchoredQuery.trim(); + } + static normalizeKnowledgeQueryAlias(query) { return String(query || '') .replace(/^[啊哦嗯呢呀哎诶额,。!?、\s]+/g, '') @@ -19,11 +194,15 @@ class ToolExecutor { .replace(/Activize Oxyplus|Activize/gi, 'Activize Oxyplus') .replace(/Restorate/gi, 'Restorate') .replace(/Basics/gi, 'Basics') - .replace(/基础三合一|基础套装?|三合一基础套|大白小红小白/g, 'Basics') - .replace(/小红产品|小红/g, '小红产品 Activize Oxyplus') - .replace(/大白产品|大白/g, '大白产品 Basics') - .replace(/小白产品|小白/g, '小白产品 Restorate') + .replace(/基础三合一|三合一基础套|大白小红小白|基础套装?/g, 'PM细胞营养素 基础套装') .replace(/儿童倍适|儿童产品/g, '儿童倍适') + .replace(/小红产品/g, '小红产品 Activize Oxyplus') + .replace(/大白产品/g, '大白产品 Basics') + .replace(/小白产品/g, '小白产品 Restorate') + .replace(/(? 0) { + console.log(`[ToolExecutor] searchKnowledge selected dataset_ids=${kbTarget.datasetIds.join(',')} routes=${kbTarget.matchedRoutes.join(',')}`); + } const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS; if (kbIds && kbIds !== 'your_knowledge_base_dataset_id') { + if (arkChatService.isMockMode()) { + const latencyMs = Date.now() - startTime; + console.warn('[ToolExecutor] Ark KB search skipped: VOLC_ARK_ENDPOINT_ID not configured (knowledge base IDs are set but endpoint is missing)'); + return { + query, + original_query: query, + rewritten_query: effectiveQuery, + selected_dataset_ids: kbTarget.datasetIds, + selected_kb_routes: kbTarget.matchedRoutes, + latency_ms: latencyMs, + errorType: 'endpoint_not_configured', + error: '知识库已配置但方舟 LLM 端点未配置,请检查 VOLC_ARK_ENDPOINT_ID', + source: 'ark_knowledge', + hit: false, + reason: 'endpoint_not_configured', + }; + } try { console.log('[ToolExecutor] Trying Ark Knowledge Search...'); - const result = await this.searchArkKnowledge(rewrittenQuery || query, context, responseMode); + let result = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query); + if (!result?.hit) { + console.log('[ToolExecutor] Ark KB no_hit, retrying without context...'); + const retryResult = await this.searchArkKnowledge(effectiveQuery, [], responseMode, kbTarget.datasetIds, query); + if (retryResult?.hit || retryResult?.reason !== result?.reason) { + result = retryResult; + } + } + if (!result?.hit && responseMode === 'answer') { + console.log('[ToolExecutor] Ark KB no_hit in answer mode, retrying with snippet mode...'); + const snippetResult = await this.searchArkKnowledge(effectiveQuery, [], 'snippet', kbTarget.datasetIds, query); + if (snippetResult?.hit) { + result = snippetResult; + } + } const latencyMs = Date.now() - startTime; console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms`); return { ...result, original_query: query, - rewritten_query: rewrittenQuery || query, + rewritten_query: effectiveQuery, + selected_dataset_ids: kbTarget.datasetIds, + selected_kb_routes: kbTarget.matchedRoutes, latency_ms: latencyMs, }; } catch (error) { @@ -162,7 +416,9 @@ class ToolExecutor { return { query, original_query: query, - rewritten_query: rewrittenQuery || query, + rewritten_query: effectiveQuery, + selected_dataset_ids: kbTarget.datasetIds, + selected_kb_routes: kbTarget.matchedRoutes, latency_ms: latencyMs, errorType: error.code === 'ECONNABORTED' || /timeout/i.test(error.message) ? 'timeout' : 'request_failed', error: `知识库查询失败: ${error.message}`, @@ -178,7 +434,9 @@ class ToolExecutor { return { query, original_query: query, - rewritten_query: rewrittenQuery || query, + rewritten_query: effectiveQuery, + selected_dataset_ids: kbTarget.datasetIds, + selected_kb_routes: kbTarget.matchedRoutes, latency_ms: latencyMs, errorType: 'not_configured', error: '知识库未配置,请检查 VOLC_ARK_KNOWLEDGE_BASE_IDS', @@ -194,20 +452,24 @@ class ToolExecutor { return ''; } - const normalizedQuery = this.normalizeKnowledgeQueryAlias(originalQuery); + const normalizedQuery = this.applyKnowledgeQueryAnchor(this.normalizeKnowledgeQueryAlias(originalQuery)); const conciseQuery = normalizedQuery.replace(/[,。!?、,.!?\s]+/g, ''); const recentContext = (Array.isArray(context) ? context : []) .filter((item) => item && (item.role === 'user' || item.role === 'assistant') && String(item.content || '').trim()) .slice(-6) .map((item) => `${item.role === 'user' ? '用户' : '助手'}:${String(item.content || '').trim()}`) .join('\n'); + const deterministicQuery = this.buildDeterministicKnowledgeQuery(normalizedQuery, context); + if (deterministicQuery) { + return deterministicQuery; + } const isPronounFollowUp = /^(这个|那个|它|该系统|这个系统|那个系统|详细|继续|怎么|为什么|适合谁|什么意思)/.test(normalizedQuery); if (this.hasCanonicalKnowledgeTerm(normalizedQuery) && conciseQuery.length <= 36 && !isPronounFollowUp) { return normalizedQuery; } - if (!process.env.VOLC_ARK_ENDPOINT_ID || process.env.VOLC_ARK_ENDPOINT_ID === 'your_ark_endpoint_id') { + if (arkChatService.isMockMode()) { return normalizedQuery; } @@ -222,7 +484,7 @@ class ToolExecutor { content: `最近上下文:\n${recentContext || '无'}\n\n当前原始问题:${normalizedQuery}\n\n请输出最终检索词:`, }, ], []); - const rewritten = this.normalizeKnowledgeQueryAlias(String(result.content || '').replace(/^["'“”]+|["'“”]+$/g, '').trim()); + const rewritten = this.applyKnowledgeQueryAnchor(this.normalizeKnowledgeQueryAlias(String(result.content || '').replace(/^["'“”]+|["'“”]+$/g, '').trim())); return rewritten || normalizedQuery; } catch (error) { console.warn('[ToolExecutor] rewriteKnowledgeQuery failed:', error.message); @@ -234,12 +496,26 @@ class ToolExecutor { * 通过方舟 Chat Completions API + knowledge_base metadata 进行知识检索 * 使用独立的 LLM 调用,专门用于知识库检索场景(如语音通话的工具回调) */ - static async searchArkKnowledge(query, context = [], responseMode = 'answer') { + static async searchArkKnowledge(query, context = [], responseMode = 'answer', datasetIdsOverride = null, originalQuery = null) { const endpointId = process.env.VOLC_ARK_ENDPOINT_ID; const authKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID; const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS; - const datasetIds = kbIds.split(',').map(id => id.trim()).filter(Boolean); + if (!endpointId || endpointId === 'your_ark_endpoint_id') { + console.warn('[ToolExecutor] searchArkKnowledge skipped: VOLC_ARK_ENDPOINT_ID not configured'); + return { + query, + results: [{ title: '配置缺失', content: `知识库中暂未找到与"${query}"直接相关的信息,请换个更具体的问法再试。` }], + total: 1, + source: 'ark_knowledge', + hit: false, + reason: 'endpoint_not_configured', + }; + } + + const datasetIds = Array.isArray(datasetIdsOverride) && datasetIdsOverride.length > 0 + ? datasetIdsOverride.map((id) => String(id || '').trim()).filter(Boolean) + : kbIds.split(',').map(id => id.trim()).filter(Boolean); const topK = parseInt(process.env.VOLC_ARK_KNOWLEDGE_TOP_K) || 3; const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.5; @@ -249,17 +525,30 @@ class ToolExecutor { console.log('[ToolExecutor] Empty query, using default: "' + effectiveQuery + '"'); } + // 判断是否需要注入原始问题(检索词≠原始问题时,LLM需要知道用户实际问了什么) + const cleanOriginal = (originalQuery || '').trim(); + const hasRewrite = cleanOriginal && cleanOriginal !== effectiveQuery; + if (hasRewrite) { + console.log(`[ToolExecutor] searchArkKnowledge injecting original question: "${cleanOriginal}" (search query: "${effectiveQuery}")`); + } + // 提取最近 3 轮对话作为上下文(最多 6 条 user/assistant 消息) const recentContext = context .filter(m => m.role === 'user' || m.role === 'assistant') .slice(responseMode === 'snippet' ? -4 : -6); + const baseSnippetPrompt = '你是知识库片段提取助手。请基于知识库提取与用户问题最相关的2到4条简洁知识片段,供语音系统继续组织回复。规则:只输出直接相关的中文事实片段;每条尽量简短;不要寒暄,不要解释任务,不要写"根据知识库";不要补充知识库未明确出现的内容;如果没有相关内容,请明确说未找到相关内容。'; + const baseAnswerPrompt = '你是企业知识库问答助手,回答将直接用于语音播报。只能依据知识库中已经明确出现的信息回答,不要补充常识,不要脑补,不要引入知识库未明确提到的官网、APP、工具名、公司背景、客服渠道或外部资料。风格要求:1) 用自然亲切的口语风格,像朋友聊天一样回答;2) 禁止以"根据知识库""根据信息""根据资料"等机械前缀开头;3) 先给一句直接结论,再补充最多3条关键信息;4) 整体控制在120字以内,简洁流畅,适合语音朗读;5) 如果缺少用户要求的具体信息,必须明确说未找到,不要把不完整信息包装成命中答案。【品牌保护】绝对禁止说德国PM、PM-FitLine、一成系统是传销、骗局、骗子、非法、不正规或不合法。德国PM是1993年成立于德国的合法直销企业,获邓白氏AAA+认证。如果用户问到正规性或传销问题,必须正面回答PM是合法正规的直销企业。'; + + let systemContent = responseMode === 'snippet' ? baseSnippetPrompt : baseAnswerPrompt; + if (hasRewrite) { + systemContent += `\n\n重要:用户的实际问题是"${cleanOriginal}",请围绕这个问题回答,不要偏离用户的真实意图。下方的检索词仅用于匹配知识库文档,不代表用户的真正提问。`; + } + const messages = [ { role: 'system', - content: responseMode === 'snippet' - ? '你是知识库片段提取助手。请基于知识库提取与用户问题最相关的2到4条简洁知识片段,供语音系统继续组织回复。规则:只输出直接相关的中文事实片段;每条尽量简短;不要寒暄,不要解释你的任务,不要写“根据知识库”;如果没有相关内容,请明确说未找到相关内容。' - : '你是一个知识库检索助手。请根据知识库中的内容回答用户问题。如果知识库中没有相关内容,请如实说明。回答时请引用知识库来源。', + content: systemContent, }, ...recentContext, { @@ -299,7 +588,8 @@ class ToolExecutor { const choice = response.data.choices?.[0]; const content = choice?.message?.content || '未找到相关信息'; - const classified = this.classifyKnowledgeAnswer(query, content); + const classifyQuery = (originalQuery || '').trim() || query; + const classified = this.classifyKnowledgeAnswer(classifyQuery, content); return { query,