From 89c659272a1efb9bab07e4e9df4de961e40b502c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:43:24 +0000 Subject: [PATCH] =?UTF-8?q?Phase=204-7:=20=E4=BA=BA=E7=B1=BB=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E4=BE=A7=E5=85=A8=E9=9D=A2=E4=BF=AE=E5=A4=8D=20=C2=B7?= =?UTF-8?q?=20CD=E7=AE=A1=E7=BA=BF=E4=BA=BA=E7=B1=BB=E9=AA=8C=E8=AF=81=20+?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E5=AF=BC=E8=88=AA=E4=BF=AE=E5=A4=8D=20+?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E5=99=A8API=E4=BB=A3=E7=90=86=20+=20?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E9=A2=84=E7=95=99=20+=20We?= =?UTF-8?q?b=E9=83=A8=E7=BD=B2=E5=AE=88=E5=8D=AB=20+=20=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/dd3872e9-4f8f-4c5f-ad59-1fc55166d46d --- .github/workflows/deploy-to-server.yml | 67 +++++ data/users.json | 39 +++ deploy/nginx/hololake.conf | 139 +++++++++ persona-studio/backend/routes/auth.js | 109 ++++++- persona-studio/backend/routes/proxy.js | 284 ++++++++++++++++++ persona-studio/backend/server.js | 6 + persona-studio/frontend/chat.js | 122 +++++++- persona-studio/frontend/index.html | 138 +++++++-- signal-log/diag-humanside-20260325.json | 81 +++++ signal-log/index.json | 22 +- signal-log/syslog-humanside-fix-20260325.json | 95 ++++++ skyeye/guards/web-deploy-guard.json | 98 ++++++ skyeye/infra-manifest.json | 30 ++ 13 files changed, 1192 insertions(+), 38 deletions(-) create mode 100644 data/users.json create mode 100644 deploy/nginx/hololake.conf create mode 100644 persona-studio/backend/routes/proxy.js create mode 100644 signal-log/diag-humanside-20260325.json create mode 100644 signal-log/syslog-humanside-fix-20260325.json create mode 100644 skyeye/guards/web-deploy-guard.json diff --git a/.github/workflows/deploy-to-server.yml b/.github/workflows/deploy-to-server.yml index 81c92290..d094fd71 100644 --- a/.github/workflows/deploy-to-server.yml +++ b/.github/workflows/deploy-to-server.yml @@ -538,6 +538,19 @@ jobs: check_module '光湖集成壳' 'app' check_module 'Persona Studio' 'persona-studio' check_module '后端服务' 'backend' + check_module '用户中心' 'm05-user-center' + check_module '对话界面' 'm07-dialogue-ui' + check_module '云服务' 'm10-cloud' + check_module '模块管理' 'm11-module' + check_module '云盘' 'm15-cloud-drive' + check_module '登录' 'm01-login' + check_module '健康检查' 'm18-health-check' + check_module '冷启动' 'coldstart' + check_module '仪表盘' 'dashboard' + check_module '门户' 'portal' + check_module '通知' 'notification' + check_module '帮助中心' 'help-center' + check_module '前端' 'frontend' echo '' printf \"\$RESULTS\" @@ -559,6 +572,59 @@ jobs: " echo "✅ 冒烟测试完成" + - name: 👤 人类使用侧验证 + continue-on-error: true + run: | + echo "👤 人类使用侧验证(核心!系统测跑通 ≠ 人类测跑通)..." + echo "等待 10 秒让服务启动..." + sleep 10 + + HUMAN_CHECK_PASSED=0 + HUMAN_CHECK_FAILED=0 + + # 验证主站可访问 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 \ + "http://${{ secrets.DEPLOY_HOST }}" 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then + echo "✅ 主站 HTTP 响应: $HTTP_CODE" + HUMAN_CHECK_PASSED=$((HUMAN_CHECK_PASSED+1)) + else + echo "❌ 主站 HTTP 响应: $HTTP_CODE(人类无法访问)" + HUMAN_CHECK_FAILED=$((HUMAN_CHECK_FAILED+1)) + fi + + # 验证 Persona Studio 可访问 + PS_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 \ + "http://${{ secrets.DEPLOY_HOST }}/persona-studio/" 2>/dev/null || echo "000") + if [ "$PS_CODE" = "200" ] || [ "$PS_CODE" = "301" ] || [ "$PS_CODE" = "302" ]; then + echo "✅ Persona Studio HTTP 响应: $PS_CODE" + HUMAN_CHECK_PASSED=$((HUMAN_CHECK_PASSED+1)) + else + echo "⚠️ Persona Studio HTTP 响应: $PS_CODE" + HUMAN_CHECK_FAILED=$((HUMAN_CHECK_FAILED+1)) + fi + + # 验证后端 API 健康 + API_CODE=$(ssh -i ~/.ssh/deploy_key \ + "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:3002/api/ps/health 2>/dev/null || echo '000'" \ + 2>/dev/null || echo "000") + if [ "$API_CODE" = "200" ]; then + echo "✅ Persona Studio API 健康: $API_CODE" + HUMAN_CHECK_PASSED=$((HUMAN_CHECK_PASSED+1)) + else + echo "⚠️ Persona Studio API 响应: $API_CODE" + HUMAN_CHECK_FAILED=$((HUMAN_CHECK_FAILED+1)) + fi + + echo "" + echo "━━━ 人类使用侧验证结果:${HUMAN_CHECK_PASSED} 通过 / ${HUMAN_CHECK_FAILED} 异常 ━━━" + + echo "### 👤 人类使用侧验证" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- 通过: ${HUMAN_CHECK_PASSED}" >> $GITHUB_STEP_SUMMARY + echo "- 异常: ${HUMAN_CHECK_FAILED}" >> $GITHUB_STEP_SUMMARY + - name: 📝 部署汇总 run: | echo "### 🚀 全站部署完成" >> $GITHUB_STEP_SUMMARY @@ -567,6 +633,7 @@ jobs: echo "- 目标: \`${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}\`" >> $GITHUB_STEP_SUMMARY echo "- 提交: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "- 时间: $(TZ=Asia/Shanghai date '+%Y-%m-%d %H:%M CST')" >> $GITHUB_STEP_SUMMARY + echo "- 人类使用侧验证: 已执行" >> $GITHUB_STEP_SUMMARY # ═══════════════════════════════════════════════════════════════ # Job 3: Notion 部署通知 diff --git a/data/users.json b/data/users.json new file mode 100644 index 00000000..6143c301 --- /dev/null +++ b/data/users.json @@ -0,0 +1,39 @@ +{ + "version": "1.0", + "description": "guanghulab 用户数据库 · 微信登录 + 开发者编号绑定", + "created_at": "2026-03-25T02:16:00Z", + "created_by": "ZY-HUMANSIDE-FIX-2026-0325-002", + "users": {}, + "dev_id_map": {}, + "roles": { + "admin": { + "permissions": ["chat", "dev", "log", "status", "admin", "system"], + "description": "系统管理员 · 全部功能 + 系统管理" + }, + "developer": { + "permissions": ["chat", "dev", "log", "status"], + "description": "开发者 · 聊天 + 开发 + 日志 + 状态查看" + }, + "visitor": { + "permissions": ["chat", "status"], + "description": "访客 · 聊天 + 状态查看(只读)" + } + }, + "auth_methods": { + "wechat": { + "enabled": false, + "status": "pending_appid", + "note": "等待冰朔提供微信开放平台 AppID/AppSecret" + }, + "dev_id": { + "enabled": true, + "status": "active", + "note": "开发者编号直接登录 · 服务器端验证" + }, + "guest": { + "enabled": true, + "status": "active", + "note": "访客模式 · 无需登录" + } + } +} diff --git a/deploy/nginx/hololake.conf b/deploy/nginx/hololake.conf new file mode 100644 index 00000000..37f9fc40 --- /dev/null +++ b/deploy/nginx/hololake.conf @@ -0,0 +1,139 @@ +# /etc/nginx/conf.d/hololake.conf +# ═══════════════════════════════════════════════ +# guanghulab.com 统一入口 · Nginx 配置模板 +# 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001 +# 📜 Copyright: 国作登字-2026-A-00037559 +# ═══════════════════════════════════════════════ +# +# 使用方法: +# 1. 复制到服务器: scp deploy/nginx/hololake.conf root@8.155.62.235:/etc/nginx/conf.d/ +# 2. 如果使用 Let's Encrypt: certbot --nginx -d guanghulab.com -d www.guanghulab.com +# 3. 验证: nginx -t && systemctl reload nginx +# +# 端口映射: +# 3721 → API 代理(AI 聊天,SSE 流式) +# 3002 → Persona Studio 后端 +# 3000 → Express 主后端 +# 3001 → HLI 中间层 +# 8080 → WebSocket(状态看板实时推送) + +# HTTP → HTTPS 重定向(启用 HTTPS 后取消注释) +# server { +# listen 80; +# server_name guanghulab.com www.guanghulab.com; +# return 301 https://$host$request_uri; +# } + +server { + listen 80; + # listen 443 ssl; # 启用 HTTPS 后取消注释 + server_name guanghulab.com www.guanghulab.com; + + # Let's Encrypt 证书(certbot 自动填充,启用 HTTPS 后取消注释) + # ssl_certificate /etc/letsencrypt/live/guanghulab.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/guanghulab.com/privkey.pem; + + root /var/www/guanghulab; + index index.html; + + # ── 前端静态文件(主站首页 = docs/index.html,已复制到根目录)── + location / { + try_files $uri $uri/ /index.html; + } + + # ── AI 聊天 API 代理 → api-proxy.js 端口 3721(SSE 流式支持)── + location /api/chat { + proxy_pass http://127.0.0.1:3721/api/chat; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + proxy_read_timeout 120s; + proxy_send_timeout 60s; + } + + # ── 模型列表 API ── + location /api/models { + proxy_pass http://127.0.0.1:3721/api/models; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # ── Persona Studio API → 后端端口 3002 ── + location /api/ps/ { + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-API-Key" always; + if ($request_method = OPTIONS) { + return 204; + } + proxy_pass http://127.0.0.1:3002/api/ps/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # ── 后端健康检查 ── + location /api/health { + proxy_pass http://127.0.0.1:3721/api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # ── 其他 API → Express 后端端口 3000 ── + location /api/ { + proxy_pass http://127.0.0.1:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # ── Persona Studio WebSocket → 预览进度推送 ── + location /ws/preview { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + } + + # ── WebSocket → 看板实时推送端口 8080 ── + location /ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + } + + # ── Persona Studio 前端静态文件 ── + location /persona-studio/ { + proxy_pass http://127.0.0.1:3002/persona-studio/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # ── 错误页面 ── + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; +} diff --git a/persona-studio/backend/routes/auth.js b/persona-studio/backend/routes/auth.js index 9868aa6d..76080001 100644 --- a/persona-studio/backend/routes/auth.js +++ b/persona-studio/backend/routes/auth.js @@ -1,6 +1,11 @@ /** * persona-studio · 登录校验路由 * POST /api/ps/auth/login { dev_id: "EXP-000" } + * GET /api/ps/auth/session { token } + * POST /api/ps/auth/wechat/callback { code } — 微信登录回调(待配置) + * POST /api/ps/auth/bind { openid, dev_id } — 绑定开发者编号(待配置) + * + * 📜 Copyright: 国作登字-2026-A-00037559 */ const express = require('express'); const router = express.Router(); @@ -10,6 +15,10 @@ const crypto = require('crypto'); const HUMAN_REGISTRY_PATH = path.join(__dirname, '..', '..', 'brain', 'human-registry.json'); const REGISTRY_PATH = path.join(__dirname, '..', '..', 'brain', 'registry.json'); +const USERS_DB_PATH = path.join(__dirname, '..', '..', '..', 'data', 'users.json'); + +// In-memory session store (server restart clears sessions) +const sessions = new Map(); function loadHumanRegistry() { try { @@ -27,6 +36,14 @@ function loadRegistry() { } } +function loadUsersDB() { + try { + return JSON.parse(fs.readFileSync(USERS_DB_PATH, 'utf-8')); + } catch { + return { users: {}, dev_id_map: {} }; + } +} + function findDeveloper(devId) { const humanReg = loadHumanRegistry(); if (humanReg.developers && Array.isArray(humanReg.developers)) { @@ -63,12 +80,21 @@ router.post('/login', (req, res) => { } const token = crypto.randomBytes(32).toString('hex'); + sessions.set(token, { + dev_id: 'GUEST', + name: guestConfig.name || '访客体验者', + role: 'visitor', + created_at: new Date().toISOString() + }); + return res.json({ error: false, dev_id: 'GUEST', name: guestConfig.name || '访客体验者', status: 'guest', - token + role: 'visitor', + token, + auth_method: 'guest' }); } @@ -98,16 +124,91 @@ router.post('/login', (req, res) => { }); } - // 生成简单 session token + // 生成 session token const token = crypto.randomBytes(32).toString('hex'); + sessions.set(token, { + dev_id, + name: entry.name, + role: entry.role || 'developer', + created_at: new Date().toISOString() + }); res.json({ error: false, dev_id, name: entry.name, status: entry.status, - role: entry.role, - token + role: entry.role || 'developer', + token, + auth_method: 'dev_id' + }); +}); + +// GET /api/ps/auth/session — 验证 session token +router.get('/session', (req, res) => { + const token = req.headers['x-session-token'] || req.query.token; + + if (!token) { + return res.status(401).json({ + error: true, + code: 'NO_TOKEN', + message: '未提供 session token' + }); + } + + const session = sessions.get(token); + if (!session) { + return res.status(401).json({ + error: true, + code: 'INVALID_TOKEN', + message: 'Session 已过期或无效' + }); + } + + res.json({ + error: false, + dev_id: session.dev_id, + name: session.name, + role: session.role + }); +}); + +// POST /api/ps/auth/wechat/callback — 微信登录回调(预留接口) +router.post('/wechat/callback', (req, res) => { + // 微信登录需要冰朔提供 AppID/AppSecret 后才能启用 + // 当前返回提示信息 + res.status(501).json({ + error: true, + code: 'WECHAT_NOT_CONFIGURED', + message: '微信登录尚未配置。需要冰朔提供微信开放平台 AppID/AppSecret。当前请使用开发者编号登录。' + }); +}); + +// POST /api/ps/auth/bind — 绑定微信 openid 和开发者编号(预留接口) +router.post('/bind', (req, res) => { + res.status(501).json({ + error: true, + code: 'BIND_NOT_CONFIGURED', + message: '绑定功能尚未启用。需要先完成微信登录配置。' + }); +}); + +// GET /api/ps/auth/methods — 获取可用登录方式 +router.get('/methods', (_req, res) => { + const usersDB = loadUsersDB(); + const methods = usersDB.auth_methods || { + dev_id: { enabled: true }, + guest: { enabled: true }, + wechat: { enabled: false } + }; + + res.json({ + error: false, + methods: { + dev_id: { enabled: methods.dev_id ? methods.dev_id.enabled : true }, + guest: { enabled: methods.guest ? methods.guest.enabled : true }, + wechat: { enabled: methods.wechat ? methods.wechat.enabled : false } + } }); }); diff --git a/persona-studio/backend/routes/proxy.js b/persona-studio/backend/routes/proxy.js new file mode 100644 index 00000000..5e0a5eb6 --- /dev/null +++ b/persona-studio/backend/routes/proxy.js @@ -0,0 +1,284 @@ +/** + * persona-studio · 服务器端 AI 模型代理路由 + * + * 目的:消除用户手动输入 API 密钥的需求 + * 所有模型调用由服务器端代理完成,API 密钥仅存在于服务器 .env + * + * 路由前缀:/api/ps/proxy + * + * 📜 Copyright: 国作登字-2026-A-00037559 + * 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001 + */ +const express = require('express'); +const router = express.Router(); +const https = require('https'); +const http = require('http'); + +// 服务器端配置的模型 API +const MODEL_API_KEY = process.env.MODEL_API_KEY || process.env.YUNWU_API_KEY || ''; +const MODEL_API_BASE = process.env.MODEL_API_BASE || 'https://api.yunwu.ai/v1'; +const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'gemini-2.0-flash'; + +/** + * 检查服务器端代理是否可用 + * GET /api/ps/proxy/status + */ +router.get('/status', (_req, res) => { + const available = !!MODEL_API_KEY; + res.json({ + error: false, + proxy_available: available, + model: available ? DEFAULT_MODEL : null, + api_base: available ? MODEL_API_BASE : null, + message: available + ? '服务器端代理可用 · 无需 API 密钥' + : '服务器端代理未配置 · 请使用自有 API 密钥' + }); +}); + +/** + * 获取服务器端可用模型列表 + * GET /api/ps/proxy/models + */ +router.get('/models', async (_req, res) => { + if (!MODEL_API_KEY) { + return res.status(503).json({ + error: true, + code: 'PROXY_NOT_CONFIGURED', + message: '服务器端 API 密钥未配置' + }); + } + + try { + const data = await callModelAPI('GET', '/models', null); + const models = (data.data || []).map(m => m.id).filter(Boolean).sort(); + res.json({ error: false, models, api_base: MODEL_API_BASE }); + } catch (err) { + res.status(502).json({ + error: true, + code: 'MODEL_API_ERROR', + message: '无法获取模型列表: ' + (err.message || 'Unknown error') + }); + } +}); + +/** + * 服务器端代理聊天 + * POST /api/ps/proxy/chat + * Body: { messages: [...], model?: string, max_tokens?: number } + */ +router.post('/chat', async (req, res) => { + if (!MODEL_API_KEY) { + return res.status(503).json({ + error: true, + code: 'PROXY_NOT_CONFIGURED', + message: '服务器端 API 密钥未配置,请使用自有 API 密钥' + }); + } + + const { messages, model, max_tokens } = req.body || {}; + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ + error: true, + code: 'INVALID_MESSAGES', + message: '消息列表不能为空' + }); + } + + // Validate message format + for (const msg of messages) { + if (!msg.role || !msg.content) { + return res.status(400).json({ + error: true, + code: 'INVALID_MESSAGE_FORMAT', + message: '每条消息必须包含 role 和 content 字段' + }); + } + if (!['system', 'user', 'assistant'].includes(msg.role)) { + return res.status(400).json({ + error: true, + code: 'INVALID_ROLE', + message: '消息 role 必须是 system/user/assistant' + }); + } + } + + try { + const data = await callModelAPI('POST', '/chat/completions', { + model: model || DEFAULT_MODEL, + messages, + max_tokens: Math.min(max_tokens || 4000, 8000), + temperature: 0.3 + }); + + if (data.choices && data.choices[0] && data.choices[0].message) { + res.json({ + error: false, + reply: data.choices[0].message.content, + model: data.model || model || DEFAULT_MODEL, + usage: data.usage || null + }); + } else if (data.error) { + res.status(502).json({ + error: true, + code: 'MODEL_ERROR', + message: data.error.message || '模型返回错误' + }); + } else { + res.status(502).json({ + error: true, + code: 'UNEXPECTED_RESPONSE', + message: '模型返回了意外格式的响应' + }); + } + } catch (err) { + res.status(502).json({ + error: true, + code: 'PROXY_ERROR', + message: '服务器代理请求失败: ' + (err.message || 'Unknown error') + }); + } +}); + +/** + * 服务器端代理流式聊天(SSE) + * POST /api/ps/proxy/chat/stream + * Body: { messages: [...], model?: string, max_tokens?: number } + */ +router.post('/chat/stream', async (req, res) => { + if (!MODEL_API_KEY) { + return res.status(503).json({ + error: true, + code: 'PROXY_NOT_CONFIGURED', + message: '服务器端 API 密钥未配置' + }); + } + + const { messages, model, max_tokens } = req.body || {}; + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ + error: true, + code: 'INVALID_MESSAGES', + message: '消息列表不能为空' + }); + } + + // Set SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + try { + const base = MODEL_API_BASE.replace(/\/+$/, ''); + const chatUrl = new URL(base + '/chat/completions'); + const isHttps = chatUrl.protocol === 'https:'; + const mod = isHttps ? https : http; + + const body = JSON.stringify({ + model: model || DEFAULT_MODEL, + messages, + max_tokens: Math.min(max_tokens || 4000, 8000), + temperature: 0.3, + stream: true + }); + + const options = { + hostname: chatUrl.hostname, + port: chatUrl.port || (isHttps ? 443 : 80), + path: chatUrl.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + MODEL_API_KEY, + 'Content-Length': Buffer.byteLength(body) + }, + timeout: 120000 + }; + + const proxyReq = mod.request(options, (proxyRes) => { + proxyRes.on('data', (chunk) => { + res.write(chunk); + }); + proxyRes.on('end', () => { + res.end(); + }); + }); + + proxyReq.on('error', (err) => { + res.write('data: ' + JSON.stringify({ error: true, message: err.message }) + '\n\n'); + res.end(); + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(); + res.write('data: ' + JSON.stringify({ error: true, message: 'Request timeout' }) + '\n\n'); + res.end(); + }); + + req.on('close', () => { + proxyReq.destroy(); + }); + + proxyReq.write(body); + proxyReq.end(); + } catch (err) { + res.write('data: ' + JSON.stringify({ error: true, message: err.message }) + '\n\n'); + res.end(); + } +}); + +/** + * Helper: Call model API (non-streaming) + */ +function callModelAPI(method, endpoint, body) { + return new Promise((resolve, reject) => { + const base = MODEL_API_BASE.replace(/\/+$/, ''); + const url = new URL(base + endpoint); + const isHttps = url.protocol === 'https:'; + const mod = isHttps ? https : http; + + const bodyStr = body ? JSON.stringify(body) : null; + + const options = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method, + headers: { + 'Authorization': 'Bearer ' + MODEL_API_KEY, + 'Content-Type': 'application/json' + }, + timeout: 60000 + }; + + if (bodyStr) { + options.headers['Content-Length'] = Buffer.byteLength(bodyStr); + } + + const req = mod.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (_e) { + reject(new Error('Failed to parse API response')); + } + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + if (bodyStr) req.write(bodyStr); + req.end(); + }); +} + +module.exports = router; diff --git a/persona-studio/backend/server.js b/persona-studio/backend/server.js index ae561b8f..0414ffdb 100644 --- a/persona-studio/backend/server.js +++ b/persona-studio/backend/server.js @@ -10,6 +10,7 @@ const buildRoutes = require('./routes/build'); const notifyRoutes = require('./routes/notify'); const apikeyRoutes = require('./routes/apikey'); const previewRoutes = require('./routes/preview'); +const proxyRoutes = require('./routes/proxy'); const app = express(); app.use(cors({ @@ -34,6 +35,7 @@ app.use('/api/ps/chat', chatRoutes); app.use('/api/ps/build', buildRoutes); app.use('/api/ps/notify', notifyRoutes); app.use('/api/ps/apikey', apikeyRoutes); +app.use('/api/ps/proxy', proxyRoutes); // ── 健康检查 ── // ── 健康检查 ──app.use('/api/ps/preview', previewRoutes); @@ -73,6 +75,10 @@ app.get('/', (_req, res) => { '/api/ps/notify/send', '/api/ps/apikey/detect-models', '/api/ps/apikey/chat', + '/api/ps/proxy/status', + '/api/ps/proxy/models', + '/api/ps/proxy/chat', + '/api/ps/proxy/chat/stream', '/api/ps/preview/:devId/:project', '/api/ps/health' ] diff --git a/persona-studio/frontend/chat.js b/persona-studio/frontend/chat.js index b4a38bf7..541884ea 100644 --- a/persona-studio/frontend/chat.js +++ b/persona-studio/frontend/chat.js @@ -10,6 +10,7 @@ const LOGIN_MODE = sessionStorage.getItem('login_mode'); // 'developer' or 'gues const USER_API_BASE = sessionStorage.getItem('user_api_base'); const USER_API_KEY = sessionStorage.getItem('user_api_key'); const SELECTED_MODEL = sessionStorage.getItem('selected_model'); +const USE_SERVER_PROXY = sessionStorage.getItem('use_server_proxy') === 'true'; const API_BASE = (function () { if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { @@ -389,8 +390,13 @@ async function sendMessage() { sendBtn.disabled = true; try { - // All modes now use API Key for real AI — ZhuYuan is awake - await streamApiKeyReply(fullText); + if (USE_SERVER_PROXY) { + // 服务器代理模式:无需 API 密钥 + await streamServerProxyReply(fullText); + } else { + // All modes now use API Key for real AI — ZhuYuan is awake + await streamApiKeyReply(fullText); + } } catch (_err) { appendMessage('system', '消息发送失败,请检查网络连接后再试'); } @@ -404,6 +410,118 @@ async function sendMessage() { input.focus(); } +/* ---- 服务器代理模式对话(SSE 流式) ---- */ +async function streamServerProxyReply(text) { + var apiMessages = [ZHUYUAN_SYSTEM_PROMPT]; + var contextPrompt = buildContextPrompt(); + if (contextPrompt) { + apiMessages.push(contextPrompt); + } + + var recentHistory = conversationHistory.slice(-20).map(function (msg) { + return { role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content }; + }); + apiMessages = apiMessages.concat(recentHistory); + + var streamEl = appendStreamMessage(); + + try { + var res = await fetch(API_BASE + '/api/ps/proxy/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: apiMessages, + model: SELECTED_MODEL || undefined, + max_tokens: 2000 + }) + }); + + if (!res.ok) { + var errText = '请求失败 (HTTP ' + res.status + ')'; + try { + var errData = await res.json(); + errText = errData.message || errText; + } catch (_e) { /* ignore parse error */ } + streamEl.textContent = '⚠️ ' + errText; + return; + } + + // SSE 流式读取 + var full = ''; + var reader = res.body.getReader(); + var decoder = new TextDecoder(); + var buf = ''; + + while (true) { + var chunk = await reader.read(); + if (chunk.done) break; + buf += decoder.decode(chunk.value, { stream: true }); + var lines = buf.split('\n'); + buf = lines.pop(); + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (!line.startsWith('data: ')) continue; + var d = line.slice(6); + if (d === '[DONE]') continue; + try { + var parsed = JSON.parse(d); + var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta; + var content = delta && delta.content; + if (content) { + full += content; + streamEl.textContent = full + '▋'; + var chatBody = document.getElementById('chatBody'); + chatBody.scrollTop = chatBody.scrollHeight; + } + } catch (_parseErr) { /* ignore malformed SSE line */ } + } + } + + streamEl.textContent = full || '(未收到有效回复)'; + if (full) { + conversationHistory.push({ role: 'assistant', content: full }); + + var readyKeywords = ['方案已确认', '我要开发', '开始帮你做', '方案确认', '可以开始', '开始开发']; + if (readyKeywords.some(function (kw) { return full.includes(kw); })) { + buildReady = true; + document.getElementById('buildBtn').style.display = 'inline-flex'; + } + } + } catch (err) { + // 流式失败,降级到非流式代理 + try { + var proxyRes = await fetch(API_BASE + '/api/ps/proxy/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: apiMessages, + model: SELECTED_MODEL || undefined, + max_tokens: 2000 + }) + }); + + if (proxyRes.ok) { + var data = await proxyRes.json(); + if (data.reply) { + streamEl.textContent = data.reply; + conversationHistory.push({ role: 'assistant', content: data.reply }); + } else { + streamEl.textContent = '(未收到有效回复)'; + } + } else { + var proxyErrText = '请求失败 (HTTP ' + proxyRes.status + ')'; + try { + var proxyErrData = await proxyRes.json(); + proxyErrText = proxyErrData.message || proxyErrText; + } catch (_e) { /* ignore */ } + streamEl.textContent = '⚠️ ' + proxyErrText; + } + } catch (proxyErr) { + streamEl.textContent = '⚠️ ' + (proxyErr.message || '请求失败,请检查网络连接'); + } + } +} + /* ---- API Key 对话(浏览器直连 SSE 流式) ---- */ async function streamApiKeyReply(text) { // Build messages with ZhuYuan system prompt + context diff --git a/persona-studio/frontend/index.html b/persona-studio/frontend/index.html index d9299f13..f95df727 100644 --- a/persona-studio/frontend/index.html +++ b/persona-studio/frontend/index.html @@ -77,38 +77,59 @@