<Project-27 x.AI> docker web 应用, python 使用 API 调用 GROK2 的两个模型 ( grok-beta 与 grok-vision-beta) 实践

目的:

x.ai 注册可以得到 $25 账户充值, 用了它。

做个 WEB 应用去访问 GROK2 模型:

        使用模型:grok-vision-beta
                识别图片:拖拽上传, 粘贴 URL

        使用模型:grok-beta
                文字对话

从注册开始

网站: https://x.ai/api

API KEY 获得: 下图钥匙图标,按1234就可以生成

x.ai 不需要预存 KEY, 回来还能找到:

指导文档

链接1:https://docs.x.ai/docs

链接2: https://docs.x.ai/api/integrations

主要信息:

应用程序

1. 程序界面

2. 界面概述

上半部是使用 grok-vision-beta 模型:

  • 可以上传图片,或加入 URL 为什么来源(只测试了 Mid Journey)。
  • URL 提交后,会加载图片到预览中显示。
  • 分析按键上有5个预设的提示词。
  • 结果返回是英文与中文(对沾艺术的单词,实在基础为零,为了便于阅读),相当于询问两次,模型回复两次,即:两倍的费用。

下半部是使用 grok-beta 模型:

  •         只是简单的提问与返回

3. 功能

  • 1. 粘贴 URL后,会预览图片。先转换为 base64 格式,再将图片数据连同提示词一起发送到服务器。
  • 2. 双语:在点击分析按钮后,先发送带 language='en' 的请求获取英文结果,再后发送带 language='zh' 的请求获取中文结果,两个结果分别显示在各自的区域。
  • 对上传的图片有压缩处理,为了省钱。
  • 左上角有清除按钮,跟随翻页。 

目录结构

27.xAI/
│
├── app.py           # Flask 主程序
├── static/           
│   ├── style.css    # CSS 样式文件
│   ├── script.js    # JavaScript 前端代码    
│   └── Favicon.jpg
├── templates/        
│   └── index.html      # 主页
├── cert.pem            # SSL证书文件
├── key.pem             # SSL私钥文件
└── config.ini          # API密钥

完整代码

1. app.py

from flask import Flask, request, jsonify, render_template
from openai import OpenAI
import configparser
from functools import wraps
import os
import time
import base64
from urllib.parse import urlparse
import ssl
import requests
from io import BytesIO
import base64
from urllib3.exceptions import InsecureRequestWarning
from werkzeug.serving import WSGIRequestHandler

app = Flask(__name__)

# 常量定义
TIMEOUT_SECONDS = 10
MAX_IMAGE_SIZE = 10 * 1024 * 1024  # 10MB
SUPPORTED_IMAGE_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
CERT_FILE = "cert.pem"
KEY_FILE = "key.pem"

def check_ssl_files():
    """检查SSL证书文件是否存在"""
    cert_path = os.path.join(os.path.dirname(__file__), CERT_FILE)
    key_path = os.path.join(os.path.dirname(__file__), KEY_FILE)
    
    if not os.path.exists(cert_path):
        raise FileNotFoundError(f"未找到SSL证书文件 {CERT_FILE}")
    if not os.path.exists(key_path):
        raise FileNotFoundError(f"未找到SSL密钥文件 {KEY_FILE}")
        
    return cert_path, key_path

def load_config():
    config = configparser.ConfigParser()
    config_path = os.path.join(os.path.dirname(__file__), 'config.ini')

    if not os.path.exists(config_path):
        raise FileNotFoundError("未找到 config.ini 文件")

    config.read(config_path)

    if 'XAI' not in config or 'api_key' not in config['XAI']:
        raise KeyError("缺少API密钥配置")

    return config['XAI']['api_key']

def verify_api_key(client):
    try:
        response = client.chat.completions.create(
            model="grok-beta",
            messages=[{"role": "user", "content": "test"}],
            max_tokens=1,
            timeout=TIMEOUT_SECONDS
        )
        return True
    except Exception as e:
        print(f"API验证错误: {str(e)}")
        return False

def validate_image_url(url):
    try:
        parsed = urlparse(url)
        return bool(parsed.scheme and parsed.netloc)
    except:
        return False
    
# SSL Configuration
def create_ssl_context():
    """创建SSL上下文"""
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    
    # 设置SSL协议版本
    context.minimum_version = ssl.TLSVersion.TLSv1_2
    context.maximum_version = ssl.TLSVersion.TLSv1_3
    
    # 设置加密套件
    context.set_ciphers('ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256')
    
    # 加载证书
    cert_path = os.path.join(os.path.dirname(__file__), "cert.pem")
    key_path = os.path.join(os.path.dirname(__file__), "key.pem")
    
    if not os.path.exists(cert_path) or not os.path.exists(key_path):
        raise FileNotFoundError("SSL certificates not found")
        
    context.load_cert_chain(cert_path, key_path)
    
    return context

# 配置Werkzeug处理程序
class CustomRequestHandler(WSGIRequestHandler):
    protocol_version = "HTTP/1.1"
    
def fetch_and_convert_image(url):
    """获取图片并转换为base64格式"""
    try:
        # 添加用户代理头,模拟浏览器请求
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
        }
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        
        # 获取图片内容
        image_data = response.content
        
        # 转换为base64
        base64_data = base64.b64encode(image_data).decode('utf-8')
        
        # 根据URL确定图片类型
        image_type = url.split('.')[-1].lower()
        if image_type not in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
            image_type = 'png'  # 默认使用png
            
        return f"data:image/{image_type};base64,{base64_data}"
    except Exception as e:
        raise Exception(f"无法获取图片: {str(e)}")

def validate_base64_image(base64_string):
    try:
        # 检查图片大小是否在限制范围内
        image_size = len(base64.b64decode(base64_string))
        if image_size > MAX_IMAGE_SIZE:
            return False, "图片大小超过限制(10MB)"
        return True, None
    except:
        return False, "无效的base64图片数据"

def handle_api_error(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except Exception as e:
            error_message = str(e)
            status_code = 500
            
            # 定义双语错误消息
            error_messages = {
                'Invalid API key': {
                    'en': "Invalid API key. Please check your API key in config.ini",
                    'zh': "API密钥无效。请检查配置文件中的API密钥。"
                },
                'Insufficient credits': {
                    'en': "Insufficient credits. Please check your billing status.",
                    'zh': "额度不足。请检查您的账户余额。"
                },
                'Rate limit': {
                    'en': "Rate limit exceeded. Please try again later.",
                    'zh': "请求频率超限。请稍后再试。"
                },
                'Connection timeout': {
                    'en': "Connection timeout. Please try again.",
                    'zh': "连接超时。请重试。"
                },
                'Connection error': {
                    'en': "Unable to connect to the server. Please check your connection.",
                    'zh': "无法连接到服务器。请检查网络连接。"
                },
                'Forbidden': {
                    'en': "Unable to access this image. Please check if the image URL is publicly accessible.",
                    'zh': "无法访问此图片。请检查图片URL是否可公开访问。"
                },
                'HTTP status 403': {
                    'en': "Access to this image is forbidden. Please try a different image or URL.",
                    'zh': "无法访问此图片。请尝试使用其他图片或URL。"
                },
                'Unrecoverable data': {
                    'en': "Unable to process the image. Please try a different image.",
                    'zh': "无法处理此图片。请尝试使用其他图片。"
                }
            }
            
            # 获取适当的错误消息
            message_en = "An unexpected error occurred. Please try again."
            message_zh = "发生未知错误。请重试。"
            
            for key, messages in error_messages.items():
                if key in error_message:
                    message_en = messages['en']
                    message_zh = messages['zh']
                    break
            
            # 设置适当的状态码
            if "Invalid API key" in error_message:
                status_code = 401
            elif "Insufficient credits" in error_message:
                status_code = 402
            elif "Rate limit" in error_message:
                status_code = 429
            elif "Forbidden" in error_message or "HTTP status 403" in error_message:
                status_code = 403
            elif "Connection timeout" in error_message:
                status_code = 504
                
            return jsonify({
                'success': False,
                'error': {
                    'en': message_en,
                    'zh': message_zh,
                    'code': status_code,
                    'original': str(e)
                }
            }), status_code
            
    return decorated_function

# 初始化API客户端
try:
    api_key = load_config()
    client = OpenAI(
        api_key=api_key,
        base_url="https://api.x.ai/v1",
        timeout=TIMEOUT_SECONDS
    )
    
    # 初始化后立即验证API密钥
    if not verify_api_key(client):
        raise ValueError(
            "config.ini中的xAI API密钥无效。请检查您的API密钥: "
            "https://console.x.ai/api-keys"
        )
except Exception as e:
    print(f"配置加载错误: {str(e)}")
    raise


@app.route('/')
def home():
    return render_template('index.html')

@app.route('/proxy-image', methods=['POST'])
@handle_api_error
def proxy_image():
    """代理获取图片并转换为base64"""
    try:
        data = request.json
        image_url = data.get('url')
        
        if not image_url:
            print("错误:未提供图片URL")
            return jsonify({
                'success': False,
                'error': {
                    'en': 'No image URL provided',
                    'zh': '未提供图片URL',
                    'code': 400
                }
            }), 400

        print(f"正在获取图片: {image_url}")  # 调试日志

        # 添加用户代理头,模拟浏览器请求
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
            'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
            'Accept-Encoding': 'gzip, deflate, br',
            'Connection': 'keep-alive'
        }
        
        try:
            # 获取图片,禁用 SSL 验证
            response = requests.get(
                image_url, 
                headers=headers, 
                timeout=30,
                verify=False  # 禁用 SSL 验证
            )
            response.raise_for_status()
            
            print(f"图片获取成功,状态码: {response.status_code}")  # 调试日志
            print(f"内容类型: {response.headers.get('content-type')}")  # 调试日志
            
        except requests.exceptions.RequestException as e:
            print(f"请求错误: {str(e)}")  # 调试日志
            return jsonify({
                'success': False,
                'error': {
                    'en': f'Failed to fetch image: {str(e)}',
                    'zh': f'获取图片失败: {str(e)}',
                    'code': 500
                }
            }), 500
        
        # 获取图片内容类型
        content_type = response.headers.get('content-type', 'image/jpeg')
        
        # 转换为base64
        try:
            image_data = response.content
            base64_data = base64.b64encode(image_data).decode('utf-8')
            base64_image = f"data:{content_type};base64,{base64_data}"
            
            print("图片成功转换为base64")  # 调试日志
            
            return jsonify({
                'success': True,
                'base64Image': base64_image
            })
            
        except Exception as e:
            print(f"base64转换错误: {str(e)}")  # 调试日志
            return jsonify({
                'success': False,
                'error': {
                    'en': f'Failed to convert image: {str(e)}',
                    'zh': f'图片转换失败: {str(e)}',
                    'code': 500
                }
            }), 500
            
    except Exception as e:
        print(f"代理图片错误: {str(e)}")  # 调试日志
        import traceback
        print(f"错误堆栈: {traceback.format_exc()}")  # 打印完整错误堆栈
        return jsonify({
            'success': False,
            'error': {
                'en': f'Failed to process image: {str(e)}',
                'zh': f'图片处理失败: {str(e)}',
                'code': 500
            }
        }), 500

@app.route('/analyze', methods=['POST'])
@handle_api_error
def analyze_image():
    try:
        data = request.json
        prompt = data.get('prompt', '这张图片里有什么?')
        language = data.get('language', 'en')
        image_data = data.get('image')

        print(f"收到分析请求 - 语言: {language}")  # 调试日志

        if not image_data:
            print("错误:未提供图片数据")  # 调试日志
            return jsonify({
                'success': False,
                'error': {
                    'en': 'No image data provided',
                    'zh': '未提供图片数据',
                    'code': 400
                }
            }), 400

        # 打印图片数据的前100个字符(用于调试)
        print(f"图片数据预览: {image_data[:100]}...")  # 调试日志

        # 验证提示词
        if not prompt or len(prompt.strip()) == 0:
            print("错误:提示词为空")  # 调试日志
            return jsonify({
                'success': False,
                'error': {
                    'en': 'Prompt cannot be empty',
                    'zh': '提示词不能为空',
                    'code': 400
                }
            }), 400

        print(f"使用的提示词: {prompt}")  # 调试日志

        # 语言特定的系统消息
        system_messages = {
            'zh': "请用中文回答以下问题。",
            'en': "Please answer in English."
        }
        system_message = system_messages.get(language, system_messages['en'])

        messages = [
            {
                "role": "system",
                "content": system_message
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": image_data,
                            "detail": "high",
                        },
                    },
                    {
                        "type": "text",
                        "text": prompt,
                    },
                ],
            },
        ]

        print("准备调用API...")  # 调试日志

        try:
            # 调用API
            response = client.chat.completions.create(
                model="grok-vision-beta",
                messages=messages,
                temperature=0.01,
                timeout=TIMEOUT_SECONDS
            )
            
            print("API调用成功")  # 调试日志
            return jsonify({
                'success': True,
                'result': response.choices[0].message.content
            })
        except Exception as api_error:
            print(f"API调用错误: {str(api_error)}")  # 调试日志
            # 记录详细的API错误信息
            print(f"API错误详情: {repr(api_error)}")
            raise api_error

    except Exception as e:
        print(f"处理错误: {str(e)}")  # 调试日志
        print(f"错误类型: {type(e)}")  # 调试日志
        print(f"错误详情: {repr(e)}")  # 调试日志
        import traceback
        print(f"错误堆栈: {traceback.format_exc()}")  # 打印完整的错误堆栈
        raise

@app.route('/chat', methods=['POST'])
@handle_api_error
def chat():
    """聊天端点使用x.ai"""
    messages = request.json.get('messages', [])
    
    if not messages:
        return jsonify({
            'success': False,
            'error': {
                'en': 'No messages provided',
                'zh': '未提供对话消息',
                'code': 400
            }
        }), 400

    response = client.chat.completions.create(
        model="grok-beta",
        messages=messages,
        temperature=0.7,
        timeout=TIMEOUT_SECONDS
    )

    return jsonify({
        'success': True,
        'result': response.choices[0].message.content
    })


if __name__ == '__main__':
    try:
        # 创建SSL上下文
        ssl_context = create_ssl_context()
        
        # 配置Flask
        app.config.update(
            SESSION_COOKIE_SECURE=True,
            REMEMBER_COOKIE_SECURE=True,
            SESSION_COOKIE_HTTPONLY=True,
            REMEMBER_COOKIE_HTTPONLY=True
        )
        
        # 启动服务器
        print("Server starting with SSL configuration...")
        app.run(
            host='0.0.0.0',  # 允许外部访问
            port=9011,
            ssl_context=ssl_context,
            request_handler=CustomRequestHandler,
            debug=True,
            use_reloader=True,
            threaded=True
        )
    except Exception as e:
        print(f"Server startup error: {str(e)}")
        raise

2. script.js

document.addEventListener('DOMContentLoaded', function() {
    // --- 状态管理 --- //
    let chatHistory = [];

    // 图片配置
    const imageConfig = {
        maxWidth: 1600,
        maxHeight: 1600,
        maxSizeMB: 1,
        quality: 0.9
    };

    // --- DOM 元素 --- //
    function getElement(id) {
        const element = document.getElementById(id);
        if (!element) {
            console.warn(`未找到ID为'${id}'的元素`);
        }
        return element;
    }

    const elements = {
        // 图像分析元素
        imageUpload: getElement('imageUpload'),
        imageUrl: getElement('imageUrl'),
        dropZone: getElement('dropZone'),
        preview: getElement('preview'),
        compressionInfo: getElement('compressionInfo'),
        prompt: getElement('prompt'),
        analyzeImage: getElement('analyzeImage'),
        result: getElement('result'),
        resultChinese: getElement('resultChinese'),
        
        // 聊天元素
        chatMessages: getElement('chatMessages'),
        chatInput: getElement('chatInput'),
        sendMessage: getElement('sendMessage'),
        
        // 全局元素
        clearAll: getElement('clearAll'),
        pasteUrl: getElement('pasteUrl'),
        loadingOverlay: getElement('loadingOverlay')
    };

    // --- 初始化 --- //
    function initialize() {
        validateRequiredElements();
        setupEventListeners();
    }

    function validateRequiredElements() {
        const requiredElements = [
            'imageUpload', 'imageUrl', 'dropZone', 'preview', 'compressionInfo', 
            'prompt', 'analyzeImage', 'result', 'resultChinese', 'chatMessages',
            'chatInput', 'sendMessage', 'clearAll', 'pasteUrl', 'loadingOverlay'
        ];

        const missingElements = requiredElements.filter(id => !elements[id]);
        if (missingElements.length > 0) {
            console.error('缺少必需的元素:', missingElements);
            throw new Error('缺少必需的元素');
        }
    }

    // --- 事件监听器 --- //
    function setupEventListeners() {
        // 图片上传监听器
        if (elements.dropZone) {
            ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
                elements.dropZone.addEventListener(eventName, preventDefaults);
            });

            ['dragenter', 'dragover'].forEach(eventName => {
                elements.dropZone.addEventListener(eventName, highlight);
            });

            ['dragleave', 'drop'].forEach(eventName => {
                elements.dropZone.addEventListener(eventName, unhighlight);
            });

            elements.dropZone.addEventListener('drop', handleDrop);
            elements.dropZone.addEventListener('click', () => elements.imageUpload.click());
        }

        // 图像分析监听器
        elements.imageUpload?.addEventListener('change', handleFileSelect);
        elements.imageUrl?.addEventListener('input', handleUrlInput);
        elements.analyzeImage?.addEventListener('click', handleVisionAnalysis);
        elements.pasteUrl?.addEventListener('click', handlePaste);

        // 聊天监听器
        elements.sendMessage?.addEventListener('click', handleSendMessage);
        elements.chatInput?.addEventListener('keypress', (e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                handleSendMessage();
            }
        });

        // 全局监听器
        elements.clearAll?.addEventListener('click', handleClearAll);
        elements.imageUrl?.addEventListener('keydown', handlePasteShortcut);

        // 提示建议
        document.querySelectorAll('.prompt-suggestion').forEach(button => {
            button.addEventListener('click', () => {
                if (elements.prompt) {
                    elements.prompt.value = button.textContent.trim();
                    elements.prompt.focus();
                }
            });
        });
    }

    // --- 事件处理器 --- //
    function preventDefaults(e) {
        e.preventDefault();
        e.stopPropagation();
    }

    function highlight() {
        elements.dropZone?.classList.add('dragover');
    }

    function unhighlight() {
        elements.dropZone?.classList.remove('dragover');
    }

    // --- 图像处理 --- //
    async function handleDrop(e) {
        unhighlight();
        const file = e.dataTransfer.files[0];
        if (file && file.type.startsWith('image/')) {
            await handleImage(file);
        } else {
            showError({
                error: {
                    en: 'Please drop an image file',
                    zh: '请拖放图片文件'
                }
            });
        }
    }

    async function handleFileSelect(e) {
        const file = e.target.files[0];
        if (file) {
            await handleImage(file);
        }
    }

    async function handleImage(file) {
        if (!file.type.startsWith('image/')) {
            showError({
                error: {
                    en: 'Please select an image file',
                    zh: '请选择图片文件'
                }
            });
            return;
        }

        try {
            const compressedImage = await compressImage(file);
            if (elements.preview) {
                elements.preview.src = compressedImage;
            }
            if (elements.imageUrl) {
                elements.imageUrl.value = '';
            }
        } catch (error) {
            showError({
                error: {
                    en: 'Error processing image',
                    zh: '图片处理错误'
                }
            });
            console.error('图片处理错误:', error);
        }
    }

    // 处理URL输入
    function handleUrlInput(e) {
        const url = e.target.value.trim();
        if (url && elements.preview) {
            elements.preview.src = url;
            elements.preview.onerror = () => {
                showError({
                    error: {
                        en: 'Invalid image URL or image not accessible',
                        zh: '图片URL无效或无法访问'
                    }
                });
                elements.preview.src = '';
            };
            elements.preview.onload = () => {
                if (elements.imageUpload) {
                    elements.imageUpload.value = '';
                }
                if (elements.compressionInfo) {
                    elements.compressionInfo.textContent = '使用URL图片';
                }
            };
        }
    }

    // --- 图像压缩 --- //
    async function compressImage(file) {
        return new Promise((resolve) => {
            const reader = new FileReader();
            reader.readAsDataURL(file);
            reader.onload = function(e) {
                const img = new Image();
                img.src = e.target.result;
                img.onload = function() {
                    const canvas = document.createElement('canvas');
                    let { width, height } = calculateDimensions(img.width, img.height);
                    
                    canvas.width = width;
                    canvas.height = height;
                    
                    const ctx = canvas.getContext('2d');
                    ctx.drawImage(img, 0, 0, width, height);
                    
                    let quality = imageConfig.quality;
                    let compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
                    
                    while (compressedDataUrl.length > imageConfig.maxSizeMB * 1024 * 1024 && quality > 0.1) {
                        quality -= 0.1;
                        compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
                    }
                    
                    updateCompressionInfo(file.size, compressedDataUrl.length);
                    resolve(compressedDataUrl);
                };
            };
        });
    }

    // 计算图像尺寸
    function calculateDimensions(width, height) {
        if (width > imageConfig.maxWidth) {
            height = height * (imageConfig.maxWidth / width);
            width = imageConfig.maxWidth;
        }
        if (height > imageConfig.maxHeight) {
            width = width * (imageConfig.maxHeight / height);
            height = imageConfig.maxHeight;
        }
        return { width, height };
    }

    // 更新压缩信息
    function updateCompressionInfo(originalSize, compressedSize) {
        if (elements.compressionInfo) {
            const originalMB = (originalSize / (1024 * 1024)).toFixed(2);
            const compressedMB = (compressedSize / (1024 * 1024)).toFixed(2);
            elements.compressionInfo.textContent = 
                `原始大小: ${originalMB}MB → 压缩后: ${compressedMB}MB`;
        }
    }

    // --- 图像分析 --- //
    async function handleVisionAnalysis(e) {
        e.preventDefault();
        
        if (!validateInputs()) return;
    
        try {
            setLoading(true, 'analyze');
            
            const englishResponse = await analyzeImage('en');
            if (englishResponse.success) {
                showResult(englishResponse.result, 'en');
                
                const chineseResponse = await analyzeImage('zh');
                if (chineseResponse.success) {
                    showResult(chineseResponse.result, 'zh');
                } else {
                    console.error('Chinese analysis error:', chineseResponse);  // 添加错误日志
                    showError(chineseResponse.error);
                }
            } else {
                console.error('English analysis error:', englishResponse);  // 添加错误日志
                showError(englishResponse.error);
            }
        } catch (error) {
            console.error('Vision analysis error:', error);  // 添加错误日志
            showError({
                error: {
                    en: 'Error analyzing image',
                    zh: '图片分析错误'
                }
            });
        } finally {
            setLoading(false, 'analyze');
        }
    }

    async function getImageData(url) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.crossOrigin = 'anonymous';  // 尝试允许跨域访问
            
            img.onload = function() {
                try {
                    const canvas = document.createElement('canvas');
                    canvas.width = img.naturalWidth;
                    canvas.height = img.naturalHeight;
                    
                    const ctx = canvas.getContext('2d');
                    ctx.drawImage(img, 0, 0);
                    
                    // 尝试获取base64数据
                    const base64Data = canvas.toDataURL('image/png');
                    resolve(base64Data);
                } catch (err) {
                    reject(new Error('无法处理图片数据,请尝试下载后重新上传'));
                }
            };
            
            img.onerror = function() {
                // 如果直接访问失败,提供用户友好的提示
                reject(new Error('无法直接访问图片。请点击"下载图片"按钮,然后重新上传。'));
            };
            
            img.src = url;
        });
    }
    // 分析图像
async function analyzeImage(language = 'en') {
    try {
        if (!elements.preview?.src) {
            throw new Error('无可用的图像数据');
        }

        let imageData = elements.preview.src;
        
        // 如果是远程URL图片
        if (!imageData.startsWith('data:image')) {
            try {
                imageData = await getImageData(elements.preview.src);
            } catch (err) {
                // 显示用户友好的提示和下载按钮
                showDownloadPrompt(elements.preview.src);
                throw err;
            }
        }

        const requestData = {
            type: 'upload',
            image: imageData,
            url: null,
            prompt: elements.prompt?.value,
            language
        };

        const response = await fetch('/analyze', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(requestData)
        });

        if (!response.ok) {
            const errorData = await response.json();
            throw errorData;
        }

        return await response.json();

    } catch (error) {
        console.error('图片分析详细错误:', error);
        throw {
            error: {
                en: `Error processing image: ${error.message || 'Unknown error'}`,
                zh: `图片处理错误: ${error.message || '未知错误'}`
            }
        };
    }
}

    // --- 聊天功能 --- //
    async function handleSendMessage() {
        if (!elements.chatInput?.value.trim()) {
            showError({
                error: {
                    en: 'Please enter a message',
                    zh: '请输入消息'
                }
            }, 'chat');
            return;
        }
    
        const userMessage = elements.chatInput.value.trim();
        elements.chatInput.value = '';
    
        appendChatMessage('user', userMessage);
        chatHistory.push({ role: 'user', content: userMessage });
    
        try {
            setLoading(true, 'chat');
            const response = await sendChatMessage(chatHistory);
            
            if (response.success) {
                appendChatMessage('assistant', response.result);
                chatHistory.push({ role: 'assistant', content: response.result });
            } else {
                showChatError(response.error);
            }
        } catch (error) {
            showChatError(error.error || {
                en: 'Failed to send message',
                zh: '发送消息失败'
            });
        } finally {
            setLoading(false, 'chat');
        }
    }

    // 添加错误显示功能
    function showChatError(error) {
        const errorMessage = `错误: ${error.en}\n错误:${error.zh}`;
        appendChatMessage('error', errorMessage);
    }

    // 发送聊天消息
    async function sendChatMessage(messages) {
        try {
            const maxRetries = 3;
            let retryCount = 0;
            let lastError = null;
    
            while (retryCount < maxRetries) {
                try {
                    const response = await fetch('/chat', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({ messages }),
                        // 添加fetch选项以处理SSL
                        credentials: 'same-origin',
                        mode: 'same-origin'
                    });
    
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
    
                    return await response.json();
                } catch (error) {
                    lastError = error;
                    retryCount++;
                    
                    if (retryCount < maxRetries) {
                        // 等待一段时间后重试
                        await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
                        console.log(`重试发送消息... (${retryCount}/${maxRetries})`);
                    }
                }
            }
    
            throw new Error(`连接失败 (${maxRetries} 次尝试): ${lastError.message}`);
        } catch (error) {
            console.error('Chat error:', error);
            throw {
                error: {
                    en: 'Network error occurred. Please check your connection and try again.',
                    zh: '网络连接错误。请检查网络连接后重试。'
                }
            };
        }
    }

// 添加显示下载提示的函数
function showDownloadPrompt(imageUrl) {
    const promptDiv = document.createElement('div');
    promptDiv.className = 'download-prompt';
    promptDiv.innerHTML = `
        <div class="error fade-in">
            <p>由于图片访问限制,请先下载图片后再上传:</p>
            <button onclick="downloadImage('${imageUrl}')" class="button-primary mt-2">
                下载图片
            </button>
        </div>
    `;
    
    // 插入到结果区域
    if (elements.result) {
        elements.result.innerHTML = '';
        elements.result.appendChild(promptDiv);
    }
}

// 添加下载图片的函数
function downloadImage(url) {
    const a = document.createElement('a');
    a.href = url;
    a.download = 'image.png';  // 设置下载的文件名
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
}

    // 添加聊天消息到界面
    function appendChatMessage(role, content) {
        if (!elements.chatMessages) return;
    
        const messageDiv = document.createElement('div');
        messageDiv.className = `chat-message ${role}-message`;
        
        const contentP = document.createElement('p');
        contentP.textContent = content;
        messageDiv.appendChild(contentP);
        
        elements.chatMessages.appendChild(messageDiv);
        elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
    }

    // --- 剪贴板处理 --- //
    async function handlePaste() {
        try {
            const text = await navigator.clipboard.readText();
            if (text && elements.imageUrl) {
                elements.imageUrl.value = text;
                const event = new Event('input');
                elements.imageUrl.dispatchEvent(event);
            }
        } catch (err) {
            showError({
                error: {
                    en: 'Unable to paste from clipboard. Please paste manually or check browser permissions.',
                    zh: '无法从剪贴板粘贴。请手动粘贴或检查浏览器权限。'
                }
            });
            console.error('剪贴板错误:', err);
        }
    }

    // 处理粘贴快捷键
    function handlePasteShortcut(e) {
        if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
            setTimeout(() => {
                const event = new Event('input');
                e.target.dispatchEvent(event);
            }, 0);
        }
    }

    // --- 界面更新 --- //
    function showResult(content, language) {
        const resultElement = language === 'zh' ? 
            elements.resultChinese : 
            elements.result;
        
        if (resultElement) {
            resultElement.textContent = content;
        }
    }

    // 显示错误信息
    function showError(error, type = 'analyze') {
        const errorObj = typeof error === 'string' ? {
            error: {
                en: error,zh: error
            }
        } : error;

        if (type === 'chat') {
            appendChatMessage('error', `错误: ${errorObj.error.en}\n错误:${errorObj.error.zh}`);
        } else {
            if (elements.result) {
                elements.result.innerHTML = `<div class="error fade-in">错误: ${errorObj.error.en}</div>`;
            }
            if (elements.resultChinese) {
                elements.resultChinese.innerHTML = `<div class="error fade-in">错误:${errorObj.error.zh}</div>`;
            }
        }
    }

    // 显示聊天错误信息
    function showChatError(error) {
        appendChatMessage('error', `错误: ${error.en}\n错误:${error.zh}`);
    }

    // 设置加载状态
    function setLoading(isLoading, type = 'analyze') {
        if (elements.loadingOverlay) {
            elements.loadingOverlay.classList.toggle('active', isLoading);
        }

        // 禁用相关按钮
        if (type === 'analyze') {
            if (elements.analyzeImage) {
                elements.analyzeImage.disabled = isLoading;
            }
        } else if (type === 'chat') {
            if (elements.sendMessage) {
                elements.sendMessage.disabled = isLoading;
            }
            if (elements.chatInput) {
                elements.chatInput.disabled = isLoading;
            }
        }
    }

    // 验证输入
    function validateInputs() {
        if (!elements.preview?.src) {
            showError({
                error: {
                    en: 'Please upload an image or provide an image URL',
                    zh: '请上传图片或提供图片URL'
                }
            });
            return false;
        }

        if (!elements.prompt?.value.trim()) {
            showError({
                error: {
                    en: 'Please enter a prompt',
                    zh: '请输入提示词'
                }
            });
            return false;
        }

        return true;
    }

    // 清除所有内容
    function handleClearAll() {
        // 重置图片上传
        if (elements.imageUpload) {
            elements.imageUpload.value = '';
        }
        if (elements.imageUrl) {
            elements.imageUrl.value = '';
        }
        if (elements.preview) {
            elements.preview.src = '';
        }
        if (elements.compressionInfo) {
            elements.compressionInfo.textContent = '';
        }

        // 重置提示词
        if (elements.prompt) {
            elements.prompt.value = '';
        }

        // 重置结果
        if (elements.result) {
            elements.result.textContent = '';
        }
        if (elements.resultChinese) {
            elements.resultChinese.textContent = '';
        }

        // 重置聊天
        if (elements.chatMessages) {
            elements.chatMessages.innerHTML = '';
        }
        if (elements.chatInput) {
            elements.chatInput.value = '';
        }

        // 重置聊天历史
        chatHistory = [];
    }

    // 初始化应用
    initialize();
});

3. style.css

/* 基础样式和变量 */
:root {
    --primary: #2563eb;
    --primary-dark: #1d4ed8;
    --secondary: #64748b;
    --success: #22c55e;
    --danger: #dc2626;
    --warning: #f59e0b;
    --gray-50: #f9fafb;
    --gray-100: #f3f4f6;
    --gray-200: #e5e7eb;
    --gray-300: #d1d5db;
    --gray-400: #9ca3af;
    --gray-500: #6b7280;
    --gray-600: #4b5563;
    --gray-700: #374151;
    --gray-800: #1f2937;
}

/* 重置和基础样式 */
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    line-height: 1.6;
    color: var(--gray-800);
    background-color: var(--gray-100);
    min-height: 100vh;
}

/* 布局 */
.container {
    max-width: 1200px;
    margin: calc(44px + 20px) auto 20px;
    padding: 20px;
    background-color: white;
    border-radius: 12px;
    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

/* 排版 */
h1 {
    font-size: 2rem;
    font-weight: 700;
    color: var(--gray-800);
    text-align: center;
    margin-bottom: 2rem;
}

h2 {
    font-size: 1.5rem;
    font-weight: 600;
    color: var(--gray-700);
    margin: 1.5rem 0 1rem;
}

h3 {
    font-size: 1.25rem;
    font-weight: 500;
    color: var(--gray-600);
    margin: 1rem 0 0.5rem;
}

/* 区域分块 */
.section {
    background-color: var(--gray-50);
    border: 1px solid var(--gray-200);
    border-radius: 8px;
    padding: 1.5rem;
    margin-bottom: 2rem;
}

/* 上传区域 */
.upload-area {
    border: 2px dashed var(--gray-300);
    border-radius: 8px;
    padding: 2rem;
    text-align: center;
    background-color: white;
    transition: all 0.3s ease;
    cursor: pointer;
    margin: 1rem 0;
}

.upload-area:hover, .upload-area.dragover {
    border-color: var(--primary);
    background-color: rgba(37, 99, 235, 0.05);
}

.upload-area input[type="file"] {
    display: none;
}

.file-info {
    color: var(--gray-500);
    font-size: 0.875rem;
    margin-top: 0.5rem;
}

/* 输入样式 */
input[type="text"],
input[type="url"],
textarea {
    width: 100%;
    padding: 0.75rem 1rem;
    border: 2px solid var(--gray-200);
    border-radius: 8px;
    font-size: 1rem;
    transition: all 0.3s ease;
    background-color: white;
}

input[type="text"]:focus,
input[type="url"]:focus,
textarea:focus {
    border-color: var(--primary);
    outline: none;
    box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}

textarea {
    min-height: 120px;
    resize: vertical;
}

/* 预览区域 */
.preview-container {
    background-color: white;
    border: 1px solid var(--gray-200);
    border-radius: 8px;
    padding: 1rem;
    text-align: center;
}

#preview {
    max-width: 100%;
    max-height: 400px;
    display: block;
    margin: 0 auto;
    border-radius: 4px;
}

.compression-info {
    color: var(--gray-600);
    font-size: 0.875rem;
    margin-top: 0.5rem;
    text-align: center;
}

/* 提示词区域 */
.prompt-section {
    margin: 1.5rem 0;
}

.large-prompt textarea {
    min-height: 150px;
    font-size: 1.1rem;
}

.prompt-suggestions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin: 1rem 0;
}

.prompt-suggestion {
    padding: 0.5rem 1rem;
    background-color: var(--gray-100);
    border: 1px solid var(--gray-300);
    border-radius: 20px;
    font-size: 0.875rem;
    color: var(--gray-700);
    cursor: pointer;
    transition: all 0.2s ease;
}

.prompt-suggestion:hover {
    background-color: var(--gray-200);
    border-color: var(--gray-400);
}

.prompt-suggestion.highlight {
    background-color: var(--primary);
    color: white;
    border-color: var(--primary);
}

.prompt-suggestion.highlight:hover {
    background-color: var(--primary-dark);
    border-color: var(--primary-dark);
}

/* 按钮 */
.button-container {
    display: flex;
    gap: 1rem;
    margin: 1rem 0;
    justify-content: center;
}

button {
    padding: 0.75rem 1.5rem;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.3s ease;
}

.button-primary {
    background-color: var(--primary);
    color: white;
    min-width: 120px;
}

.button-primary:hover {
    background-color: var(--primary-dark);
    transform: translateY(-1px);
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.button-primary:disabled {
    background-color: var(--gray-400);
    cursor: not-allowed;
    transform: none;
}

/* 清除按钮 */
.clear-button-container {
    position: fixed;
    top: 20px;
    left: 20px;
    z-index: 1000;
}

.clear-button {
    background-color: var(--danger);
    color: white;
    padding: 10px 20px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    gap: 8px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.clear-button:hover {
    background-color: #b91c1c;
    transform: translateY(-1px);
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
}

/* 结果区域 */
.result-container {
    background-color: white;
    border: 1px solid var(--gray-200);
    border-radius: 8px;
    padding: 1.5rem;
    min-height: 100px;
    margin-top: 1rem;
}

/* 错误和加载状态 */
.error {
    background-color: #fee2e2;
    border: 1px solid #fecaca;
    color: var(--danger);
    padding: 1rem;
    border-radius: 8px;
    margin: 1rem 0;
}

.loading-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 2000;
}

.loading-overlay.active {
    display: flex;
}

.loading-spinner {
    width: 50px;
    height: 50px;
    border: 4px solid var(--gray-200);
    border-top-color: var(--primary);
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

.loading-text {
    color: white;
    margin-top: 1rem;
    font-size: 1.1rem;
}

/* 响应式设计 */
@media (max-width: 768px) {
    .container {
        margin: calc(44px + 10px) 10px 10px;
        padding: 15px;
    }

    .clear-button-container {
        top: 10px;
        left: 10px;
    }

    h1 { font-size: 1.5rem; }
    h2 { font-size: 1.25rem; }
    h3 { font-size: 1.1rem; }

    .prompt-suggestions {
        flex-direction: column;
    }

    .prompt-suggestion {
        width: 100%;
        text-align: center;
    }

    .button-container {
        flex-direction: column;
    }

    button {
        width: 100%;
    }
}

/* 动画 */
.fade-in {
    animation: fadeIn 0.3s ease-in;
}

@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

/* 实用工具类 */
.full-width {
    width: 100%;
}

.text-center {
    text-align: center;
}

.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }

/* URL输入容器 */
.url-input-container {
    margin: 10px 0;
}

.input-with-button {
    display: flex;
    gap: 8px;
    align-items: center;
}

.input-with-button input {
    flex: 1;
}

.button-secondary {
    background-color: var(--gray-200);
    color: var(--gray-700);
    padding: 0.75rem 1rem;
    border-radius: 8px;
    font-size: 1rem;
    display: flex;
    align-items: center;
    gap: 4px;
    transition: all 0.2s ease;
}

.button-secondary:hover {
    background-color: var(--gray-300);
}

/* 双语结果样式 */
.result-container {
    background-color: white;
    border: 1px solid var(--gray-200);
    border-radius: 8px;
    margin-top: 1rem;
}

.language-result {
    padding: 1.5rem;
}

.language-label {
    font-weight: 600;
    color: var(--gray-700);
    margin-bottom: 0.5rem;
    font-size: 0.9rem;
    text-transform: uppercase;
}

.result-content {
    line-height: 1.6;
    color: var(--gray-800);
    white-space: pre-wrap;
}

.language-divider {
    height: 1px;
    background-color: var(--gray-200);
    margin: 0 1.5rem;
}

/* 结果内的错误样式 */
.result-content .error {
    margin: 0;
}

/* 结果内的加载状态 */
.result-content.loading {
    color: var(--gray-500);
    font-style: italic;
}

.download-prompt {
    padding: 1rem;
    margin: 1rem 0;
    border-radius: 8px;
    background-color: var(--gray-50);
    border: 1px solid var(--gray-200);
}

.download-prompt p {
    margin-bottom: 1rem;
    color: var(--gray-700);
}

.download-prompt .button-primary {
    display: inline-block;
    padding: 0.5rem 1rem;
    background-color: var(--primary);
    color: white;
    border-radius: 4px;
    text-decoration: none;
    transition: all 0.2s ease;
}

.download-prompt .button-primary:hover {
    background-color: var(--primary-dark);
    transform: translateY(-1px);
}

4. index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="xAI Vision and Chat Interface">
    <title>xAI Vision Interface</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <link rel="icon" type="image/jpeg" href="{{ url_for('static', filename='favicon.jpg') }}">
</head>
<body>
    <!-- Clear All Button (Fixed Position) -->
    <div class="clear-button-container">
        <button id="clearAll">🗑️ Clear All</button>
    </div>
    
    <div class="container">
        <h1>xAI Vision Interface</h1>
    
        <div class="section vision-section">
            <h2>Image Analysis</h2>
    
            <div class="upload-section">
                <h3>Upload Image</h3>
                
                <div id="dropZone" class="upload-area">
                    <input type="file" id="imageUpload" accept="image/*" hidden>
                    <p>Drop your image here or click to upload</p>
                    <div class="file-info">Images will be automatically compressed</div>
                </div>
    
                <h3>Or Enter Image URL</h3>
                <div class="url-input-container">
                    <div class="input-with-button">
                        <input type="url" id="imageUrl" placeholder="Enter image URL">
                        <button id="pasteUrl" class="button-secondary">📋 Paste</button>
                    </div>
                </div>
            </div>
    
            <div class="preview-section">
                <h3>Image Preview</h3>
                <div class="preview-container">
                    <img id="preview" src="" alt="Preview will appear here">
                    <div id="compressionInfo" class="compression-info"></div>
                </div>
            </div>
    
            <div class="prompt-section large-prompt">
                <h3>What would you like to know about the image?</h3>
                <textarea id="prompt" placeholder="Enter your question about the image"></textarea>
                
                <div class="prompt-suggestions">
                    <button class="prompt-suggestion">Create prompt for this image</button>
                    <button class="prompt-suggestion">Explain this diagram in detail</button>
                    <button class="prompt-suggestion">Analyze the trends in this chart</button>
                    <button class="prompt-suggestion">Extract text from this document</button>
                    <button class="prompt-suggestion">Describe what you see</button>
                </div>
    
                <div class="button-container">
                    <button id="analyzeImage" class="button-primary">Analyze Image</button>
                </div>
            </div>
    
            <div class="result-section">
                <h3>Analysis Result</h3>
                <div class="result-container">
                    <div class="language-result">
                        <div class="language-label">English:</div>
                        <div id="result" class="result-content"></div>
                    </div>
                    <div class="language-divider"></div>
                    <div class="language-result">
                        <div class="language-label">中文:</div>
                        <div id="resultChinese" class="result-content"></div>
                    </div>
                </div>
            </div>
        </div>
    
        <div class="section chat-section">
            <h2>AI Chat</h2>
            <div id="chatMessages" class="chat-messages"></div>
            <div class="chat-input-section">
                <textarea id="chatInput" placeholder="Type your message..."></textarea>
                <div class="button-container">
                    <button id="sendMessage" class="button-primary">Send Message</button>
                </div>
            </div>
        </div>
    </div>
    
    <div id="loadingOverlay" class="loading-overlay">
        <div class="loading-spinner"></div>
        <div class="loading-text">Processing...</div>
    </div>

    <!-- Scripts -->
    <script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>

5. config.ini

[XAI]
api_key = xai-8KMQo1mtdb7Gpol00000000000000000000000000000000000QhRbjbEXl

6. cert.pem key.pem

这两是我本地发行的“证书”,用于 SSL 连接的身份认证。见我以前文章有详细介绍。

Docker 部署

1. Dockerfile

FROM python:3.9-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
    openssl \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN if [ ! -f cert.pem ] || [ ! -f key.pem ]; then \
    openssl req -x509 -newkey rsa:4096 -nodes \
    -out cert.pem -keyout key.pem -days 365 \
    -subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"; \
    fi

ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV PYTHONUNBUFFERED=1

EXPOSE 9011

CMD ["python", "app.py"]

2. requirements.txt

flask>=3.1.0
openai>=1.0.0
requests>=2.26.0
python-dotenv>=0.19.0
werkzeug>=2.0.0
configparser>=5.0.0
urllib3>=1.26.0

3. 部署到 Docker / NAS Container

[~] # cd /share/Multimedia/2024-MyProgramFiles/27.xai/
[/share/Multimedia/2024-MyProgramFiles/27.xai] # docker build -t xai .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

Sending build context to Docker daemon  57.92MB
Step 1/12 : FROM python:3.9-slim
...
...
Step 12/12 : CMD ["python", "app.py"]
 ---> Running in f7f9fba09e23
 ---> Removed intermediate container f7f9fba09e23
 ---> e211db362533
Successfully built e211db362533
Successfully tagged xai:latest
[/share/Multimedia/2024-MyProgramFiles/27.xai] #
[/share/Multimedia/2024-MyProgramFiles/27.xai] # docker run -d -p 9011:9011 --name xai_container --restart always xai
3b621f79b6ed0bf32450aea1ea1b231cc4454e4b6c1339ca260cfb58da31282b

演示

视频上传一直不成功,看图:

a. URL 上传图片:

b. 拖拽上传图片

c. 向 AI 提问题

总结:

从我申请到开台使用API, x.ai 已经在界面上去掉 GROK, GROK2+FLUX Beta, 现在只有 GROK2, 但是 API 使用模型还是这两,没变化。 

之前 FLUX 只能出一张图,现在同 Mid Journey 一样出四张。 现在图多了,发现两尾猫猫,3䐚美女,无装备太空猫就出来了。

与 Mid Journey 还是有不小差距。

政治上也有很大的进步空间。一定是在造谣!

补充:端口用的 9011

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值