【SenseNova U1.5 Lite实战】AMD ROCm 192G显存部署全流程与性能调优

【SenseNova U1.5 Lite实战】AMD ROCm 192G显存部署全流程与性能调优


一、环境声明

项目 验证方式
硬件 AMD GPU,192GB 显存 amd-smi
ROCm 版本 7.2.3 amd-smi 头部
PyTorch 2.11+ (ROCm 版) python -c "import torch; print(torch.version.hip)"
transformers 5.14.1 `pip list
平台 魔搭社区 AMDGPU 实例 实例面板
磁盘 200GB df -h
Python 3.12 python --version

二、参考资料

项目
SenseNova-U1.5 Lite 官方体验地址 https://unify.light-ai.top/home?model=u15-lite
Github https://github.com/OpenSenseNova/SenseNova-U1
HuggingFace https://huggingface.co/collections/sensenova/sensenova-u15
魔搭社区 https://modelscope.cn/models/SenseNova/SenseNova-U1.5-8B-MoT
Gitcode https://gitcode.com/SenseNova/SenseNova-U1?source_module=search_project

提示:本文档使用魔搭AMD GPU + 魔搭社区参考资源部署,博主从0搭建,值得参考,并且提供了WebUi方便大家部署后进行测试

一键验证命令(复制执行):

Bash 复制代码
echo "=== GPU ===" && amd-smi | head -5
echo "=== PyTorch ROCm ===" && python -c "import torch; print(f'ROCm可用: {torch.cuda.is_available()}'); print(f'GPU型号: {torch.cuda.get_device_name(0)}'); print(f'显存: {torch.cuda.get_device_properties(0).total_mem / 1e9:.0f} GB'); print(f'HIP版本: {torch.version.hip}')"
echo "=== transformers ===" && python -c "import transformers; print(transformers.__version__)"
echo "=== 关键路径 ===" && ls /mnt/workspace/SenseNova-U1/requirements.txt && ls /mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master/config.json

本机实际输出验证:

Plaintext 复制代码
ROCm可用: True
GPU型号: AMD Radeon Graphics
显存: 196 GB
HIP版本: 6.3.4
transformers: 5.14.1

三、从零部署全流程

SenseNova U1.5 Lite 的官方部署文档写得比较简略,实际在 AMD ROCm 环境上走下来遇到不少魔搭特有的问题。下面按实际执行顺序给出完整流程,每一步带命令和诊断方法。

第 1 步:启动 GPU 实例 + 克隆仓库

魔搭实例启动后自带 Git,直接克隆官方仓库:

Bash 复制代码
cd /mnt/workspace && git clone https://github.com/OpenSenseNova/SenseNova-U1.git
cd SenseNova-U1

注意: 魔搭实例的 /mnt/workspace 是持久化存储,重启后文件不会丢失。但模型文件很大(50GB),建议放在这里而不是 /root 临时目录。

第 2 步:安装依赖(pip install -r)

第一个坑:清华源对某些 wheel 返回 403。 实测阿里云镜像可用:

Bash 复制代码
# ⚠️ 用阿里云镜像(清华源容易 403)
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com

# ⚠️ 关键:必须可编辑安装,否则 import sensenova_u1 会失败
pip install -e .

pip install -e . 会把当前目录以软链接形式注册到 Python 环境,这样 import sensenova_u1 才指向仓库的 src/ 目录,而不是 pip 缓存里的旧版本。

第 3 步:ROCm PyTorch 重装(最大坑!)

问题: 魔搭实例默认装的是 CPU 版 PyTorchtorch.cuda.is_available() 返回 False,推理时 GPU 0% 利用率、CPU 满载。这是 AMD ROCm 环境最容易踩的坑,几乎所有第一次部署的人都会遇到。

诊断方法:

Bash 复制代码
python -c "import torch; print(torch.__version__, 'HIP:', torch.version.hip)"
# 如果 torch.version.hip 是 None → CPU 版,必须重装!

解决命令:

Bash 复制代码
# 1. 卸载 CPU 版 + 清理缓存
pip uninstall torch torchvision torchaudio -y
pip cache purge

# 2. 安装 ROCm 版(选 rocm6.2 或 rocm6.3,匹配 ROCm 7.x)
# timeout 600 秒防止大文件下载中断,retries 5 次自动重试
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2 --timeout 600 --retries 5

# 3. 验证(必须输出 True)
python -c "import torch; print(torch.cuda.is_available())"

关于 torchaudio: AMD ROCm 环境不需要 torchaudio,装了反而会报 ImportError: libtorch_hip.so。如果遇到,直接卸载:

Bash 复制代码
pip uninstall torchaudio -y

第 4 步:下载模型(魔搭内部免流量)

SenseNova U1.5 Lite 8B MoT Preview 版本约 50GB,魔搭内部下载免费:

Bash 复制代码
# 官方推荐方式,自动路由到魔搭镜像
python -c "from modelscope import snapshot_download; snapshot_download('SenseNova/SenseNova-U1.5-8B-MoT-Preview')"

第 5 步:跑通第一张图

设置模型路径环境变量,执行官方推理脚本:

Bash 复制代码
export MODEL=/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master
cd /mnt/workspace/SenseNova-U1

python examples/t2i/inference.py \
  --model_path $MODEL \
  --prompt "一只猫坐在桌子上,阳光从窗户照进来" \
  --width 2048 --height 2048 \
  --cfg_scale 4.0 --num_steps 8 \
  --output /mnt/workspace/output.png

同时另开一个终端监控 GPU:

Bash 复制代码
watch -n 1 amd-smi

判断 GPU 是否在跑:

  • GPU 利用率跳动 → 成功在 GPU 上推理

  • 全程 0% → PyTorch 还是 CPU 版,回到第 3 步重装


四、踩坑 10 条(根因分析 + 解决方案)

部署过程中记录了 12 个实际遇到的问题,按影响程度排序。每条都带根因分析和可直接复制的解决方案。

坑 1:pip 清华源 403

现象: pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ 返回 HTTP error 403

根因: 清华源对某些 wheel 包(尤其是 CUDA/ROCm 相关的大包)做了访问限制。

解决: 换阿里云镜像,加 --trusted-host 跳过证书验证:

Bash 复制代码
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com

坑 2:终端粘贴带控制字符

现象: bash: $'\E[200~echo': command not found

根因: 从某些网页粘贴时,浏览器会附带终端控制字符(如 bracketed paste mode 的转义序列 \E[200~)。

解决: 手动重新输入命令,或先粘到本地记事本/VS Code 再复制。

坑 3:ModuleNotFoundError: sensenova_u1

现象: 运行推理脚本时报 ImportError: No module named 'sensenova_u1'

根因: 没执行 pip install -e .,Python 找不到仓库里的 src/sensenova_u1 包。

解决: 进入仓库根目录执行可编辑安装:

Bash 复制代码
cd /mnt/workspace/SenseNova-U1 && pip install -e .

坑 4:torchaudio 报 libtorch_hip.so 缺失

现象: ImportError: libtorch_hip.so: cannot open shared object file

根因: AMD ROCm 环境不需要 torchaudio,装了反而会因为找不到 HIP 音频库而 import 失败。

解决: 直接卸载:

Bash 复制代码
pip uninstall torchaudio -y

坑 5:模型路径找不到 config.json

现象: FileNotFoundError: /xxx/config.json not found

根因: modelscope 的缓存路径是 ~/.cache/modelscope/hub//mnt/workspace/models/models/名称/snapshots/master/,嵌套两层 models/

解决: 用 find 定位:

Bash 复制代码
find / -name "config.json" -path "*SenseNova*" 2>/dev/null

坑 6:GPU 0% 利用率(最隐蔽的坑)

现象: amd-smi 显示 GPU 利用率 0%,CPU 满载,推理比预期慢 10-50 倍。

根因: 魔搭默认装 CPU 版 PyTorch,torch.cuda.is_available() 返回 False,所有 tensor 都在 CPU 上跑。

解决: 卸载重装 ROCm 版(详见第 3 步),安装后必须验证:

Bash 复制代码
python -c "import torch; print(torch.cuda.is_available())"  # 必须 True

坑 7:端口 7860 被占用

现象: Flask 启动时报 Address already in use

根因: 之前的 Flask 进程没杀干净。

解决:

Bash 复制代码
# 方法 1:杀所有 Flask 进程
pkill -f "python webui.py"

# 方法 2:用 fuser 杀端口
fuser -k 7860/tcp

# 方法 3:直接换端口
cd /mnt/workspace && python -c "import webui; webui.app.run(host='0.0.0.0', port=7863, threaded=True)"

坑 8:Gradio 清华源 403 + 依赖太重

现象: 一开始想用 Gradio 搭 Web UI,但 pip install gradio 清华源 403,且 Gradio 依赖链很重(几十 MB)。

根因: 同坑 1,且 Gradio 在无外网或受限镜像环境下安装困难。

解决: 放弃 Gradio,改用 Flask + 原生 HTML,依赖只有 2 个轻量包:

Bash 复制代码
pip install flask pillow flask-cors -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com

坑 9:POST 请求 504 网关超时

现象: Flask Web UI 的文生图按钮点下去,网关等了几分钟后返回 504。

根因: SenseNova U1 推理一次需要几十秒到几分钟(取决于分辨率和步数),魔搭网关有超时限制。同步 Flask 要等推理完才能返回,网关先掐断了连接。

解决: 改异步任务模式:POST 立即返回 task_id,后端线程异步执行推理,前端每 2 秒轮询 /api/status/<task_id>

Python 复制代码
# Flask 后端:立即返回 task_id
def _submit_task(fn, *args, **kwargs):
    task_id = uuid.uuid4().hex
    with task_lock:
        task_store[task_id] = {"status": "pending"}
    t = threading.Thread(target=_run_task, args=(task_id, fn) + args, kwargs=kwargs, daemon=True)
    t.start()
    return task_id

@app.route("/api/t2i", methods=["POST"])
def api_t2i():
    tid = _submit_task(_do_t2i, request.json)
    return jsonify({"task_id": tid})

# 前端:轮询任务状态
async function submitAndPoll(endpoint, payload) {
    const r = await fetch(endpoint, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
    const {task_id} = await r.json();
    return new Promise((resolve, reject) => {
        const timer = setInterval(async () => {
            const sr = await fetch('/api/status/' + task_id);
            const td = await sr.json();
            if (td.status === 'done') { clearInterval(timer); resolve(td.result); }
            else if (td.status === 'error') { clearInterval(timer); reject(new Error(td.error)); }
        }, 2000);
    });
}

坑 10:SSE 流式被网关缓冲

现象: Chat 和 VQA 的 SSE 流式,前端一直等到模型推理完才一次性收到全部数据,没有打字机效果。

根因: Flask 没立即发送数据,网关在等全量响应才开始转发,导致 SSE 的流式特性完全失效。

解决: Flask SSE 必须做三件事------

  1. 立即 yield connected 帧: 请求进来先立刻发一个 JSON 帧,让网关开始转发

  2. 每 10 秒 ping 保活: 推理过程中每 10 秒发一条 SSE 注释帧 : ping\n\n,防止网关因长时间无数据而掐断连接

  3. 设置三个关键响应头: Cache-Control: no-cache + X-Accel-Buffering: no + Connection: keep-alive

Python 复制代码
def _sse_gen():
    # 1. 立即发 connected 帧 ------ 让网关开始转发,不再缓冲
    yield f"data: {json.dumps({'type': 'connected', 'msg': '思考中...'}, ensure_ascii=False)}\n\n"
    
    # 2. 等待后台推理完成,每 10 秒发 ping 保持连接
    while True:
        try:
            evt, payload = result_q.get(timeout=10)
            break
        except queue.Empty:
            yield ": ping\n\n"  # SSE 注释帧,前端忽略,仅用于保活
    
    # 3. 推理完成后打字机逐字输出
    text = payload["text"]
    for i in range(len(text)):
        yield f"data: {json.dumps({'type': 'delta', 'text': text[:i+1]}, ensure_ascii=False)}\n\n"
        time.sleep(0.02)
    
    yield f"data: {json.dumps({'type': 'done', 'full_text': text}, ensure_ascii=False)}\n\n"

return Response(
    stream_with_context(_sse_gen()),
    content_type="text/event-stream",
    headers={
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",
        "Connection": "keep-alive",
    },
)

五、对比实验设计与数据

SenseNova U1.5 Lite 的推理质量和速度受多个参数影响,下面给出 4 组对比实验设计。实验数据通过配套 run_one.py 脚本自动采集------该脚本用 time.time() 统计耗时、从 sysfs 的 /sys/class/drm/card0/device/mem_info_vram_used 读取推理前后显存变化(ROCm 环境下 sysfs 比 amd-smi 表格解析更可靠)、os.path.getsize() 取图片大小。

以下数据表格为待填充状态,跑完实验后将实际值填入。

实验 1:8 步蒸馏 vs 50 步完整

SenseNova U1.5 Lite 的 "Lite" 版核心卖点就是 8 步蒸馏------把原来需要 50 步的扩散过程压缩到 8 步,速度提升 6 倍以上。本组实验验证这个加速比的实际表现和质量影响。

固定参数: 分辨率 2048×2048、cfg_scale 4.0、prompt 相同

步数 提示词 分辨率 CFG_Scale 耗时秒 图片大小(KB) 质量备注
8 一只橘色猫咪坐在木质餐桌上,温暖阳光从窗户照进来 2048x2048 4.0 38.47 5866.9 蒸馏快,细节略糙
50 一只橘色猫咪坐在木质餐桌上,温暖阳光从窗户照进来 2048x2048 4.0 96.82 4846.7 完整推理,细节丰富

实验 2:分辨率对比

SenseNova U1 的训练集覆盖特定分辨率,不在训练集里的分辨率会出现明显质量下降(伪影、模糊)。patch_size=32,所以分辨率最好对齐 patch。

固定参数: 8 步蒸馏、cfg_scale 4.0

分辨率 比例 训练集命中 提示 步数 CFG_Scale 耗时(秒) 图片大小(KB) 质量备注
1024×1024 1:1 ❌ 不在 壮丽山脉风景,蓝天白云,金色阳光洒在雪山顶峰,宁静湖泊倒影 8 4.0 26.92 1362.8 非训练分辨率,质量下降
2048×2048 1:1 ✅ 命中 壮丽山脉风景,蓝天白云,金色阳光洒在雪山顶峰,宁静湖泊倒影 8 4.0 36.55 5686.8 t2i 最佳训练分辨率
2720×1536 16:9 ✅ 命中 壮丽山脉风景,蓝天白云,金色阳光洒在雪山顶峰,宁静湖泊倒影 8 4.0 36.82 5437.5 宽屏风景最佳

图片对比

图片以及数据对比

实验 3:CFG Scale 对比

Classifier-Free Guidance 控制生成的创意自由度:值越小越自由,值越大越严格遵循提示词。官方推荐 4.0。

固定参数: 8 步蒸馏、2048×2048

cfg_scale 测试文案 分辨率 步数 耗时(秒) 图片大小(KB) 效果描述
1.0 赛博朋克城市夜景,霓虹灯闪烁,雨天湿润街道,飞行汽车 2048x2048 8 31.36 4695.5 创意自由,可能偏离提示词
4.0 赛博朋克城市夜景,霓虹灯闪烁,雨天湿润街道,飞行汽车 2048x2048 8 36.84 6350.0 平衡,官方推荐
7.0 赛博朋克城市夜景,霓虹灯闪烁,雨天湿润街道,飞行汽车 2048x2048 8 36.42 7231.3 严格遵循提示词,可能过拟合

实验 4:Think Mode 对比(Chat Tab 深度思考开关)

SenseNova U1 的 Chat 功能有一个 think_mode 参数------开启后模型会先在内部推理再输出,关闭则直接给出答案。

固定参数: 4 步文本生成、相同 prompt

测试一prompt: "一个长方形长10宽5对角线长度是多少图请给出计算过程和结果"

think_mode 显存增量(MB) 耗时(秒) 文本长度 效果备注
True 20 37.1 685 思考时间更长,回答更详细
False 20 11.3 257 直接给答案,速度快

测试二prompt: "A rectangle has length 10 and width 5. What is the length of its diagonal? Show your calculation step by step and give the exact result."

think_mode 显存增量(MB) 耗时(秒) 文本长度 效果备注
True 10 37.1 685 思考时间更长,回答更详细
False 10 11.3 257 直接给答案,速度快

测试三:"一只未来感机械凤凰展开翅膀站在县崖边"

think_mode 文案 耗时(秒) 图片大小(KB) 效果备注(个人眼光)
True 一只未来感机械凤凰展开翅膀站在县崖边 85.79 6437.5 看起来场景比较单一
False 一只未来感机械凤凰展开翅膀站在县崖边 37.11 6192.5 肉眼看见配色比较丰富

一键实验脚本(复制执行)

以下脚本会依次跑完全部 4 组实验,每组自动采集指标存进 JSON 和日志文件:

Bash 复制代码
#!/bin/bash
MODEL=/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master
OUT=/mnt/workspace/experiments
mkdir -p $OUT && cd /mnt/workspace/SenseNova-U1

echo "========== 实验1: 8步 vs 50步 =========="
START=$(date +%s)
python examples/t2i/inference.py --model_path $MODEL \
  --prompt "一只猫坐在桌子上,阳光从窗户照进来,电影质感" \
  --width 2048 --height 2048 --cfg_scale 4.0 --num_steps 8 \
  --output $OUT/exp1_8steps.png --profile 2>&1 | tee $OUT/exp1_8steps_日志.txt
echo "8步耗时: $(($(date +%s)-START)) 秒"

START=$(date +%s)
python examples/t2i/inference.py --model_path $MODEL \
  --prompt "一只猫坐在桌子上,阳光从窗户照进来,电影质感" \
  --width 2048 --height 2048 --cfg_scale 4.0 --num_steps 50 \
  --output $OUT/exp1_50steps.png --profile 2>&1 | tee $OUT/exp1_50steps_日志.txt
echo "50步耗时: $(($(date +%s)-START)) 秒"

echo "========== 实验2: 分辨率 =========="
for r in "1024 1024" "2048 2048" "2720 1536"; do
  W=$(echo $r | cut -d' ' -f1); H=$(echo $r | cut -d' ' -f2)
  START=$(date +%s)
  python examples/t2i/inference.py --model_path $MODEL \
    --prompt "风景照片,山脉,蓝天白云,高清" \
    --width $W --height $H --cfg_scale 4.0 --num_steps 8 \
    --output $OUT/exp2_${W}x${H}.png --profile 2>&1 | tee $OUT/exp2_${W}x${H}_日志.txt
  echo "${W}x${H} 耗时: $(($(date +%s)-START)) 秒"
done

echo "========== 实验3: cfg_scale =========="
for c in 1.0 4.0 7.0; do
  python examples/t2i/inference.py --model_path $MODEL \
    --prompt "赛博朋克城市夜景,霓虹灯,雨天,反光路面" \
    --width 2048 --height 2048 --cfg_scale $c --num_steps 8 \
    --output $OUT/exp3_cfg${c}.png --profile 2>&1 | tee $OUT/exp3_cfg${c}_日志.txt
  echo "cfg_scale=$c done"
done

echo "========== 全部完成 =========="
ls -lh $OUT/

六、Web UI 架构设计

部署成功后,命令行推理只能自己用。我用 Flask + 原生 HTML 搭了一套完整的 Web UI,封装了 SenseNova U1 全部 4 个功能。单文件、依赖轻量、网关友好。

本套UI方便大家测试:我已更新至GitHub地址

技术选型

组件 选择 原因
后端框架 Flask 轻量(单文件可用),原生支持 SSE 流式
前端 原生 HTML + CSS + JS 无需构建工具,复制粘贴即可运行
通信协议 SSE(Server-Sent Events) 比 WebSocket 轻,Flask 原生支持
流式模式 打字机伪流式 + ping 保活 网关不掐断,视觉效果等同真流式

4 个功能 Tab

Tab 后端推理类 通信模式 说明
🎨 文生图 SenseNovaU1T2I 异步任务轮询 prompt → 生成图片,默认 8 步蒸馏
✏️ 图片编辑 SenseNovaU1Editing 异步任务轮询 上传原图 + 指令 → 改图
🔍 图片理解 VQA SenseNovaU1Interleave SSE 打字机 上传图 + 问题 → 逐字回答
💬 多模态对话 SenseNovaU1Interleave SSE 打字机 纯文本对话,带深度思考开关

1.文生图

2.图片编辑

3.图片理解

4.多模态对话

懒加载策略

SenseNova U1 三个引擎(T2I / Edit / Interleave)各 50GB+,同时加载会撑爆 192GB 显存。采用 按需懒加载------引擎只在第一次调用对应 Tab 时才初始化:

Python 复制代码
engine_t2i = engine_edit = engine_il = None

def get_t2i():
    global engine_t2i
    if engine_t2i is None:
        print("[加载] 文生图引擎 (T2I)...")
        engine_t2i = SenseNovaU1T2I(MODEL_PATH, dtype=torch.bfloat16)
        print("[加载] ✅ T2I 就绪")
    return engine_t2i

SSE 流式完整时序

以 Chat Tab 为例,一次对话请求的完整时序:

Plaintext 复制代码
前端 POST /api/chat {prompt, think_mode: true}
  → Flask 立即 yield {"type":"connected","msg":"思考中..."}    ← 网关开始转发
  → 后台线程执行 interleave_gen()                              ← 可能 30-60 秒
  → 每 10s yield ": ping\n\n"                                  ← 保活,防网关掐断
  → 推理完成后逐字 yield {"type":"delta","text":"..."}          ← 打字机效果
  → yield {"type":"done","elapsed":"45.2s"}                    ← 结束帧

关键设计:connected 帧必须在推理开始前立即发送 ------这是防止网关缓冲的核心。ping 帧用 SSE 注释帧格式(以 : 开头),前端会自动忽略,只用于保持 TCP 连接活跃。

前端 SSE 通用解析器

前端的 SSE 解析逻辑可以复用到 VQA 和 Chat 两个 Tab:

JavaScript 复制代码
async function streamPost(endpoint, payload, onConnected, onDelta, onDone, onError) {
    const res = await fetch(endpoint, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload)
    });
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
        const {done, value} = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, {stream: true});

        // 按 \n\n 拆分 SSE 帧
        let idx;
        while ((idx = buffer.indexOf('\n\n')) !== -1) {
            const frame = buffer.slice(0, idx);
            buffer = buffer.slice(idx + 2);
            const line = frame.split('\n').find(l => l.startsWith('data:'));
            if (!line) continue;  // : ping 注释帧,跳过
            const data = JSON.parse(line.slice(5).trim());
            if (data.type === 'connected') onConnected(data.msg);
            else if (data.type === 'delta') onDelta(data.text);
            else if (data.type === 'done') { onDone(data); return; }
            else if (data.type === 'error') { onError(data.msg); return; }
        }
    }
}

Web UI 启动

Bash 复制代码
# 1. 装依赖(2 个包,几秒搞定)
pip install flask pillow flask-cors -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com

# 2. 启动
cd /mnt/workspace && python webui.py
# 输出:Running on http://0.0.0.0:7860

# 3. 访问
# 在浏览器打开 http://<服务器IP>:7860

七、结论与可复现脚本

SenseNova U1.5 Lite 在 AMD ROCm 环境上的部署,核心踩坑集中在三个方面:ROCm PyTorch 默认未装、pip 镜像选择、Flask Web UI 的网关超时与缓冲。解决这三个问题后,整体部署流程稳定,推理质量和速度达到官方预期。

SenseNova U1 的 NEO-unify 架构(统一多模态解码循环)是它既优秀又难调的原因------一个模型覆盖文生图、图片编辑、VQA、多模态对话四大能力,但核心解码函数 interleave_gen() 的自定义循环也让真 token 级流式变得困难。

快速上手清单(可复现 · 新 AMD 服务器从零跑通)

⚠️ 注意: 以下每一步都是独立命令,请逐行复制执行 ,不要用 && 串联------中间失败了不会报错但后面会挂。每步成功后会有 [OK] 提示。

第 1 步:克隆仓库 + 安装依赖
Bash 复制代码
# 1a. 克隆官方仓库(放到 /mnt/workspace 持久化目录,不要放 /root)
cd /mnt/workspace
git clone https://github.com/OpenSenseNova/SenseNova-U1.git  && echo "[OK] clone 成功"
cd SenseNova-U1  && echo "[OK] 进入仓库"

# 1b. 安装 requirements(用阿里云镜像,清华源容易 403)
pip install -r requirements.txt \
  -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
  && echo "[OK] requirements 安装成功"

# 1c. 可编辑安装(关键!否则 import sensenova_u1 失败)
pip install -e .  && echo "[OK] editable install 成功"

# 1d. 卸载 torchaudio(AMD ROCm 不需要,装了会报 libtorch_hip.so 缺失)
pip uninstall torchaudio -y  && echo "[OK] torchaudio 已卸载"
第 2 步:安装 ROCm 版 PyTorch(最大坑!必须重装)
Bash 复制代码
# 2a. 诊断:如果输出 None 说明是 CPU 版,必须重装
python -c "import torch; print('HIP版本:', torch.version.hip)"

# 2b. 卸载 CPU 版 + 清理缓存
pip uninstall torch torchvision torchaudio -y
pip cache purge  && echo "[OK] 旧版 PyTorch 已清理"

# 2c. 安装 ROCm 版(rocm6.2 或 rocm6.3 都可,匹配 ROCm 7.x)
# timeout 600 防大文件下载中断,retries 5 自动重试
pip install torch torchvision \
  --index-url https://download.pytorch.org/whl/rocm6.2 \
  --timeout 600 --retries 5 \
  && echo "[OK] ROCm PyTorch 安装成功"

# 2d. 验证(必须输出 True,否则回到 2b 重走)
python -c "import torch; print('ROCm可用:', torch.cuda.is_available()); print('GPU:', torch.cuda.get_device_name(0)); print('显存GB:', torch.cuda.get_device_properties(0).total_mem / 1e9)"

本机验证输出参考:

Plaintext 复制代码
ROCm可用: True
GPU: AMD Radeon Graphics
显存GB: 196.0
第 3 步:下载模型(50GB,魔搭内部免流量)
Bash 复制代码
# 3a. 用 modelscope 下载(魔搭内部自动路由到镜像,外网服务器换 huggingface_hub)
python -c "
from modelscope import snapshot_download
path = snapshot_download('SenseNova/SenseNova-U1.5-8B-MoT-Preview')
print('模型路径:', path)
" && echo "[OK] 模型下载完成"

# 3b. 找 config.json 确认(modelscope 缓存路径嵌套两层)
find /mnt/workspace -name "config.json" -path "*SenseNova*Preview*" 2>/dev/null | head -3

本机找到的路径参考:

Plaintext 复制代码
/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master/config.json
第 4 步:跑通第一张图
Bash 复制代码
# 4a. 设置模型路径(从上一步 find 结果复制,把 /config.json 去掉)
export MODEL=/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master
# 验证路径是否正确
ls $MODEL/config.json  && echo "[OK] 模型路径确认"

# 4b. 执行推理(另开终端跑 watch -n 1 amd-smi 看 GPU 是否在跑)
cd /mnt/workspace/SenseNova-U1
python examples/t2i/inference.py \
  --model_path $MODEL \
  --prompt "一只猫坐在桌子上,阳光从窗户照进来" \
  --width 2048 --height 2048 \
  --cfg_scale 4.0 --num_steps 8 \
  --output /mnt/workspace/output.png \
  && echo "[OK] 第一张图生成成功 → /mnt/workspace/output.png"

判断 GPU 是否在跑: 另开终端跑 watch -n 1 amd-smi,GPU 利用率跳动=成功,全程 0%=PyTorch 还是 CPU 版(回到第 2 步)。

第 5 步:启动 Web UI
Bash 复制代码
# 5a. 上传 webui.py 到服务器(代码见附录 B)
# 如果从 GitHub 拉取:
cd /mnt/workspace && git clone <你的webui仓库地址> temp_webui && cp temp_webui/webui.py /mnt/workspace/ && rm -rf temp_webui

# 5b. 装依赖
pip install flask pillow flask-cors \
  -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
  && echo "[OK] Web UI 依赖安装成功"

# 5c. 启动(按需换端口)
cd /mnt/workspace && python webui.py
# 看到 "Running on http://0.0.0.0:7860" 就成功了
# 浏览器打开 http://<服务器IP>:7860

附录 A:关键路径速查

路径 说明
/mnt/workspace/SenseNova-U1 官方仓库
/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master 模型目录(config.json 所在)
/mnt/workspace/webui.py Web UI 单文件(640 行,Flask + 原生 HTML)
/mnt/workspace/run_one.py 实验辅助脚本(自动采集耗时/显存/图片大小)
/mnt/workspace/experiments/ 对比实验输出目录
src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:1003 interleave_gen() 核心解码循环

附录 B:webui.py 代码获取

webui.py 是 640 行的单文件 Flask 应用,包含完整的 4 Tab Web UI + SSE 流式。获取方式:

  1. GitHub 仓库 **:下载 https://github.com/SupStrong/SenseNova-webui 或复制下面代码**

  2. 从本地复制 :本文附件同目录下的 webui.py 文件,直接上传到服务器 /mnt/workspace/

启动后访问 http://0.0.0.0:7860,4 个 Tab:

XML 复制代码
"""
SenseNova U1.5 Lite · 完整 Web UI
功能:文生图 | 图片编辑 | 图片理解VQA | 多模态对话
启动: pip install flask pillow flask-cors && python webui.py
访问: http://0.0.0.0:7860
"""
import sys, time, base64, io, os, importlib.util, threading, uuid, queue, json
from flask import Flask, request, jsonify, Response, stream_with_context
from flask_cors import CORS
from PIL import Image
import torch

# ===== 异步任务基础设施 =====
task_store = {}  # {task_id: {"status": "pending"/"done"/"error", "result": ...}}
task_lock = threading.Lock()

def _load_module_from_path(name, path):
    """用绝对路径加载模块,避免同名 inference.py 冲突"""
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    sys.modules[name] = mod
    spec.loader.exec_module(mod)
    return mod

# === 干净导入三个官方推理类(分别用独立模块名)====
_BASE = "/mnt/workspace/SenseNova-U1/examples"
t2i_mod = _load_module_from_path("sensenova_t2i", f"{_BASE}/t2i/inference.py")
edit_mod = _load_module_from_path("sensenova_edit", f"{_BASE}/editing/inference.py")
il_mod = _load_module_from_path("sensenova_interleave", f"{_BASE}/interleave/inference.py")

SenseNovaU1T2I = t2i_mod.SenseNovaU1T2I
SenseNovaU1Editing = edit_mod.SenseNovaU1Editing
SenseNovaU1Interleave = il_mod.SenseNovaU1Interleave
T2I_RES = t2i_mod.SUPPORTED_RESOLUTIONS

app = Flask(__name__)
CORS(app)

MODEL_PATH = "/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master"

# ===== 引擎懒加载(按需加载,避免 3×50GB 撑爆 192GB)=====
engine_t2i = None
engine_edit = None
engine_il = None

def get_t2i():
    global engine_t2i
    if engine_t2i is None:
        print("[加载] 文生图引擎 (T2I)...")
        engine_t2i = SenseNovaU1T2I(MODEL_PATH, dtype=torch.bfloat16)
        print("[加载] ✅ T2I 就绪")
    return engine_t2i

def get_edit():
    global engine_edit
    if engine_edit is None:
        print("[加载] 图片编辑引擎 (Edit)...")
        engine_edit = SenseNovaU1Editing(MODEL_PATH, dtype=torch.bfloat16)
        print("[加载] ✅ Edit 就绪")
    return engine_edit

def get_il():
    global engine_il
    if engine_il is None:
        print("[加载] 多模态引擎 (Interleave)...")
        engine_il = SenseNovaU1Interleave(MODEL_PATH, dtype=torch.bfloat16)
        print("[加载] ✅ Interleave 就绪")
    return engine_il

print(f"[模型路径] {MODEL_PATH}")
print("🚀 Web UI 启动后将按需加载引擎(懒加载)")

# ===== 工具函数 =====
def b64_to_pil(b64_str: str) -> Image.Image:
    if b64_str.startswith("data:"):
        b64_str = b64_str.split(",", 1)[1]
    data = base64.b64decode(b64_str)
    return Image.open(io.BytesIO(data)).convert("RGB")

def pil_to_b64(img: Image.Image) -> str:
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()

# ===== 后台线程执行器 =====
def _run_task(task_id, fn, *args, **kwargs):
    try:
        result = fn(*args, **kwargs)
        with task_lock:
            task_store[task_id] = {"status": "done", "result": result}
    except Exception as e:
        import traceback
        traceback.print_exc()
        with task_lock:
            task_store[task_id] = {"status": "error", "error": str(e)}

def _submit_task(fn, *args, **kwargs):
    """提交任务,立即返回 task_id"""
    task_id = uuid.uuid4().hex
    with task_lock:
        task_store[task_id] = {"status": "pending"}
    t = threading.Thread(target=_run_task, args=(task_id, fn) + args, kwargs=kwargs, daemon=True)
    t.start()
    return task_id

# ===== API 路由 =====
@app.route("/")
def index():
    return HTML_PAGE

@app.route("/api/status/<task_id>")
def api_status(task_id):
    with task_lock:
        task = task_store.get(task_id)
    if not task:
        return jsonify({"status": "not_found"}), 404
    # 用完清理
    if task["status"] in ("done", "error"):
        with task_lock:
            task_store.pop(task_id, None)
    return jsonify(task)

# ---- 文生图 ----
def _do_t2i(data):
    w, h = T2I_RES.get(data.get("ratio", "1:1"), (2048, 2048))
    start = time.time()
    images = get_t2i().generate(
        data.get("prompt", ""), image_size=(w, h),
        cfg_scale=float(data.get("cfg_scale", 4.0)),
        num_steps=int(data.get("num_steps", 8)),
        seed=int(data.get("seed", 42)),
    )
    elapsed = time.time() - start
    print(f"[T2I] {elapsed:.1f}s | {w}x{h}")
    return {"image": pil_to_b64(images[0]), "elapsed": f"{elapsed:.1f}s", "size": f"{w}x{h}"}

@app.route("/api/t2i", methods=["POST"])
def api_t2i():
    tid = _submit_task(_do_t2i, request.json)
    return jsonify({"task_id": tid})

# ---- 图片编辑 ----
def _do_edit(data):
    input_imgs = [b64_to_pil(b) for b in data.get("images", [])]
    ratio = data.get("ratio", "1:1")
    if ratio in T2I_RES:
        w, h = T2I_RES[ratio]
    else:
        if input_imgs:
            w, h = input_imgs[0].size
        else:
            w, h = 2048, 2048
    start = time.time()
    images, think = get_edit().edit(
        data.get("prompt", ""), input_imgs, image_size=(w, h),
        cfg_scale=float(data.get("cfg_scale", 4.0)),
        num_steps=int(data.get("num_steps", 8)), seed=int(data.get("seed", 42)),
    )
    elapsed = time.time() - start
    print(f"[Edit] {elapsed:.1f}s | {w}x{h}")
    return {"image": pil_to_b64(images[0]), "elapsed": f"{elapsed:.1f}s", "think": think}

@app.route("/api/edit", methods=["POST"])
def api_edit():
    tid = _submit_task(_do_edit, request.json)
    return jsonify({"task_id": tid})

# ---- VQA ----
def _do_vqa(data):
    """后台执行 VQA 推理,返回完整结果 dict"""
    input_imgs = [b64_to_pil(b) for b in data.get("images", [])]
    start = time.time()
    text, images = get_il().generate(
        data.get("prompt", ""), input_images=input_imgs,
        num_steps=int(data.get("num_steps", 6)), think_mode=False,
        seed=int(data.get("seed", 42)),
    )
    elapsed = time.time() - start
    print(f"[VQA] {elapsed:.1f}s | text_len={len(text)}")
    result = {"text": text, "elapsed": f"{elapsed:.1f}s"}
    if images:
        result["image"] = pil_to_b64(images[0])
    return result

@app.route("/api/vqa", methods=["POST"])
def api_vqa():
    """SSE 流式 VQA:先发 connected → 推理中定时 ping → 打字机逐字输出 → 最后发图片"""
    data = request.json
    result_q: queue.Queue = queue.Queue()

    def _run():
        try:
            result = _do_vqa(data)
            result_q.put(("result", result))
        except Exception as e:
            result_q.put(("error", str(e)))

    threading.Thread(target=_run, daemon=True).start()

    def _sse_gen():
        # 1. 立即发 connected 帧 ------ 让网关开始转发,不再缓冲
        yield f"data: {json.dumps({'type': 'connected', 'msg': '思考中...'}, ensure_ascii=False)}\n\n"
        # 2. 等待后台推理完成,每 10 秒发 ping 保持连接
        while True:
            try:
                evt, payload = result_q.get(timeout=10)
                break
            except queue.Empty:
                yield f": ping\n\n"  # SSE 注释帧,仅用于保活,前端忽略
        # 3. 推理完成,发结果或错误
        if evt == "error":
            yield f"data: {json.dumps({'type': 'error', 'msg': payload}, ensure_ascii=False)}\n\n"
            return
        # 4. 打字机逐字输出文本
        text = payload["text"]
        for i in range(len(text)):
            yield f"data: {json.dumps({'type': 'delta', 'text': text[: i + 1]}, ensure_ascii=False)}\n\n"
            time.sleep(0.02)
        # 5. 完成帧
        final = {k: v for k, v in payload.items() if k != "text"}
        final["type"] = "done"
        final["full_text"] = text
        yield f"data: {json.dumps(final, ensure_ascii=False)}\n\n"

    return Response(
        stream_with_context(_sse_gen()),
        content_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
            "Connection": "keep-alive",
        },
    )

# Chat 专用 system prompt:禁止生成图片,只做纯文本对话
CHAT_SYSTEM_PROMPT = """You are a helpful, concise text-only assistant.

Rules:
- ONLY respond with natural language text. Do NOT generate or reference images.
- NEVER use tags like <image>, <image1>, <image2> or any image-related placeholders.
- NEVER use tags like <think> or </think>. Reason silently and directly give the final answer.
- Answer the user's question clearly and concisely in the same language as the question.
- When asked about a visual task (like drawing or describing an image), describe it in words only --- do not attempt to generate any image.
"""

# ---- Chat ----
@app.route("/api/chat", methods=["POST"])
def api_chat():
    """SSE 流式 Chat:纯文本对话,禁止生成图片"""
    data = request.json
    think_mode = bool(data.get("think_mode", True))
    result_q: queue.Queue = queue.Queue()

    def _run():
        try:
            start = time.time()
            text, images = get_il().generate(
                data.get("prompt", ""), input_images=[],
                num_steps=int(data.get("num_steps", 4)),
                think_mode=think_mode,
                seed=int(data.get("seed", 42)),
                system_message=CHAT_SYSTEM_PROMPT,
            )
            elapsed = time.time() - start
            print(f"[Chat] {elapsed:.1f}s | think_mode={think_mode} | text_len={len(text)}")
            result_q.put(("result", {"text": text, "elapsed": f"{elapsed:.1f}s", "think_mode": think_mode}))
        except Exception as e:
            import traceback
            traceback.print_exc()
            result_q.put(("error", str(e)))

    threading.Thread(target=_run, daemon=True).start()

    def _sse_gen():
        # 1. 立即发 connected 帧
        yield f"data: {json.dumps({'type': 'connected', 'msg': '思考中...'}, ensure_ascii=False)}\n\n"
        # 2. 等待后台推理,每 10 秒 ping
        while True:
            try:
                evt, payload = result_q.get(timeout=10)
                break
            except queue.Empty:
                yield f": ping\n\n"
        # 3. 结果
        if evt == "error":
            yield f"data: {json.dumps({'type': 'error', 'msg': payload}, ensure_ascii=False)}\n\n"
            return
        text = payload["text"]
        # 4. 打字机逐字
        for i in range(len(text)):
            yield f"data: {json.dumps({'type': 'delta', 'text': text[: i + 1]}, ensure_ascii=False)}\n\n"
            time.sleep(0.015)
        # 5. 完成
        yield f"data: {json.dumps({'type': 'done', 'elapsed': payload['elapsed'], 'full_text': text}, ensure_ascii=False)}\n\n"

    return Response(
        stream_with_context(_sse_gen()),
        content_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
            "Connection": "keep-alive",
        },
    )

# ===== 前端 HTML =====
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SenseNova U1.5 Lite · 全功能 Web UI</title>
<style>
*{box-sizing:border-box}
body{font-family:-apple-system,"PingFang SC",sans-serif;margin:0;background:linear-gradient(135deg,#1a1a2e,#16213e 50%,#0f3460);min-height:100vh;color:#333}
.container{max-width:1280px;margin:0 auto;padding:24px}
h1{color:#fff;text-align:center;margin:0 0 24px;font-size:26px;text-shadow:0 2px 8px rgba(0,0,0,.3)}
.subtitle{color:#a0aec0;text-align:center;margin-bottom:24px;font-size:13px}
.tabs{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap}
.tab{padding:10px 20px;background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:10px;cursor:pointer;font-size:14px;font-weight:600;transition:all .2s}
.tab.active{background:#fff;color:#1a1a2e;box-shadow:0 4px 12px rgba(0,0,0,.2)}
.tab:hover:not(.active){background:rgba(255,255,255,.2)}
.card{background:#fff;border-radius:16px;padding:24px;box-shadow:0 10px 40px rgba(0,0,0,.2)}
.tab-panel{display:none}
.tab-panel.active{display:block}
.row{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
label{display:block;font-weight:600;margin:14px 0 6px;color:#374151;font-size:13px}
textarea,select,input[type=number]{width:100%;padding:10px 12px;border:2px solid #e5e7eb;border-radius:10px;font-size:14px;background:#fafafa}
textarea{resize:vertical;min-height:110px}
textarea:focus,select:focus,input:focus{outline:none;border-color:#667eea;background:#fff}
button{width:100%;padding:13px;margin-top:16px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border:none;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;transition:transform .15s,opacity .2s}
button:hover:not(:disabled){transform:translateY(-2px)}
button:disabled{opacity:.55;cursor:not-allowed}
.result-box{text-align:center;min-height:300px;display:flex;flex-direction:column;justify-content:center;align-items:center}
.result-box img{max-width:100%;max-height:60vh;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.1);margin-top:8px}
.result-box .empty{color:#9ca3af;font-size:14px}
.info{margin-top:10px;color:#666;font-size:12px}
.chat-area{max-height:60vh;overflow-y:auto;padding:10px;border:2px solid #e5e7eb;border-radius:12px;background:#f9fafb;min-height:300px}
.msg{margin-bottom:12px;padding:10px 14px;border-radius:10px;font-size:14px;line-height:1.6}
.msg.user{background:#dbeafe;color:#1e40af;margin-left:20%}
.msg.bot{background:#e0e7ff;color:#3730a3;margin-right:10%;white-space:pre-wrap}
.msg.bot img{max-width:100%;border-radius:8px;margin-top:8px}
.think-box{background:#fef3c7;color:#92400e;padding:8px 12px;border-radius:8px;margin-top:8px;font-size:12px;border-left:3px solid #f59e0b}
.upload-area{border:2px dashed #d1d5db;border-radius:12px;padding:20px;text-align:center;cursor:pointer;transition:border-color .2s}
.upload-area:hover{border-color:#667eea}
.upload-area img{max-width:100%;max-height:150px;border-radius:8px;margin-top:8px}
.thinking{display:inline-block;color:#667eea;font-weight:600}
@media (max-width:900px){.row{grid-template-columns:1fr}.row-3{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<div class="container">
  <h1>🚀 SenseNova U1.5 Lite · 全功能 Web UI</h1>
  <p class="subtitle">AMD ROCm 192GB · 8B NEO-unify 原生统一多模态 · T2I / 编辑 / VQA / 对话</p>

  <div class="tabs">
    <button class="tab active" onclick="switchTab('t2i')">🎨 文生图</button>
    <button class="tab" onclick="switchTab('edit')">✏️ 图片编辑</button>
    <button class="tab" onclick="switchTab('vqa')">🔍 图片理解</button>
    <button class="tab" onclick="switchTab('chat')">💬 多模态对话</button>
  </div>

  <!-- ========== T2I ========== -->
  <div id="t2i" class="tab-panel active">
    <div class="card row">
      <div>
        <label>提示词 Prompt</label>
        <textarea id="t2i_prompt">一只猫坐在桌子上,阳光从窗户照进来,电影质感</textarea>
        <div class="row-3">
          <div><label>比例</label><select id="t2i_ratio">
            <option>1:1</option><option>16:9</option><option>9:16</option><option>3:2</option><option>2:3</option></select></div>
          <div><label>CFG</label><input type="number" id="t2i_cfg" value="4.0" step="0.5"></div>
          <div><label>步数</label><input type="number" id="t2i_steps" value="8"></div>
        </div>
        <div class="row-3">
          <div><label>种子</label><input type="number" id="t2i_seed" value="42"></div>
        </div>
        <button id="t2i_btn" onclick="apiT2I()">🚀 生成</button>
      </div>
      <div class="result-box">
        <img id="t2i_img" style="display:none" />
        <div id="t2i_info" class="empty">生成结果将显示在这里</div>
      </div>
    </div>
  </div>

  <!-- ========== EDIT ========== -->
  <div id="edit" class="tab-panel">
    <div class="card row">
      <div>
        <label>上传原图(可多张)</label>
        <div class="upload-area" onclick="document.getElementById('edit_file').click()" id="edit_upload">
          📷 点击上传图片
          <input type="file" id="edit_file" accept="image/*" multiple style="display:none" onchange="handleEditUpload(this)">
        </div>
        <div id="edit_previews" style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap"></div>
        <label>编辑指令</label>
        <textarea id="edit_prompt" placeholder="例如:把外套换成红色,保持背景不变">把背景换成日落场景,人物保持不变</textarea>
        <div class="row-3">
          <div><label>输出比例</label><select id="edit_ratio">
            <option>跟随原图</option><option>1:1</option><option>16:9</option><option>9:16</option></select></div>
          <div><label>CFG</label><input type="number" id="edit_cfg" value="4.0" step="0.5"></div>
          <div><label>步数</label><input type="number" id="edit_steps" value="8"></div>
        </div>
        <button id="edit_btn" onclick="apiEdit()">✏️ 编辑</button>
      </div>
      <div class="result-box">
        <img id="edit_img" style="display:none" />
        <div id="edit_info" class="empty">编辑结果将显示在这里</div>
      </div>
    </div>
  </div>

  <!-- ========== VQA ========== -->
  <div id="vqa" class="tab-panel">
    <div class="card row">
      <div>
        <label>上传图片</label>
        <div class="upload-area" onclick="document.getElementById('vqa_file').click()" id="vqa_upload">
          📷 点击上传要理解的图片
          <input type="file" id="vqa_file" accept="image/*" style="display:none" onchange="handleVqaUpload(this)">
        </div>
        <img id="vqa_preview" style="display:none;max-height:200px;margin-top:10px;border-radius:10px" />
        <label>问题</label>
        <textarea id="vqa_prompt" placeholder="这张图里有什么?描述一下细节">这张图片里有什么?详细描述一下</textarea>
        <div class="row-3">
          <div><label>种子</label><input type="number" id="vqa_seed" value="42"></div>
          <div><label>步数</label><input type="number" id="vqa_steps" value="4"></div>
        </div>
        <button id="vqa_btn" onclick="apiVqa()">� 分析</button>
      </div>
      <div class="result-box" style="text-align:left">
        <div id="vqa_text" style="display:none;white-space:pre-wrap;padding:14px;background:#f9fafb;border-radius:10px;line-height:1.7"></div>
        <img id="vqa_gen_img" style="display:none;margin-top:12px;max-height:300px;border-radius:10px" />
        <div id="vqa_info" class="empty">分析结果将显示在这里</div>
      </div>
    </div>
  </div>

  <!-- ========== CHAT ========== -->
  <div id="chat" class="tab-panel">
    <div class="card">
      <div class="chat-area" id="chat_area">
        <div class="msg bot">👋 你好!我是 SenseNova U1.5 Lite 多模态助手。可以问我问题,也可以让我生成图片!</div>
      </div>
      <div style="display:flex;gap:10px;margin-top:14px">
        <textarea id="chat_input" placeholder="输入问题或描述...(支持多模态,模型会自动判断)" style="flex:1;min-height:50px" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();apiChat()}"></textarea>
        <button id="chat_btn" onclick="apiChat()" style="width:auto;padding:0 24px">发送</button>
      </div>
      <div style="display:flex;gap:16px;margin-top:8px;align-items:center">
        <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:#4b5563">
          <input type="checkbox" id="chat_deep" checked style="width:16px;height:16px;cursor:pointer">
          🧠 深度思考 (think_mode)
        </label>
        <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:#4b5563">
          <input type="checkbox" id="chat_wide" style="width:16px;height:16px;cursor:pointer">
          🌐 联网搜索(预留)
        </label>
      </div>
      <div id="chat_info" class="info" style="margin-top:6px"></div>
    </div>
  </div>

</div>

<script>
const editImgs = []; // 存 base64

function switchTab(name){
  document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.textContent.includes({t2i:'文生图',edit:'图片编辑',vqa:'图片理解',chat:'多模态对话'}[name])));
  document.querySelectorAll('.tab-panel').forEach(p=>p.classList.toggle('active',p.id===name));
}

// 通用轮询函数:post提交 → 拿 task_id → 轮询 /api/status/<id> → 返回 result
async function submitAndPoll(endpoint, payload, setLoading){
  const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
  const {task_id}=await r.json();
  return new Promise((resolve,reject)=>{
    const timer=setInterval(async()=>{
      const sr=await fetch('/api/status/'+task_id);
      const td=await sr.json();
      if(td.status==='done'){clearInterval(timer);resolve(td.result);}
      else if(td.status==='error'){clearInterval(timer);reject(new Error(td.error));}
      else if(setLoading){setLoading();}
    },2000);
  });
}

async function apiT2I(){
  const btn=document.getElementById('t2i_btn');btn.disabled=true;btn.textContent='生成中...';
  document.getElementById('t2i_info').textContent='⏳ 提交任务成功,推理中...';
  document.getElementById('t2i_img').style.display='none';
  try{
    const d=await submitAndPoll('/api/t2i',{
      prompt:document.getElementById('t2i_prompt').value,
      ratio:document.getElementById('t2i_ratio').value,
      cfg_scale:+document.getElementById('t2i_cfg').value,
      num_steps:+document.getElementById('t2i_steps').value,
      seed:+document.getElementById('t2i_seed').value,
    },()=>{document.getElementById('t2i_info').textContent='⏳ 推理中...';});
    document.getElementById('t2i_img').src=d.image;document.getElementById('t2i_img').style.display='block';
    document.getElementById('t2i_info').textContent='✅ '+d.elapsed+' | '+d.size;
  }catch(e){document.getElementById('t2i_info').textContent='❌ '+e.message;}
  btn.disabled=false;btn.textContent='🚀 生成';
}

function handleEditUpload(input){
  editImgs.length=0;const pre=document.getElementById('edit_previews');pre.innerHTML='';
  [...input.files].forEach(f=>{
    const r=new FileReader();r.onload=e=>{editImgs.push(e.target.result);
      const i=document.createElement('img');i.src=e.target.result;i.style.height='60px';i.style.borderRadius='6px';pre.appendChild(i);};
    r.readAsDataURL(f);
  });
}

async function apiEdit(){
  if(editImgs.length===0){alert('请先上传图片');return}
  const btn=document.getElementById('edit_btn');btn.disabled=true;btn.textContent='编辑中...';
  document.getElementById('edit_info').textContent='⏳ 提交任务成功,编辑推理中...';
  document.getElementById('edit_img').style.display='none';
  try{
    const d=await submitAndPoll('/api/edit',{
      prompt:document.getElementById('edit_prompt').value,
      images:editImgs,
      ratio:document.getElementById('edit_ratio').value,
      cfg_scale:+document.getElementById('edit_cfg').value,
      num_steps:+document.getElementById('edit_steps').value,
      seed:42,
    });
    document.getElementById('edit_img').src=d.image;document.getElementById('edit_img').style.display='block';
    document.getElementById('edit_info').textContent='✅ '+d.elapsed;
  }catch(e){document.getElementById('edit_info').textContent='❌ '+e.message;}
  btn.disabled=false;btn.textContent='✏️ 编辑';
}

let vqaImg='';
function handleVqaUpload(input){
  const r=new FileReader();r.onload=e=>{vqaImg=e.target.result;
    const p=document.getElementById('vqa_preview');p.src=vqaImg;p.style.display='block';};
  r.readAsDataURL(input.files[0]);
}

// ===== 通用 SSE 流式消费:POST 请求 → 逐字打字机渲染 =====
async function streamPost(endpoint, payload, onConnected, onDelta, onDone, onError){
  const res=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
  if(!res.ok){onError&&onError('HTTP '+res.status);return}
  const reader=res.body.getReader();
  const decoder=new TextDecoder();
  let buffer='', fullText='';
  while(true){
    const {done,value}=await reader.read();
    if(done)break;
    buffer+=decoder.decode(value,{stream:true});
    // 解析 SSE 帧(data: {...}\n\n)
    let idx;
    while((idx=buffer.indexOf('\n\n'))!==-1){
      const frame=buffer.slice(0,idx);
      buffer=buffer.slice(idx+2);
      const line=frame.split('\n').find(l=>l.startsWith('data:'));
      if(!line)continue;  // 可能是 : ping 注释帧,跳过
      const data=JSON.parse(line.slice(5).trim());
      if(data.type==='connected'){onConnected&&onConnected(data.msg);}
      else if(data.type==='delta'){onDelta&&onDelta(data.text);}
      else if(data.type==='done'){onDone&&onDone(data);return;}
      else if(data.type==='error'){onError&&onError(data.msg);return;}
    }
  }
}

async function apiVqa(){
  if(!vqaImg){alert('请先上传图片');return}
  const btn=document.getElementById('vqa_btn');btn.disabled=true;btn.textContent='分析中...';
  const textEl=document.getElementById('vqa_text');
  textEl.style.display='block';textEl.textContent='⏳ 连接中...';
  document.getElementById('vqa_gen_img').style.display='none';
  document.getElementById('vqa_info').textContent='';
  try{
    await streamPost('/api/vqa',{
      prompt:document.getElementById('vqa_prompt').value,
      images:[vqaImg],
      num_steps:+document.getElementById('vqa_steps').value,
      seed:+document.getElementById('vqa_seed').value,
    },(msg)=>{textEl.textContent='⏳ '+msg;},
    (text)=>{textEl.textContent=text;},
    (final)=>{
      if(final.image){document.getElementById('vqa_gen_img').src=final.image;document.getElementById('vqa_gen_img').style.display='block'}
      document.getElementById('vqa_info').textContent='✅ '+final.elapsed;
    },(err)=>{document.getElementById('vqa_info').textContent='❌ '+err;});
  }catch(e){document.getElementById('vqa_info').textContent='❌ '+e.message;}
  btn.disabled=false;btn.textContent='🔍 分析';
}

async function apiChat(){
  const btn=document.getElementById('chat_btn');btn.disabled=true;btn.textContent='...';
  const input=document.getElementById('chat_input');const area=document.getElementById('chat_area');
  const text=input.value.trim();if(!text){btn.disabled=false;btn.textContent='发送';return}
  const thinkMode=document.getElementById('chat_deep').checked;
  const um=document.createElement('div');um.className='msg user';
  um.textContent=text+(thinkMode?' 🧠':'');area.appendChild(um);
  input.value='';area.scrollTop=area.scrollHeight;
  const bm=document.createElement('div');bm.className='msg bot';bm.textContent='⏳ 连接中...';area.appendChild(bm);area.scrollTop=area.scrollHeight;
  try{
    await streamPost('/api/chat',{prompt:text,think_mode:thinkMode},
    (msg)=>{bm.textContent='⏳ '+msg;area.scrollTop=area.scrollHeight;},
    (delta)=>{bm.textContent=delta;area.scrollTop=area.scrollHeight;},
    (final)=>{document.getElementById('chat_info').textContent='✅ '+final.elapsed+(final.think_mode?' 🧠':'');},
    (err)=>{bm.textContent='❌ '+err;});
  }catch(e){bm.textContent='❌ 出错: '+e.message}
  btn.disabled=false;btn.textContent='发送';area.scrollTop=area.scrollHeight;
}
</script>
</body>
</html>
"""

if __name__ == "__main__":
    print("\n🌐 Web UI: http://0.0.0.0:7860  (threaded=True, 异步轮询模式)")
    print("功能: 🎨文生图  ✏️图片编辑  🔍图片理解VQA  💬多模态对话\n")
    app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)
Tab 后端类 协议
🎨 文生图 SenseNovaU1T2I 异步任务轮询
✏️ 图片编辑 SenseNovaU1Editing 异步任务轮询
🔍 图片理解 VQA SenseNovaU1Interleave SSE 打字机
💬 多模态对话 SenseNovaU1Interleave SSE 打字机
相关推荐
蒲公英eric11 小时前
从页面检查到功能验证:DVWA 授权绕过模块完整漏洞分析教程
web安全·ai·ctf·dvwa·ai安全·授权绕过模块
AI老陈说12 小时前
Nano Banana 2 AI 角色一致性怎么保持?Flux Art 同一角色换动作与版本管理
ai·ai工具·ai生图
三声三视15 小时前
审计清单函数名写成 def?tri-checklist 的 diff 解析在 Python 改名场景下悄悄翻车
人工智能·ai·skillhub·tri-checklist·tri-skills
VIP_CQCRE16 小时前
用 Ace Data Cloud 接入 Kling Motion:让 AI 视频生成从“好看”走向“可控”
ai·api·视频生成·kling·acedatacloud
打破砂锅问到底00716 小时前
115 行把 Qwen3-8B 跑进浏览器:WebLLM 本地推理实战
人工智能·ai·llm
TechEdu20260619 小时前
[人工智能]AI芯片家族:英伟达、AMD、英特尔、高通与华为
人工智能·ai
尘中远21 小时前
给C++工业软件搭建 Agent
开发语言·c++·qt·ai·agent
tachibana21 天前
复杂的 RAG 范式
数据库·人工智能·ai·大模型·agent
RobinDevNotes1 天前
RoCEv2如何扛起大模型训练网络
linux·网络·ai·网络linux