diff --git a/persona-studio/backend/package-lock.json b/persona-studio/backend/package-lock.json
index d79cf1ab..a5b0551b 100644
--- a/persona-studio/backend/package-lock.json
+++ b/persona-studio/backend/package-lock.json
@@ -11,7 +11,8 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
- "nodemailer": "^7.0.13"
+ "nodemailer": "^7.0.13",
+ "ws": "^8.18.0"
}
},
"node_modules/accepts": {
@@ -872,6 +873,27 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/persona-studio/backend/package.json b/persona-studio/backend/package.json
index 52d2c6d1..299beb99 100644
--- a/persona-studio/backend/package.json
+++ b/persona-studio/backend/package.json
@@ -11,6 +11,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
- "nodemailer": "^7.0.13"
+ "nodemailer": "^7.0.13",
+ "ws": "^8.18.0"
}
}
diff --git a/persona-studio/backend/routes/build.js b/persona-studio/backend/routes/build.js
index d519b60d..12b2c575 100644
--- a/persona-studio/backend/routes/build.js
+++ b/persona-studio/backend/routes/build.js
@@ -8,9 +8,12 @@ const memoryManager = require('../brain/memory-manager');
const codeGenerator = require('../brain/code-generator');
const emailSender = require('../utils/email-sender');
+// 邮箱后端正则二次校验
+const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+
// POST /api/ps/build/start
router.post('/start', async (req, res) => {
- const { dev_id, email, conversation } = req.body || {};
+ const { dev_id, email, contact, conversation } = req.body || {};
if (!dev_id || !/^EXP-\d{3,}$/.test(dev_id)) {
return res.status(400).json({
@@ -28,6 +31,15 @@ router.post('/start', async (req, res) => {
});
}
+ // 后端二次邮箱校验
+ if (!EMAIL_RE.test(email)) {
+ return res.status(400).json({
+ error: true,
+ code: 'INVALID_EMAIL',
+ message: '邮箱格式不正确'
+ });
+ }
+
// 先立即响应,后台异步处理
res.json({
error: false,
@@ -37,7 +49,16 @@ router.post('/start', async (req, res) => {
// 异步执行代码生成 + 邮件通知
(async () => {
+ const broadcast = req.app.locals.broadcastToClient || function () {};
+
try {
+ broadcast(dev_id, {
+ type: 'progress',
+ message: '🔧 正在创建项目骨架...',
+ status: 'building',
+ status_text: '构建中'
+ });
+
const result = await codeGenerator.generate({
dev_id,
conversation: conversation || [],
@@ -46,20 +67,42 @@ router.post('/start', async (req, res) => {
// 记录项目
memoryManager.addProject(dev_id, {
name: result.projectName || 'untitled',
+ email: email,
+ contact: contact || null,
status: 'completed',
created_at: new Date().toISOString(),
files: result.files || []
});
+ // 自动提取知识
+ memoryManager.autoExtractKnowledge(dev_id, conversation, result.projectName);
+
+ // 通知预览就绪
+ broadcast(dev_id, {
+ type: 'preview_ready',
+ project: result.projectName,
+ message: '✅ 预览已就绪'
+ });
+
+ broadcast(dev_id, {
+ type: 'complete',
+ message: '🎉 全部完成!邮件正在发送'
+ });
+
// 发邮件
await emailSender.sendCompletion({
to: email,
dev_id,
projectName: result.projectName,
- summary: result.summary
+ summary: result.summary,
+ files: result.files
});
} catch (err) {
console.error('Build pipeline error:', err.message);
+ broadcast(dev_id, {
+ type: 'error',
+ message: '构建过程出错: ' + err.message
+ });
}
})();
});
diff --git a/persona-studio/backend/routes/preview.js b/persona-studio/backend/routes/preview.js
new file mode 100644
index 00000000..1b310dbc
--- /dev/null
+++ b/persona-studio/backend/routes/preview.js
@@ -0,0 +1,72 @@
+/**
+ * persona-studio · 预览 API
+ * GET /api/ps/preview/:devId/:project 提供 iframe 实时预览
+ */
+const express = require('express');
+const router = express.Router();
+const path = require('path');
+const fs = require('fs');
+
+const WORKSPACE_DIR = path.join(__dirname, '..', '..', 'workspace');
+
+// GET /api/ps/preview/:devId/:project
+router.get('/:devId/:project', (req, res) => {
+ const { devId, project } = req.params;
+
+ if (!devId || !project) {
+ return res.status(400).json({
+ error: true,
+ code: 'MISSING_PARAMS',
+ message: '缺少必要参数'
+ });
+ }
+
+ // 安全校验:防止路径遍历
+ const safeDevId = path.basename(devId);
+ const safeProject = path.basename(project);
+ const previewDir = path.join(WORKSPACE_DIR, safeDevId, safeProject, 'preview');
+ const projectDir = path.join(WORKSPACE_DIR, safeDevId, safeProject);
+
+ // 优先从 preview/ 子目录查找
+ let targetDir = previewDir;
+ if (!fs.existsSync(previewDir)) {
+ targetDir = projectDir;
+ }
+
+ const indexPath = path.join(targetDir, 'index.html');
+ if (!fs.existsSync(indexPath)) {
+ return res.status(404).send(
+ '
' +
+ '' +
+ ''
+ );
+ }
+
+ res.sendFile(indexPath);
+});
+
+// GET /api/ps/preview/:devId/:project/:file (sub-resources like CSS/JS)
+router.get('/:devId/:project/:file', (req, res) => {
+ const { devId, project, file } = req.params;
+
+ const safeDevId = path.basename(devId);
+ const safeProject = path.basename(project);
+ const safeFile = path.basename(file);
+
+ const previewDir = path.join(WORKSPACE_DIR, safeDevId, safeProject, 'preview');
+ const projectDir = path.join(WORKSPACE_DIR, safeDevId, safeProject);
+
+ let targetDir = previewDir;
+ if (!fs.existsSync(previewDir)) {
+ targetDir = projectDir;
+ }
+
+ const filePath = path.join(targetDir, safeFile);
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).send('File not found');
+ }
+
+ res.sendFile(filePath);
+});
+
+module.exports = router;
diff --git a/persona-studio/backend/server.js b/persona-studio/backend/server.js
index a4899137..b3bca0e8 100644
--- a/persona-studio/backend/server.js
+++ b/persona-studio/backend/server.js
@@ -2,12 +2,14 @@ require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
+const http = require('http');
const authRoutes = require('./routes/auth');
const chatRoutes = require('./routes/chat');
const buildRoutes = require('./routes/build');
const notifyRoutes = require('./routes/notify');
const apikeyRoutes = require('./routes/apikey');
+const previewRoutes = require('./routes/preview');
const app = express();
app.use(cors());
@@ -22,6 +24,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/preview', previewRoutes);
// ── 健康检查 ──
app.get('/api/ps/health', (_req, res) => {
@@ -48,7 +51,7 @@ app.get('/', (_req, res) => {
res.json({
status: 'ok',
message: 'Persona Studio 后端服务运行中',
- version: '1.0.0',
+ version: '2.0.0',
routes: [
'/api/ps/auth/login',
'/api/ps/chat/message',
@@ -57,6 +60,7 @@ app.get('/', (_req, res) => {
'/api/ps/notify/send',
'/api/ps/apikey/detect-models',
'/api/ps/apikey/chat',
+ '/api/ps/preview/:devId/:project',
'/api/ps/health'
]
});
@@ -64,7 +68,59 @@ app.get('/', (_req, res) => {
const PORT = process.env.PS_PORT || 3002;
-app.listen(PORT, () => {
+// ── WebSocket 服务(预览进度推送) ──
+const server = http.createServer(app);
+
+// WebSocket clients map: dev_id -> Set
+const wsClients = new Map();
+
+try {
+ const WebSocket = require('ws');
+ const wss = new WebSocket.Server({ server, path: '/ws/preview' });
+
+ wss.on('connection', (ws, req) => {
+ const url = new URL(req.url, 'http://localhost');
+ const devId = url.searchParams.get('dev_id') || 'unknown';
+
+ if (!wsClients.has(devId)) {
+ wsClients.set(devId, new Set());
+ }
+ wsClients.get(devId).add(ws);
+
+ ws.on('close', () => {
+ const clients = wsClients.get(devId);
+ if (clients) {
+ clients.delete(ws);
+ if (clients.size === 0) wsClients.delete(devId);
+ }
+ });
+
+ ws.on('error', () => {
+ const clients = wsClients.get(devId);
+ if (clients) {
+ clients.delete(ws);
+ if (clients.size === 0) wsClients.delete(devId);
+ }
+ });
+ });
+
+ // Export broadcast function for other modules
+ app.locals.broadcastToClient = function (devId, data) {
+ const clients = wsClients.get(devId);
+ if (!clients) return;
+ const msg = typeof data === 'string' ? data : JSON.stringify(data);
+ clients.forEach(function (ws) {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(msg);
+ }
+ });
+ };
+} catch (_e) {
+ // ws module not installed, WebSocket disabled
+ app.locals.broadcastToClient = function () {};
+}
+
+server.listen(PORT, () => {
console.log(`🌊 Persona Studio 后端服务启动 · 端口 ${PORT}`);
});
diff --git a/persona-studio/backend/utils/email-sender.js b/persona-studio/backend/utils/email-sender.js
index 937c220a..f81c3680 100644
--- a/persona-studio/backend/utils/email-sender.js
+++ b/persona-studio/backend/utils/email-sender.js
@@ -50,21 +50,51 @@ async function send({ to, subject, body }) {
/**
* 发送开发完成通知
*/
-async function sendCompletion({ to, dev_id, projectName, summary }) {
+async function sendCompletion({ to, dev_id, projectName, summary, files, downloadUrl }) {
const subject = `✅ 你的模块已完成 · ${projectName}`;
+
+ // 生成文件列表
+ let fileListHtml = '';
+ if (files && files.length > 0) {
+ fileListHtml = '📁 文件列表
';
+ files.forEach(function (f) {
+ fileListHtml += '- ' + f + '
';
+ });
+ fileListHtml += '
';
+ }
+
+ // 下载链接
+ let downloadHtml = '';
+ if (downloadUrl) {
+ downloadHtml = '📦 下载项目包
';
+ }
+
const body = [
- '',
- '
🌊 光湖 Persona Studio
',
- '
',
- `
你好 ${dev_id},
`,
- `
你的项目 ${projectName} 已经完成开发!
`,
+ '
',
+ '
',
+ '
🌊 光湖 Persona Studio
',
+ '
HoloLake Era · AGE OS · 人格语言操作系统
',
+ '
',
+ '
',
+ '
你好 ' + dev_id + ',
',
+ '
你的项目 ' + projectName + ' 已经完成开发!
',
'
📋 开发摘要
',
- `
${summary || '项目代码已生成'}
`,
- '
',
- '
',
- '光湖语言人格系统 · HoloLake Era · AGE OS
',
- '此邮件由铸渊自动发送',
+ '
' + (summary || '项目代码已生成') + '
',
+ fileListHtml,
+ downloadHtml,
+ '
📖 使用说明
',
+ '
',
+ '- 下载项目包并解压
',
+ '- 在浏览器中打开 index.html 查看效果
',
+ '- 如需修改,用任意代码编辑器打开项目文件
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '🌀 铸渊 · 代码守护人格体 · 自动发送
',
+ '光湖语言人格系统 · HoloLake Era · AGE OS',
'
',
+ '
',
'
'
].join('\n');
diff --git a/persona-studio/frontend/chat.html b/persona-studio/frontend/chat.html
index 38a10165..4d38c376 100644
--- a/persona-studio/frontend/chat.html
+++ b/persona-studio/frontend/chat.html
@@ -7,7 +7,7 @@
-
+
-
+
-
📧 请填写模块开发完成后发送的邮箱
-
+
📧 开发完成后,代码将发送到你的邮箱
+
+
+
+
+
-
+
diff --git a/persona-studio/frontend/chat.js b/persona-studio/frontend/chat.js
index e3a50cce..ed1c460a 100644
--- a/persona-studio/frontend/chat.js
+++ b/persona-studio/frontend/chat.js
@@ -584,21 +584,65 @@ function appendMessage(role, content) {
/* ---- Build Flow ---- */
function handleBuild() {
- document.getElementById('emailModal').style.display = 'flex';
- document.getElementById('emailInput').focus();
+ var modal = document.getElementById('emailModal');
+ var emailInput = document.getElementById('emailInput');
+ var contactInput = document.getElementById('contactInput');
+ var errorDiv = document.getElementById('emailError');
+
+ // 预填已存储的邮箱
+ var savedEmail = sessionStorage.getItem('ps_build_email') || '';
+ var savedContact = sessionStorage.getItem('ps_build_contact') || '';
+ if (savedEmail) emailInput.value = savedEmail;
+ if (savedContact) contactInput.value = savedContact;
+
+ errorDiv.style.display = 'none';
+ modal.style.display = 'flex';
+ emailInput.focus();
}
function closeEmailModal() {
document.getElementById('emailModal').style.display = 'none';
+ document.getElementById('emailError').style.display = 'none';
+}
+
+/**
+ * 后端二次校验用的邮箱正则
+ */
+function validateEmail(email) {
+ var re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+ return re.test(email);
}
async function confirmBuild() {
- var email = document.getElementById('emailInput').value.trim();
- if (!email) return;
+ var emailInput = document.getElementById('emailInput');
+ var contactInput = document.getElementById('contactInput');
+ var errorDiv = document.getElementById('emailError');
+ var email = emailInput.value.trim();
+ var contact = contactInput.value.trim();
+
+ // 前端校验
+ if (!email) {
+ errorDiv.textContent = '请填写邮箱地址';
+ errorDiv.style.display = 'block';
+ return;
+ }
+
+ if (!validateEmail(email)) {
+ errorDiv.textContent = '邮箱格式不正确,请检查';
+ errorDiv.style.display = 'block';
+ return;
+ }
+
+ // 存储邮箱(下次预填)
+ sessionStorage.setItem('ps_build_email', email);
+ if (contact) sessionStorage.setItem('ps_build_contact', contact);
closeEmailModal();
appendMessage('system', '🚀 开发任务已提交,完成后会发送到 ' + email);
+ // 进入分屏模式
+ enterDevMode();
+
try {
await fetch(API_BASE + '/api/ps/build/start', {
method: 'POST',
@@ -606,12 +650,227 @@ async function confirmBuild() {
body: JSON.stringify({
dev_id: DEV_ID,
email: email,
+ contact: contact,
conversation: conversationHistory
})
});
} catch (_err) {
appendMessage('system', '任务提交失败,请稍后再试');
}
+
+ // 连接 WebSocket 获取进度更新
+ connectPreviewWebSocket();
+}
+
+/* ---- Dev Mode: Split Screen ---- */
+var isDevMode = false;
+var wsConnection = null;
+var currentPreviewUrl = '';
+
+function enterDevMode() {
+ if (isDevMode) return;
+ isDevMode = true;
+
+ var layout = document.getElementById('chatLayout');
+ var resizer = document.getElementById('resizer');
+ var previewPanel = document.getElementById('previewPanel');
+
+ layout.classList.add('dev-mode');
+ resizer.style.display = 'block';
+ previewPanel.style.display = 'flex';
+
+ // 初始各占50%
+ var chatMain = document.getElementById('chatMain');
+ chatMain.style.flex = '1 1 50%';
+ previewPanel.style.flex = '1 1 50%';
+
+ updatePreviewStatus('waiting', '等待中');
+ initResizer();
+}
+
+function exitDevMode() {
+ isDevMode = false;
+
+ var layout = document.getElementById('chatLayout');
+ var resizer = document.getElementById('resizer');
+ var previewPanel = document.getElementById('previewPanel');
+ var chatMain = document.getElementById('chatMain');
+
+ layout.classList.remove('dev-mode');
+ resizer.style.display = 'none';
+ previewPanel.style.display = 'none';
+ chatMain.style.flex = '';
+
+ if (wsConnection) {
+ wsConnection.close();
+ wsConnection = null;
+ }
+}
+
+/* ---- Draggable Resizer ---- */
+function initResizer() {
+ var resizer = document.getElementById('resizer');
+ var chatMain = document.getElementById('chatMain');
+ var previewPanel = document.getElementById('previewPanel');
+ var layout = document.getElementById('chatLayout');
+
+ var startX, startChatWidth, startPreviewWidth;
+
+ function onMouseDown(e) {
+ e.preventDefault();
+ startX = e.clientX;
+ startChatWidth = chatMain.getBoundingClientRect().width;
+ startPreviewWidth = previewPanel.getBoundingClientRect().width;
+ resizer.classList.add('resizing');
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ }
+
+ function onMouseMove(e) {
+ var dx = e.clientX - startX;
+ var layoutWidth = layout.getBoundingClientRect().width;
+ var sidebarWidth = document.getElementById('chatSidebar').getBoundingClientRect().width;
+ var resizerWidth = resizer.getBoundingClientRect().width;
+ var available = layoutWidth - sidebarWidth - resizerWidth;
+
+ var newChatWidth = startChatWidth + dx;
+ var newPreviewWidth = startPreviewWidth - dx;
+
+ // Enforce min-width 360px
+ if (newChatWidth < 360) newChatWidth = 360;
+ if (newPreviewWidth < 360) newPreviewWidth = 360;
+ if (newChatWidth + newPreviewWidth > available) return;
+
+ chatMain.style.flex = '0 0 ' + newChatWidth + 'px';
+ previewPanel.style.flex = '0 0 ' + newPreviewWidth + 'px';
+ }
+
+ function onMouseUp() {
+ resizer.classList.remove('resizing');
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ }
+
+ resizer.addEventListener('mousedown', onMouseDown);
+
+ // Touch support for mobile
+ resizer.addEventListener('touchstart', function (e) {
+ var touch = e.touches[0];
+ startX = touch.clientX;
+ startChatWidth = chatMain.getBoundingClientRect().width;
+ startPreviewWidth = previewPanel.getBoundingClientRect().width;
+ resizer.classList.add('resizing');
+
+ function onTouchMove(ev) {
+ var t = ev.touches[0];
+ var fakeEvent = { clientX: t.clientX };
+ onMouseMove(fakeEvent);
+ }
+
+ function onTouchEnd() {
+ resizer.classList.remove('resizing');
+ document.removeEventListener('touchmove', onTouchMove);
+ document.removeEventListener('touchend', onTouchEnd);
+ }
+
+ document.addEventListener('touchmove', onTouchMove);
+ document.addEventListener('touchend', onTouchEnd);
+ });
+}
+
+/* ---- Preview Panel ---- */
+function updatePreviewStatus(status, text) {
+ var statusEl = document.getElementById('previewStatus');
+ if (!statusEl) return;
+
+ var dotClass = 'status-dot status-' + status;
+ statusEl.innerHTML = '
' + escapeHtml(text) + '';
+}
+
+function updatePreviewUrl(url) {
+ currentPreviewUrl = url;
+ var frame = document.getElementById('previewFrame');
+ if (frame) frame.src = url;
+}
+
+function refreshPreview() {
+ var frame = document.getElementById('previewFrame');
+ if (frame && frame.src) {
+ frame.src = frame.src;
+ }
+}
+
+function openPreviewNewWindow() {
+ if (currentPreviewUrl) {
+ window.open(currentPreviewUrl, '_blank');
+ }
+}
+
+function setPreviewProjectName(name) {
+ var el = document.getElementById('previewProjectName');
+ if (el) el.textContent = name || '项目';
+}
+
+/* ---- WebSocket for Preview Updates ---- */
+function connectPreviewWebSocket() {
+ var wsBase = API_BASE.replace(/^http/, 'ws');
+ var wsUrl = wsBase + '/ws/preview?dev_id=' + encodeURIComponent(DEV_ID);
+
+ try {
+ wsConnection = new WebSocket(wsUrl);
+
+ wsConnection.onopen = function () {
+ appendMessage('system', '🔧 正在创建项目骨架...');
+ updatePreviewStatus('building', '构建中');
+ };
+
+ wsConnection.onmessage = function (event) {
+ try {
+ var data = JSON.parse(event.data);
+
+ if (data.type === 'progress') {
+ appendMessage('system', data.message || '构建进度更新');
+ if (data.status) {
+ updatePreviewStatus(data.status, data.status_text || '');
+ }
+ }
+
+ if (data.type === 'preview_ready') {
+ var previewUrl = API_BASE + '/api/ps/preview/' + encodeURIComponent(DEV_ID) + '/' + encodeURIComponent(data.project);
+ updatePreviewUrl(previewUrl);
+ setPreviewProjectName(data.project);
+ updatePreviewStatus('done', '完成');
+ appendMessage('system', '✅ 预览已就绪,右侧可以查看');
+ }
+
+ if (data.type === 'reload') {
+ refreshPreview();
+ }
+
+ if (data.type === 'complete') {
+ updatePreviewStatus('done', '全部完成');
+ appendMessage('system', '🎉 全部完成!邮件正在发送');
+ }
+
+ if (data.type === 'error') {
+ updatePreviewStatus('error', '出错');
+ appendMessage('system', '❌ ' + (data.message || '构建出错'));
+ }
+ } catch (_e) { /* ignore malformed WS message */ }
+ };
+
+ wsConnection.onerror = function () {
+ // WebSocket not available, graceful degradation
+ updatePreviewStatus('waiting', '离线模式');
+ };
+
+ wsConnection.onclose = function () {
+ wsConnection = null;
+ };
+ } catch (_e) {
+ // WebSocket connection failed, graceful degradation
+ updatePreviewStatus('waiting', '离线模式');
+ }
}
/* ---- Logout ---- */
diff --git a/persona-studio/frontend/style.css b/persona-studio/frontend/style.css
index 5d136ce2..f9f6506f 100644
--- a/persona-studio/frontend/style.css
+++ b/persona-studio/frontend/style.css
@@ -1205,7 +1205,34 @@ body {
color: var(--text);
}
-.modal-content input[type="email"] {
+.modal-field {
+ margin-bottom: 1rem;
+}
+
+.modal-label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.4rem;
+}
+
+.modal-label .required {
+ color: #f87171;
+}
+
+.modal-label .optional {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+.modal-error {
+ color: #f87171;
+ font-size: 0.8rem;
+ margin-top: 0.3rem;
+}
+
+.modal-content input[type="email"],
+.modal-content input[type="text"] {
width: 100%;
padding: 0.7rem 1rem;
font-size: 1rem;
@@ -1214,15 +1241,15 @@ body {
border: 2px solid var(--border);
border-radius: 8px;
outline: none;
- margin-bottom: 1rem;
transition: border-color 0.2s;
}
-.modal-content input[type="email"]:focus {
+.modal-content input[type="email"]:focus,
+.modal-content input[type="text"]:focus {
border-color: var(--primary);
}
-.modal-content input[type="email"]::placeholder {
+.modal-content input::placeholder {
color: var(--text-muted);
}
@@ -1232,6 +1259,139 @@ body {
justify-content: flex-end;
}
+/* ---- Split-screen / Dev Mode ---- */
+.chat-layout.dev-mode {
+ display: flex;
+ height: 100vh;
+}
+
+.chat-layout.dev-mode .chat-main {
+ min-width: 360px;
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* ---- Resizer (draggable split line) ---- */
+.resizer {
+ width: 4px;
+ cursor: col-resize;
+ background: linear-gradient(180deg, transparent, rgba(59, 130, 246, 0.4), transparent);
+ flex-shrink: 0;
+ position: relative;
+ z-index: 5;
+ transition: background 0.2s;
+}
+
+.resizer:hover,
+.resizer.resizing {
+ background: linear-gradient(180deg, transparent, rgba(59, 130, 246, 0.8), transparent);
+ width: 6px;
+}
+
+/* ---- Preview Panel ---- */
+.preview-panel {
+ min-width: 360px;
+ background: #0a0e1a;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.preview-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.6rem 1rem;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.preview-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.9rem;
+ color: var(--text);
+ font-weight: 500;
+}
+
+.preview-icon {
+ font-size: 1.1rem;
+}
+
+.preview-status {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-left: auto;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.status-dot.status-waiting {
+ background: #6b7280;
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.status-dot.status-building {
+ background: #3b82f6;
+ animation: pulse 1s ease-in-out infinite;
+}
+
+.status-dot.status-done {
+ background: #22c55e;
+}
+
+.status-dot.status-error {
+ background: #ef4444;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+.preview-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.preview-btn {
+ padding: 0.3rem 0.6rem;
+ background: transparent;
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.preview-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text);
+ border-color: var(--border-light);
+}
+
+.preview-iframe {
+ flex: 1;
+ width: calc(100% - 24px);
+ height: calc(100% - 24px);
+ border: none;
+ border-radius: 8px;
+ margin: 12px;
+ box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
+ background: #fff;
+}
+
/* ---- Animation ---- */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }