diff --git a/app.py b/app.py index a0809ba..eb9b284 100644 --- a/app.py +++ b/app.py @@ -33,6 +33,10 @@ def create_app(): def ocr(): return render_template('ocr.html') + @app.route('/visualizer') + def visualizer(): + return render_template('kongzhiqi.html') + # 自动创建数据库表 with app.app_context(): print("🔧 正在检查并创建数据库表...") diff --git a/blueprints/__pycache__/api.cpython-312.pyc b/blueprints/__pycache__/api.cpython-312.pyc index 976e61f..d499527 100644 Binary files a/blueprints/__pycache__/api.cpython-312.pyc and b/blueprints/__pycache__/api.cpython-312.pyc differ diff --git a/blueprints/api.py b/blueprints/api.py index 67b70b1..d5d5c71 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -93,6 +93,63 @@ def sync_images_background(app, record_id, raw_urls): except Exception as e: print(f"❌ 更新记录失败: {e}") +def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost): + """异步执行图片生成并存入 Redis""" + with app.app_context(): + try: + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + # 使用较长的超时时间 (10分钟),确保长耗时任务不被中断 + resp = requests.post(target_api, json=payload, headers=headers, timeout=1000) + + if resp.status_code != 200: + # 错误处理:退还积分 + user = User.query.get(user_id) + if user and "sk-" in api_key: + user.points += cost + db.session.commit() + redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text})) + return + + api_result = resp.json() + raw_urls = [item['url'] for item in api_result.get('data', [])] + + # 持久化记录 + new_record = GenerationRecord( + user_id=user_id, + prompt=payload.get('prompt'), + model=payload.get('model'), + image_urls=json.dumps(raw_urls) + ) + db.session.add(new_record) + db.session.commit() + + # 后台线程处理:下载 AI 原始图片并同步到私有 MinIO + threading.Thread( + target=sync_images_background, + args=(app, new_record.id, raw_urls) + ).start() + + # 存入 Redis 标记完成 + redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls})) + + except Exception as e: + # 异常处理:退还积分 + user = User.query.get(user_id) + if user and "sk-" in api_key: + user.points += cost + db.session.commit() + redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)})) + +@api_bp.route('/api/task_status/') +@login_required +def get_task_status(task_id): + """查询异步任务状态""" + data = redis_client.get(f"task:{task_id}") + if not data: + # 如果 Redis 里没有,可能是刚提交,也可能是过期了 + return jsonify({"status": "pending"}) + return jsonify(json.loads(data)) + @api_bp.route('/api/config') def get_config(): """从本地数据库字典获取配置""" @@ -157,197 +214,99 @@ def generate(): use_trial = False if mode == 'key': - # 自定义 Key 模式:优先使用本次输入的,否则使用数据库存的 api_key = input_key or user.api_key if not api_key: return jsonify({"error": "请先输入您的 API 密钥"}), 400 else: - # 积分/试用模式 if user.points > 0: - # 核心修复:优质模式使用专属 Key api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY target_api = Config.TRIAL_API use_trial = True else: return jsonify({"error": "可用积分已耗尽,请充值或切换至自定义 Key 模式"}), 400 - # 如果是 Key 模式且输入了新 Key,则自动更新到数据库保存 if mode == 'key' and input_key and input_key != user.api_key: user.api_key = input_key db.session.commit() - # 获取模型及对应的消耗积分 model_value = data.get('model') is_chat_model = "gemini" in model_value.lower() or "gpt" in model_value.lower() model_dict = SystemDict.query.filter_by(dict_type='ai_model', value=model_value).first() cost = model_dict.cost if model_dict else 1 - # 核心修复:优质模式积分消耗 X2 if use_trial and is_premium: cost *= 2 - # --- 积分预扣除逻辑 (点击即扣) --- if use_trial: if user.points < cost: - return jsonify({"error": f"可用积分不足,优质模式需要 {cost} 积分,您当前剩余 {user.points} 积分"}), 400 - + return jsonify({"error": f"可用积分不足"}), 400 user.points -= cost db.session.commit() - system_logger.info(f"积分预扣除 ({'优质' if is_premium else '普通'}试用)", phone=user.phone, cost=cost, remaining_points=user.points) - try: - prompt = data.get('prompt') - model = model_value - ratio = data.get('ratio') - size = data.get('size') - input_img_urls = data.get('image_urls', []) + prompt = data.get('prompt') + ratio = data.get('ratio') + size = data.get('size') + image_data = data.get('image_data', []) + + payload = { + "prompt": prompt, + "model": model_value, + "response_format": "url", + "aspect_ratio": ratio + } + if image_data: + payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data] - payload = { - "prompt": prompt, - "model": model, - "response_format": "url", - "aspect_ratio": ratio - } - if input_img_urls: - payload["image"] = input_img_urls - - if model == "nano-banana-2" and size: - payload["image_size"] = size - - # 如果是聊天模型,重新构建 payload - if is_chat_model: - messages = data.get('messages') - - # 核心修复:将图片 URL 转换为 Base64,解决第三方接口禁止 9000 端口访问的问题 - def url_to_base64(url): - if not url or not url.startswith('http'): - return url - if ':9000' not in url: - return url - try: - # 尝试通过 S3 客户端直接读取(更安全,避开网络回环问题) - filename = url.split('/')[-1] - from urllib.parse import unquote - filename = unquote(filename) - - resp = s3_client.get_object(Bucket=Config.MINIO["bucket"], Key=filename) - content = resp['Body'].read() - mime_type = resp.get('ContentType', 'image/jpeg') - - encoded_string = base64.b64encode(content).decode('utf-8') - return f"data:{mime_type};base64,{encoded_string}" - except Exception as e: - print(f"⚠️ Base64 转换失败: {e}") - return url - - if not messages: - # 兼容性处理:如果没有 messages,构建一个简单的 - messages = [ - {"role": "system", "content": data.get('system_prompt', "You are a helpful assistant.")}, - {"role": "user", "content": [ - {"type": "text", "text": prompt} - ]} - ] - for img_url in input_img_urls: - messages[1]["content"].append({"type": "image_url", "image_url": {"url": url_to_base64(img_url)}}) - else: - # 递归处理传入的 messages - for msg in messages: - content = msg.get('content') - if isinstance(content, list): - for item in content: - if isinstance(item, dict) and item.get('type') == 'image_url': - img_info = item.get('image_url') - if img_info and 'url' in img_info: - img_info['url'] = url_to_base64(img_info['url']) - - payload = { - "model": model, - "messages": messages, - "stream": False - } - target_api = Config.CHAT_API - if not mode == 'key': - # 使用生图默认的试用/优质 Key - api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY + if model_value == "nano-banana-2" and size: + payload["image_size"] = size + # 如果是聊天模型,直接同步处理 + if is_chat_model: headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - resp = requests.post(target_api, json=payload, headers=headers, timeout=300) - + chat_payload = { + "model": model_value, + "messages": [{"role": "user", "content": prompt}] + } + resp = requests.post(Config.CHAT_API, json=chat_payload, headers=headers, timeout=120) if resp.status_code != 200: - # API 报错,退还积分 if use_trial: user.points += cost db.session.commit() - system_logger.warning(f"API 报错,积分已退还", phone=user.phone, status_code=resp.status_code) return jsonify({"error": resp.text}), resp.status_code - + api_result = resp.json() + content = api_result['choices'][0]['message']['content'] - if is_chat_model: - # 聊天模型返回的是文本 - content = api_result['choices'][0]['message']['content'] - - # 核心修复:如果是验光单解读(OCR),不存入生图历史记录,避免污染生图历史 - if prompt != "解读验光单": - new_record = GenerationRecord( - user_id=session.get('user_id'), - prompt=prompt, - model=model, - image_urls=json.dumps([{"type": "text", "content": content}]) - ) - db.session.add(new_record) - db.session.commit() - system_logger.info(f"用户生成文本成功", phone=user.phone, model=model, record_id=new_record.id) - else: - system_logger.info(f"用户解析验光单成功", phone=user.phone, model=model) - - return jsonify({ - "data": [{"content": content, "type": "text"}], - "message": "解析成功!" - }) - - raw_urls = [item['url'] for item in api_result.get('data', [])] + # 记录聊天历史 + if prompt != "解读验光单": + new_record = GenerationRecord( + user_id=user_id, + prompt=prompt, + model=model_value, + image_urls=json.dumps([{"type": "text", "content": content}]) + ) + db.session.add(new_record) + db.session.commit() - # 立即写入数据库(先存原始 URL) - new_record = GenerationRecord( - user_id=session.get('user_id'), - prompt=prompt, - model=model, - image_urls=json.dumps(raw_urls) - ) - db.session.add(new_record) - db.session.commit() - - # 写入系统日志 - system_logger.info( - f"用户生成图片成功", - phone=user.phone, - model=model, - record_id=new_record.id - ) - - # 启动后台线程同步图片,不阻塞前端返回 - app = current_app._get_current_object() - threading.Thread( - target=sync_images_background, - args=(app, new_record.id, raw_urls) - ).start() - - # 立即返回原始 URL 给前端展示 return jsonify({ - "data": [{"url": url} for url in raw_urls], - "message": "生成成功!作品正在后台同步至云存储。" + "data": [{"content": content, "type": "text"}], + "message": "生成成功!" }) - except Exception as e: - # 发生系统异常,退还积分 - if use_trial: - user.points += cost - db.session.commit() - system_logger.error(f"生成异常,积分已退还", phone=user.phone, error=str(e)) - return jsonify({"error": str(e)}), 500 + # --- 异步处理生图任务 --- + task_id = str(uuid.uuid4()) + app = current_app._get_current_object() + + threading.Thread( + target=process_image_generation, + args=(app, user_id, task_id, payload, api_key, target_api, cost) + ).start() + + return jsonify({ + "task_id": task_id, + "message": "已开启异步生成任务" + }) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/config.js b/config.js deleted file mode 100644 index 22339d6..0000000 --- a/config.js +++ /dev/null @@ -1,26 +0,0 @@ -// config.js -const AppConfig = { - API_URL: 'https://ai.comfly.chat/v1/chat/completions', - //MODEL: 'gemini-3-pro-preview', // 你要求的模型 - //MODEL: 'gemini-2.5-pro', - MODEL: 'gemini-3-flash-preview', - // 这里填入第一步生成的“加密Key” - ENCRYPTED_KEY: "MARAXis7BiwzBDwiLQIjLHNRWwwbWQAKDSFyAiQEYx8iIy8QUHNRAQkoJhMdCRRRWAwJ", - - // 解密函数(必须与加密算法对应) - getDecryptedKey: function() { - const salt = "ComflyChatSecret2025"; // 必须与加密时的 Salt 一致 - try { - const raw = atob(this.ENCRYPTED_KEY); - let result = ""; - for (let i = 0; i < raw.length; i++) { - const charCode = raw.charCodeAt(i) ^ salt.charCodeAt(i % salt.length); - result += String.fromCharCode(charCode); - } - return result; - } catch (e) { - console.error("Key 解密失败"); - return ""; - } - } -}; \ No newline at end of file diff --git a/logs/system.log b/logs/system.log index 51055a5..8de7ffb 100644 --- a/logs/system.log +++ b/logs/system.log @@ -134,3 +134,35 @@ [2026-01-14 20:12:31] INFO - 用户登录成功 [2026-01-14 20:17:10] INFO - 用户登录尝试 [2026-01-14 20:17:10] INFO - 用户登录成功 +[2026-01-15 20:17:15] INFO - 用户登录尝试 +[2026-01-15 20:17:15] WARNING - 登录失败: 手机号或密码错误 +[2026-01-15 20:17:17] INFO - 用户登录尝试 +[2026-01-15 20:17:17] WARNING - 登录失败: 手机号或密码错误 +[2026-01-15 20:17:21] INFO - 用户登录尝试 +[2026-01-15 20:17:22] WARNING - 登录失败: 手机号或密码错误 +[2026-01-15 20:17:32] INFO - 用户登录尝试 +[2026-01-15 20:17:32] INFO - 用户登录成功 +[2026-01-15 20:22:42] INFO - 用户生成图片成功 +[2026-01-15 20:31:31] INFO - 用户生成图片成功 +[2026-01-15 20:45:22] INFO - 用户生成图片成功 +[2026-01-15 20:49:12] INFO - 用户生成图片成功 +[2026-01-15 20:50:53] INFO - 用户生成图片成功 +[2026-01-15 20:52:35] INFO - 用户生成图片成功 +[2026-01-15 20:52:35] INFO - 用户生成图片成功 +[2026-01-15 20:52:37] INFO - 用户生成图片成功 +[2026-01-15 20:52:37] INFO - 用户生成图片成功 +[2026-01-15 20:56:42] INFO - 用户生成图片成功 +[2026-01-15 20:57:31] INFO - 用户生成图片成功 +[2026-01-15 20:59:14] INFO - 用户生成图片成功 +[2026-01-15 21:00:00] INFO - 用户生成图片成功 +[2026-01-15 21:01:05] INFO - 用户生成图片成功 +[2026-01-15 21:01:05] INFO - 用户生成图片成功 +[2026-01-15 21:01:05] INFO - 用户生成图片成功 +[2026-01-15 21:05:31] INFO - 用户生成图片成功 +[2026-01-15 21:05:31] INFO - 用户生成图片成功 +[2026-01-15 21:05:31] INFO - 用户生成图片成功 +[2026-01-15 21:05:31] INFO - 用户生成图片成功 +[2026-01-15 21:08:09] INFO - 积分预扣除 (普通试用) +[2026-01-15 21:09:55] INFO - 用户生成图片成功 (Base64模式) +[2026-01-15 21:13:38] INFO - 积分预扣除 (普通试用) +[2026-01-15 21:13:59] INFO - 用户生成图片成功 (上传:Base64 / 接收:URL) diff --git a/main.js b/main.js deleted file mode 100644 index 236fa59..0000000 --- a/main.js +++ /dev/null @@ -1,415 +0,0 @@ -// main.js - -// 状态管理 -let imageList = []; -let finalMarkdown = ""; -let extractedData = null; -let statusInterval = null; // 用于动态文字切换 - -// DOM 元素引用 -const els = { - dropArea: document.getElementById('drop-area'), - fileInput: document.getElementById('file-input'), - placeholder: document.getElementById('upload-placeholder'), - previewGrid: document.getElementById('preview-grid'), - controlBar: document.getElementById('control-bar'), - imgCount: document.getElementById('img-count'), - clearBtn: document.getElementById('clear-btn'), - analyzeBtn: document.getElementById('analyze-btn'), - btnText: document.getElementById('btn-text'), - btnSpinner: document.getElementById('btn-spinner'), - - // 结果区域视图 - placeholderView: document.getElementById('placeholder-view'), // 初始欢迎页 - loadingView: document.getElementById('loading-view'), // 加载动画页 - loadingStatus: document.getElementById('loading-status-text'), // 动态提示文字 - resultContent: document.getElementById('result-content'), // 结果显示页 - resultScroll: document.getElementById('result-scroll'), // 滚动容器 - copyBtn: document.getElementById('copy-btn') -}; - -// === 事件监听 === - -els.dropArea.addEventListener('click', (e) => { - if (e.target !== els.clearBtn && imageList.length < 2) els.fileInput.click(); -}); - -els.fileInput.addEventListener('change', e => handleFiles(e.target.files)); - -document.addEventListener('paste', e => { - const items = e.clipboardData.items; - const files = []; - for (let item of items) { - if (item.type.indexOf('image') !== -1) files.push(item.getAsFile()); - } - if (files.length > 0) handleFiles(files); -}); - -['dragenter', 'dragover', 'dragleave', 'drop'].forEach(name => { - els.dropArea.addEventListener(name, e => { e.preventDefault(); e.stopPropagation(); }); -}); -els.dropArea.addEventListener('drop', e => handleFiles(e.dataTransfer.files)); - -els.clearBtn.addEventListener('click', (e) => { - e.stopPropagation(); - resetImages(); -}); - -// === 复制按钮逻辑 === -els.copyBtn.addEventListener('click', async () => { - let textToCopy = ""; - - if (!extractedData) { - textToCopy = els.resultContent.innerText; - showCopyFeedback("⚠️ 未识别到数据,已复制全文"); - } else { - textToCopy = generateCopyString(extractedData); - } - - const success = await copyToClipboard(textToCopy); - - if (success) { - showCopyFeedback(extractedData ? "✅ 格式化数据已复制" : "✅ 已复制全文"); - } else { - prompt("浏览器禁止自动复制,请手动复制以下内容:", textToCopy); - } -}); - -// === 兼容性极强的复制函数 === -async function copyToClipboard(text) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch (err) { - console.warn("现代复制API失败,尝试传统方法:", err); - try { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - textarea.style.top = '0'; - textarea.setAttribute('readonly', ''); - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const successful = document.execCommand('copy'); - document.body.removeChild(textarea); - return successful; - } catch (e) { - return false; - } - } -} - -function showCopyFeedback(msg) { - const originalText = "📄 复制结果"; - els.copyBtn.innerText = msg; - setTimeout(() => { - els.copyBtn.innerText = originalText; - }, 2000); -} - -// === 高度定制的复制逻辑 === -function generateCopyString(data) { - const r = data.right; - const l = data.left; - const tPd = data.total_pd; - - const blocks = []; - - const fmtDeg = (val) => { - if (!val || val === "未标明" || val.trim() === "") return null; - const num = parseFloat(val); - if (isNaN(num)) return null; - const deg = Math.round(num * 100); - if (deg === 0) return null; - if (deg < 0) return Math.abs(deg).toString(); - if (deg > 0) return "+" + deg.toString(); - return null; - }; - - const fmtCyl = (val) => fmtDeg(val); - const fmtRaw = (val) => (!val || val === "未标明" || val.trim() === "") ? null : val.trim(); - const fmtPD = (val) => { - if (!val || val === "未标明" || val.trim() === "") return null; - const num = parseFloat(val); - if (isNaN(num)) return val.trim(); - return num.toString(); - }; - - const rS = fmtDeg(r.s), lS = fmtDeg(l.s); - const rC = fmtCyl(r.c), lC = fmtCyl(l.c); - const rA = rC ? fmtRaw(r.a) : null, lA = lC ? fmtRaw(l.a) : null; - const rAdd = fmtDeg(r.add), lAdd = fmtDeg(l.add); - const rPH = fmtRaw(r.ph), lPH = fmtRaw(l.ph); - const rPd = fmtPD(r.pd), lPd = fmtPD(l.pd), totalPd = fmtPD(tPd); - - if (!rS && !rC && !lS && !lC) return "平光"; - - const isDoubleSame = (rS === lS) && (rC === lC) && (rA === lA); - - if (isDoubleSame) { - let block = `双眼${rS || ""}`; - if (rC) block += `散光${rC}`; - if (rA) block += `轴位${rA}`; - blocks.push(block); - } else { - if (rS || rC) { - let block = `右眼${rS || ""}`; - if (rC) block += `散光${rC}`; - if (rA) block += `轴位${rA}`; - blocks.push(block); - } - if (lS || lC) { - let block = `左眼${lS || ""}`; - if (lC) block += `散光${lC}`; - if (lA) block += `轴位${lA}`; - blocks.push(block); - } - } - - if (rAdd && lAdd && rAdd === lAdd) blocks.push(`ADD${rAdd}`); - else { - if (rAdd) blocks.push(`ADD${rAdd}`); - if (lAdd) blocks.push(`ADD${lAdd}`); - } - - if (rPH) blocks.push(`瞳高右眼${rPH}`); - if (lPH) blocks.push(`瞳高左眼${lPH}`); - - if (totalPd) blocks.push(`瞳距${totalPd}`); - else { - if (rPd) blocks.push(`右眼瞳距${rPd}`); - if (lPd) blocks.push(`左眼瞳距${lPd}`); - } - - return blocks.join(' '); -} - -// === 核心逻辑函数 === -async function handleFiles(files) { - if (imageList.length >= 2) { - alert("最多只能上传 2 张图片,请先清空后再试。"); - return; - } - const remainingSlots = 2 - imageList.length; - const filesProcess = Array.from(files).slice(0, remainingSlots); - - for (let file of filesProcess) { - if (!file.type.startsWith('image/')) continue; - try { - const base64 = await readFileAsBase64(file); - const compressed = await compressImage(base64, 1024, 0.8); - imageList.push(compressed); - } catch (err) { - console.error("图片处理失败", err); - } - } - updatePreviewUI(); -} - -function resetImages() { - imageList = []; - updatePreviewUI(); - els.fileInput.value = ''; -} - -function updatePreviewUI() { - const count = imageList.length; - if (count === 0) { - els.placeholder.classList.remove('hidden'); - els.previewGrid.classList.add('hidden'); - els.controlBar.classList.add('hidden'); - } else { - els.placeholder.classList.add('hidden'); - els.previewGrid.classList.remove('hidden'); - els.controlBar.classList.remove('hidden'); - } - els.previewGrid.innerHTML = ''; - els.previewGrid.className = `w-full h-full grid gap-2 ${count > 1 ? 'grid-cols-2' : 'grid-cols-1'}`; - imageList.forEach((imgSrc) => { - const div = document.createElement('div'); - div.className = "relative rounded-lg overflow-hidden border border-slate-200 bg-slate-100 flex items-center justify-center h-full max-h-[400px]"; - div.innerHTML = ``; - els.previewGrid.appendChild(div); - }); - els.imgCount.innerText = `${count}/2`; -} - -function readFileAsBase64(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => resolve(e.target.result); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} - -function compressImage(base64Str, maxDim, quality) { - return new Promise((resolve) => { - const img = new Image(); - img.src = base64Str; - img.onload = () => { - let w = img.width, h = img.height; - if (w > maxDim || h > maxDim) { - if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; } - else { w = Math.round(w * maxDim / h); h = maxDim; } - } - const canvas = document.createElement('canvas'); - canvas.width = w; canvas.height = h; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0, w, h); - resolve(canvas.toDataURL('image/jpeg', quality)); - }; - }); -} - -// === 核心:设置加载状态 UI === -function setLoading(isLoading) { - els.analyzeBtn.disabled = isLoading; - - // 清除之前的定时器 - if (statusInterval) { - clearInterval(statusInterval); - statusInterval = null; - } - - if (isLoading) { - els.btnText.innerText = "正在分析..."; - els.btnSpinner.classList.remove('hidden'); - - if (els.placeholderView) els.placeholderView.classList.add('hidden'); - if (els.resultContent) els.resultContent.classList.add('hidden'); - if (els.loadingView) els.loadingView.classList.remove('hidden'); - - const statuses = [ - "正在上传并扫描验光单...", - "AI 正在识别视觉参数...", - "正在校对球镜与柱镜数值...", - "正在进行深度光学分析...", - "正在生成专业解读报告...", - "正在整理结果排版..." - ]; - - let i = 0; - if (els.loadingStatus) { - els.loadingStatus.innerText = statuses[0]; - - statusInterval = setInterval(() => { - if (i < statuses.length - 1) { - i++; - els.loadingStatus.innerText = statuses[i]; - } else { - // 播完了还没返回?进入“深度思考”省略号动画模式 - clearInterval(statusInterval); - let dots = 0; - const baseText = statuses[statuses.length - 1]; - statusInterval = setInterval(() => { - dots = (dots + 1) % 4; - els.loadingStatus.innerText = baseText + ".".repeat(dots); - }, 500); - } - }, 3000); // 3秒切换一次,更真实 - } - } else { - els.btnText.innerText = "开始智能解读"; - els.btnSpinner.classList.add('hidden'); - - if (els.loadingView) els.loadingView.classList.add('hidden'); - if (els.resultContent) els.resultContent.classList.remove('hidden'); - } -} - -// === API 调用逻辑 === -els.analyzeBtn.addEventListener('click', async () => { - if (imageList.length === 0) return alert('请至少上传 1 张验光单'); - - const apiKey = AppConfig.getDecryptedKey(); - if (!apiKey) return alert("配置错误:无法读取 API Key"); - - setLoading(true); - finalMarkdown = ""; - extractedData = null; - els.resultContent.innerHTML = ""; - - try { - const userContent = AppPrompts.generateUserPayload(imageList); - - const response = await fetch(AppConfig.API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: AppConfig.MODEL, - stream: true, - messages: [ - { role: "system", content: AppPrompts.systemMessage }, - { role: "user", content: userContent } - ] - }) - }); - - if (!response.ok) throw new Error(`API 请求失败: ${response.status}`); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - let hasStartedStreaming = false; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ') && line !== 'data: [DONE]') { - try { - const json = JSON.parse(line.substring(6)); - const content = json.choices[0].delta?.content || ""; - - if (content && !hasStartedStreaming) { - hasStartedStreaming = true; - // 数据回来的一瞬间,立即停止加载动画和文字 - if (els.loadingView) els.loadingView.classList.add('hidden'); - els.resultContent.classList.remove('hidden'); - if (statusInterval) clearInterval(statusInterval); - } - - finalMarkdown += content; - - // 实时解析 Markdown (过滤 JSON 块) - const displayMarkdown = finalMarkdown.replace(/```json[\s\S]*```/, ''); - els.resultContent.innerHTML = marked.parse(displayMarkdown); - - // 自动滚动到底部 - if (els.resultScroll) { - els.resultScroll.scrollTop = els.resultScroll.scrollHeight; - } - } catch (e) {} - } - } - } - - // 结束后提取 JSON 用于复制功能 - const jsonMatch = finalMarkdown.match(/```json\n([\s\S]*?)\n```/); - if (jsonMatch && jsonMatch[1]) { - try { - extractedData = JSON.parse(jsonMatch[1]); - } catch (e) {} - } - - els.copyBtn.disabled = false; - els.copyBtn.classList.remove('opacity-50', 'cursor-not-allowed'); - - } catch (err) { - els.resultContent.innerHTML = `
出错了: ${err.message}
`; - if (els.loadingView) els.loadingView.classList.add('hidden'); - els.resultContent.classList.remove('hidden'); - } finally { - setLoading(false); - } -}); \ No newline at end of file diff --git a/prompts.js b/prompts.js deleted file mode 100644 index 1e0123a..0000000 --- a/prompts.js +++ /dev/null @@ -1,103 +0,0 @@ -// prompts.js - -const AppPrompts = { - systemMessage: `你是一个专业的眼科验光师助手,服务对象是眼镜店客服人员。 - -1. **基本原则**: - - 使用简体中文。 - - 核心任务:精准提取配镜数据(S, C, A, PD)。 - - **全能模式**:你需要同时具备处理“电脑验光单”、“手写处方”、“光学十字图”的能力。 - -2. **【⚠️ 核心逻辑一:视觉符号与笔迹语义】(完整保留,严禁简化)** - 请仔细观察图像中的手写标记,它们决定了数据的取舍: - * **标记 A:下划线/圈选 (Row Selection)** —— **针对电脑验光单** - * **现象**:在打印数据列表(非 AVE 行)的**某一行数字下方**画了横线,或圈出了某一行。 - * **含义**:验光师认为该行数据最准,**强制放弃**底部的 AVE(平均值)。 - * **操作**:直接提取**被标记行**的 CYL (柱镜) 和 AX (轴位)。即使 AVE 行有数据,也以被标记的行为准。 - * **标记 B:删除线 (Strikethrough/Cancellation)** - * **现象**:线条直接**贯穿/覆盖**了数字(常见于散光/轴位)。 - * **操作**:该项数据归零(如散光变 0.00)。 - * **标记 C:手写数值 (Handwritten Override)** - * **现象**:旁边写了新的数字(如 -0.75)。 - * **操作**:手写数值优先级最高,直接覆盖打印数值。 - -3. **【⚠️ 核心逻辑二:光学十字换算】(完整保留,严禁简化)** - 若图像中出现手画的“十字交叉图”,请严格执行: - * **定球镜 (S)**:取十字线上两个数值中,代数较大(更偏正/更不负)的数值作为 S。 - * **定轴位 (A)**: - - 找到标有角度数值(如 90°, 180°)的那根线。 - - 若 S 在该线上,A = 该角度。 - - 若 S 不在该线上,A = 该角度 ± 90°。 - * **定柱镜 (C)**:C = (十字线上另一个数值) - S。 - -4. **【数据提取优先级序列】** - 请按以下顺序确定最终数值: - 1. **手写修正的具体数值** (最高) - 2. **被下划线/圈选的特定打印行** (次高) - 3. **打印的 AVE (平均值)** (普通) - 4. **光学十字推算值** (仅在有十字图时生效) - -5. **【⚠️ 智能诊断与客服话术生成】(功能升级)** - 在精准提取数据后,请检查是否命中以下规则。 - **执行要求**:若命中,请在输出结果的第三部分,直接生成一段**客服可以直接复制发送给客户的回复话术**。 - *(若同时命中多条,请将话术自然融合,不要机械分点)* - - * **场景 A:非标准步长安抚(数值不能被 0.25 整除,如 -0.12, -0.37)** - * **触发条件**:提取的 S 或 C 结尾不是 .00, .25, .50, .75。 - * **话术策略**:安抚客户不用担心“奇怪的数字”,解释这是电脑原始数据,承诺会按标准(0.25档)调整。 - * **话术示例**(仅供参考): - "亲,单子上显示的 -0.37 是电脑验光的原始参考值哈。我们配镜时会按照国际标准度数(比如 0.25 或 0.50),您看您这边有没有验光师的手写单子呢,这样配出来的眼镜佩戴舒适哦~" - - * **场景 B:低度数/防蓝光推荐(双眼度数 < 0.25)** - * **触发条件**:双眼 S 和 C 绝对值均 < 0.25(含 0.00, 0.12, 0.25, 0.37)。 - * **话术策略**:恭喜客户视力底子好 -> 建议不配度数 -> 强烈推荐 0 度防蓝光(保护视力)。 - * **话术示例**(仅供参考): - "亲,您的视力底子非常好!数据看都只是极其微小的生理波动(不到 25 度),这通常是不需要配度数的。 - 如果您平时看手机电脑多,特别推荐您配一副【0度防蓝光眼镜】,既能阻隔辐射保护这么好的视力,平时戴着也很好看呢!" - - * **场景 C:PD (瞳距) 缺失** - * **触发条件**:手写区无 PD 且全图未扫描到 PD。 - * **话术策略**:温柔追问数据。 - * **话术示例**:"亲,单子上没看到瞳距(PD)数据哦,您之前有测过吗?或者手边有旧眼镜我们可以帮您测一下~" - -6. **【输出结构】** - 请严格按照以下顺序输出: - - **第一部分:分析摘要**。简述数据来源(如:“依据下划线提取了第3行数据...”)。 - - **第二部分:Markdown 表格**。包含:眼别, 球镜(S), 柱镜(C), 轴位(A), ADD, 瞳高(PH), 瞳距(PD)。 - - **第三部分:建议客服回复的话术**。 - * **关键**:请在此处输出 Point 5 中生成的**针对性话术**。 - * **格式**:不要写“规则A触发”,直接写:“建议您这样回复客户:xxxxxxxx”。 - * **融合要求**:如果同时有【非标准步长】和【低度数】,请生成一段包含这两点意思的完整话术。 - - **第四部分:JSON 数据**(必须在最后)。 - -7. **【JSON 输出格式】** -\`\`\`json -{ - "right": { "s": "", "c": "", "a": "", "add": "", "ph": "", "pd": "" }, - "left": { "s": "", "c": "", "a": "", "add": "", "ph": "", "pd": "" }, - "total_pd": "" -} -\`\`\` -`, - - generateUserPayload: function(imageArray) { - return [ - { - type: "text", - text: `请解读这张验光单。 -**执行步骤**: -1. **精准提取**:严格执行【核心逻辑一】(视觉符号)和【核心逻辑二】(光学十字),确保数据绝对准确。 -2. **话术生成(重要)**: - - 如果遇到 -0.12/-0.37 这种数,请生成一段话术,让客服告诉客户“这是电脑原始值,镜片的国际标准是0.25一档,可以询问客户有没有手写验光单”。 - - 如果遇到低度数(< 0.25),请生成一段话术,让客服推荐“0度防蓝光眼镜”。 - - **最终输出**:请在第三部分直接给出一代**客服可以复制发给客户的文字**,语气要亲切、自然。 -3. **PD 检查**:寻找打印的瞳距值。 -请输出最终结果。` - }, - ...imageArray.map(imgBase64 => ({ - type: "image_url", - image_url: { url: imgBase64, detail: "high" } - })) - ]; - } -}; \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index 250dcd1..0a038fd 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -56,6 +56,7 @@ let isHistoryLoading = false; let currentGeneratedUrls = []; let currentMode = 'trial'; // 'trial' 或 'key' let uploadedFiles = []; // 存储当前待上传的参考图 +let isSetterActive = false; // 是否激活了拍摄角度设置器模式 function switchMode(mode) { currentMode = mode; @@ -180,6 +181,12 @@ async function init() { const closeHistoryBtn = document.getElementById('closeHistoryBtn'); const historyList = document.getElementById('historyList'); + // 3D 构图辅助控制 + const openVisualizerBtn = document.getElementById('openVisualizerBtn'); + const closeVisualizerBtn = document.getElementById('closeVisualizerBtn'); + if(openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal; + if(closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal; + if(showHistoryBtn) { showHistoryBtn.onclick = () => { historyDrawer.classList.remove('translate-x-full'); @@ -339,14 +346,22 @@ function handleNewFiles(files) { const newFiles = Array.from(files).filter(f => f.type.startsWith('image/')); if (newFiles.length === 0) return; - if (uploadedFiles.length + newFiles.length > 3) { - showToast('最多只能上传 3 张参考图', 'warning'); - const remaining = 3 - uploadedFiles.length; - if (remaining > 0) { - uploadedFiles = uploadedFiles.concat(newFiles.slice(0, remaining)); + // 如果处于设置器模式,严格限制为 1 张 + if (isSetterActive) { + if (newFiles.length > 0) { + uploadedFiles = [newFiles[0]]; + showToast('设置器模式已开启,仅保留第一张参考图', 'info'); } } else { - uploadedFiles = uploadedFiles.concat(newFiles); + if (uploadedFiles.length + newFiles.length > 3) { + showToast('最多只能上传 3 张参考图', 'warning'); + const remaining = 3 - uploadedFiles.length; + if (remaining > 0) { + uploadedFiles = uploadedFiles.concat(newFiles.slice(0, remaining)); + } + } else { + uploadedFiles = uploadedFiles.concat(newFiles); + } } renderImagePreviews(); } @@ -448,16 +463,18 @@ document.getElementById('submitBtn').onclick = async () => { currentGeneratedUrls = []; // 重置当前生成列表 try { - let image_urls = []; + let image_data = []; - // 1. 如果有图则先上传 + // 1. 将图片转换为 Base64 if (uploadedFiles.length > 0) { - const uploadData = new FormData(); - for(let f of uploadedFiles) uploadData.append('images', f); - const upR = await fetch('/api/upload', { method: 'POST', body: uploadData }); - const upRes = await upR.json(); - if(upRes.error) throw new Error(upRes.error); - image_urls = upRes.urls; + btnText.innerText = "正在准备图片数据..."; + const readFileAsBase64 = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + image_data = await Promise.all(uploadedFiles.map(f => readFileAsBase64(f))); } // 2. 并行启动多个生成任务 @@ -467,10 +484,11 @@ document.getElementById('submitBtn').onclick = async () => { const startTask = async (index) => { const slot = document.createElement('div'); slot.className = 'image-frame relative bg-white/50 animate-pulse min-h-[200px] flex items-center justify-center rounded-[2.5rem] border border-slate-100 shadow-sm'; - slot.innerHTML = `
AI 构思中...
`; + slot.innerHTML = `
正在排队中...
`; grid.appendChild(slot); try { + // 1. 发起生成请求,获取任务 ID const r = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -482,17 +500,64 @@ document.getElementById('submitBtn').onclick = async () => { model: document.getElementById('modelSelect').value, ratio: document.getElementById('ratioSelect').value, size: document.getElementById('sizeSelect').value, - image_urls + image_data // 发送 Base64 数组 }) }); const res = await r.json(); if(res.error) throw new Error(res.error); - if(res.message) showToast(res.message, 'success'); + // 如果直接返回了 data (比如聊天模型),直接显示 + if (res.data) { + displayResult(slot, res.data[0]); + return; + } - const imgUrl = res.data[0].url; - currentGeneratedUrls.push(imgUrl); - + // 2. 轮询任务状态 + const taskId = res.task_id; + let pollCount = 0; + const maxPolls = 500; // 最多轮询约 16 分钟 (2s * 500 = 1000s) + + while (pollCount < maxPolls) { + await new Promise(resolve => setTimeout(resolve, 2000)); + pollCount++; + + const statusR = await fetch(`/api/task_status/${taskId}`); + const statusRes = await statusR.json(); + + if (statusRes.status === 'complete') { + const imgUrl = statusRes.urls[0]; + currentGeneratedUrls.push(imgUrl); + displayResult(slot, { url: imgUrl }); + finishedCount++; + btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`; + if(currentMode === 'trial') checkAuth(); + return; // 任务正常结束 + } else if (statusRes.status === 'error') { + throw new Error(statusRes.message || "生成失败"); + } else { + // 继续轮询状态显示 + slot.innerHTML = `
AI 正在努力创作中 (${pollCount * 2}s)...
`; + } + } + throw new Error("生成超时,请稍后在历史记录中查看"); + + } catch (e) { + slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6'; + if(e.message.includes('401') || e.message.includes('请先登录')) { + slot.innerHTML = `
登录已过期,请重新登录
`; + } else { + slot.innerHTML = `
生成异常: ${e.message}
`; + } + } + }; + + // 提取结果展示逻辑 + const displayResult = (slot, data) => { + if (data.type === 'text') { + slot.className = 'image-frame relative bg-white border border-slate-100 p-8 rounded-[2.5rem] shadow-xl overflow-y-auto max-h-[600px]'; + slot.innerHTML = `
${data.content.replace(/\n/g, '
')}
`; + } else { + const imgUrl = data.url; slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700 flex flex-col items-center justify-center overflow-hidden bg-white shadow-2xl transition-all hover:shadow-indigo-100/50'; slot.innerHTML = `
@@ -504,20 +569,8 @@ document.getElementById('submitBtn').onclick = async () => {
`; - lucide.createIcons(); - } catch (e) { - slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6'; - if(e.message.includes('401') || e.message.includes('请先登录')) { - slot.innerHTML = `
登录已过期,请重新登录
`; - } else { - slot.innerHTML = `
渲染异常: ${e.message}
`; - } - } finally { - finishedCount++; - btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`; - // 每次生成任务结束后,刷新一次积分显示 - if(currentMode === 'trial') checkAuth(); } + lucide.createIcons(); }; const tasks = Array.from({ length: num }, (_, i) => startTask(i)); @@ -585,3 +638,79 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => { showToast('网络连接失败', 'error'); } }); + +// 拍摄角度设置器弹窗控制 +function openVisualizerModal() { + const modal = document.getElementById('visualizerModal'); + if(!modal) return; + + // 检查是否已上传参考图 + if (uploadedFiles.length === 0) { + return showToast('请先上传一张参考图作为基准', 'warning'); + } + + modal.classList.remove('hidden'); + setTimeout(() => { + modal.classList.add('opacity-100'); + modal.querySelector('div').classList.remove('scale-95'); + + // 将主页面的图片同步到设置器 iframe + const reader = new FileReader(); + reader.onload = (e) => { + const iframe = document.getElementById('visualizerFrame'); + iframe.contentWindow.postMessage({ type: 'sync_image', dataUrl: e.target.result }, '*'); + }; + reader.readAsDataURL(uploadedFiles[0]); + }, 10); +} + +function closeVisualizerModal() { + const modal = document.getElementById('visualizerModal'); + if(!modal) return; + modal.classList.remove('opacity-100'); + modal.querySelector('div').classList.add('scale-95'); + setTimeout(() => { + modal.classList.add('hidden'); + }, 300); +} + +// 监听来自设置器的消息 +window.addEventListener('message', (e) => { + if (e.data.type === 'apply_prompt') { + isSetterActive = true; + + const area = document.getElementById('manualPrompt'); + const promptTpl = document.getElementById('promptTpl'); + const modelSelect = document.getElementById('modelSelect'); + const sizeGroup = document.getElementById('sizeGroup'); + + // 1. 强制切换并锁定版本 2.0 (nano-banana-2) + if (modelSelect) { + modelSelect.value = 'nano-banana-2'; + modelSelect.disabled = true; // 锁定选择 + sizeGroup.classList.remove('hidden'); + updateCostPreview(); + } + + // 2. 隐藏模板选择器 + if (promptTpl) { + promptTpl.classList.add('hidden'); + promptTpl.value = 'manual'; + } + + // 3. 强制图片数量为 1 + if (uploadedFiles.length > 1) { + uploadedFiles = uploadedFiles.slice(0, 1); + renderImagePreviews(); + } + + // 4. 替换提示词 + if (area) { + area.value = e.data.prompt; + area.classList.remove('hidden'); + showToast('已同步拍摄角度并切换至 2.0 引擎', 'success'); + } + + closeVisualizerModal(); + } +}); diff --git a/templates/index.html b/templates/index.html index f7c2b35..be9a345 100644 --- a/templates/index.html +++ b/templates/index.html @@ -51,9 +51,15 @@
-
- 02 -

渲染设定

+
+
+ 02 +

渲染设定

+
+
@@ -79,7 +85,7 @@ - +
@@ -226,6 +232,29 @@ + + + {% endblock %} {% block scripts %} diff --git a/templates/kongzhiqi.html b/templates/kongzhiqi.html new file mode 100644 index 0000000..744f460 --- /dev/null +++ b/templates/kongzhiqi.html @@ -0,0 +1,460 @@ + + + + + + AI 3D Camera Visualizer + + + + + +
+
+ + +
+ +
+
准备就绪
+
+ +
+ +
+
+ Azimuth (水平) + +
+ +
+ 正前右侧正后左侧 +
+
+ + +
+
+ Elevation (高度) + +
+ +
+ 仰拍平视俯拍 +
+
+ + +
+
+ Distance (距离) + 1.0 +
+ +
+ 特写标准远景 +
+
+ + +
+ +
+
...
+
+
+ + + + + \ No newline at end of file