从前端上传一张图,到 AI 生成一个冰球运动员:如何用 Vue 调用 Coze 工作流?

74 阅读5分钟

你有没有想过,只用一张普通照片,就能让 AI 自动生成一张穿着定制队服、摆出专业姿势的冰球运动员插画?这听起来像科幻,但在今天,借助 Coze 平台的工作流能力,它已经可以轻松实现。

但问题来了:我们有了这个神奇的工作流,前端该怎么调用它?


第一步:直觉会骗人——“直接传图不就行了吗?”

假设你在 Coze 上搭建了一个工作流,它接收一张用户上传的照片,再加上几个参数(比如队服颜色、号码、风格等),然后输出一张风格化后的冰球运动员图像。作为前端开发者,你的第一反应可能是:

“那我用 fetch 把图片和参数一起 POST 给工作流接口,不就完事了?”

听起来很合理。可当你真的去试,你会发现:Coze 的工作流并不接受原始文件、Base64 字符串,甚至不认本地路径。你传过去的图片,它根本“看不见”。

为什么?因为 Coze 的工作流运行在一个隔离的云端环境里,它无法访问你用户的硬盘,也无法解析未经注册的二进制数据。它只认一种东西:文件 ID


第二步:真相浮现——先上传,再引用

于是我们意识到:要让工作流“看到”这张图,必须先把图交给 Coze 自己的文件系统

流程变成了两步:

  1. 先把用户选中的图片 上传到 Coze 的文件服务,拿到一个唯一的 file_id
  2. 再调用工作流时,file_id 作为参数传进去,Coze 内部会自动根据这个 ID 拉取文件内容。

这就像你去图书馆借书——不能直接把家里的书塞给管理员,而是先登记入库,拿到编号,再凭编号调阅。


第三步:前端怎么做?从 <input>FormData

在 Vue 3 中,我们首先通过 <input type="file"> 获取用户选择的文件:

<input type="file" ref="fileInput" accept="image/*" @change="handleUpload" />

当用户选中图片后,我们拿到的是一个 File 对象。但要上传它,不能直接塞进 JSON,而要用 FormData ——这是浏览器专门用于表单文件上传的标准方式:

const formData = new FormData();
formData.append('file', fileInput.value.files[0]);

接着,向 Coze 的文件上传接口发起请求:

const res = await fetch('https://api.coze.cn/v1/files/upload', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${import.meta.env.VITE_PAT_TOKEN}`
  },
  body: formData
});
const { data } = await res.json();
const fileId = data.id; // 这就是我们要的 file_id

注意:这里必须使用 FormData,不能用 JSON.stringify,因为文件是二进制数据,只有 multipart/form-data 编码才能正确传输。


第四步:带着 file_id 去唤醒工作流

现在,我们有了 file_id,也收集好了其他参数(队服颜色、号码、风格等)。下一步,就是调用工作流。

Coze 的工作流接口要求请求体包含 workflow_idparameters,而文件参数必须以 JSON 字符串形式传入,例如:

const parameters = {
  picture: JSON.stringify({ file_id: fileId }), // 注意:这里是字符串!
  style: '日漫',
  uniform_color: '红',
  uniform_number: 10,
  position: 1,
  shooting_hand: 0
};

const response = await fetch('https://api.coze.cn/v1/workflow/run', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${patToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    workflow_id: '7584046188619284514',
    parameters
  })
});

为什么 pictureJSON.stringify?因为 Coze 当前的设计中,文件型参数在传输时需序列化为字符串,内部再反序列化。这是一个容易踩坑的细节。


第五步:拿到结果,渲染图片

工作流执行成功后,返回的数据中通常包含生成图片的 URL。我们只需将其赋值给响应式变量,Vue 就会自动更新页面:

const result = await response.json();
const outputData = JSON.parse(result.data);
imgUrl.value = outputData.data; // 渲染到 <img :src="imgUrl" />

至此,从用户点击上传,到 AI 生成专属冰球运动员图像,整个链路打通。


结语:看似多了一步,实则更稳

有人可能会觉得:“为什么要多一次上传?不能一步到位吗?”
但正是这种“先注册、再引用”的机制,让 Coze 能安全地管理文件生命周期、控制权限、追踪来源。对前端而言,虽然多写几行代码,却换来稳定可靠的集成体验。

更重要的是,理解这一流程,就掌握了调用任何带文件输入的 AI 工作流的通用方法——无论是图像生成、文档解析,还是音视频处理。


具体代码实例,拿来主义受苦了😭,这段代码无法直接拿来用因为调用工作流需要token,但是可以借鉴其中通用的写法运用于自己的调用工作流中

<template>
  <div class="container">
    <div class="input">
      <div class="file-input">
        <input 
          type="file" 
          ref="uploadImage" 
          accept="image/*"
          @change="updateImageData"
          required
        />
      </div>
      <img :src="imgPreview" alt="" v-if="imgPreview"/>
      <div class="settings">
        <div class="selection">
          <label>队服编号:</label>
          <input type="number" v-model="uniform_number"/>
        </div>
        <div class="selection">
          <label>队服颜色:</label>
          <select v-model="uniform_color">
            <option value="红">红</option>
            <option value="蓝">蓝</option>
            <option value="绿">绿</option>
            <option value="白">白</option>
            <option value="黑">黑</option>
          </select>
        </div>
      </div>
      <div class="settings">
        <div class="selection">
          <label>位置:</label>
          <select v-model="position">
            <option value="0">守门员</option>
            <option value="1">前锋</option>
            <option value="2">后卫</option>
          </select>
        </div>
        <div class="selection">
          <label>持杆:</label>
          <select v-model="shooting_hand">
            <option value="0">左手</option>
            <option value="1">右手</option>
          </select>
        </div>
        <div class="selection">
          <label>风格:</label>
          <select v-model="style">
            <option value="写实">写实</option>
            <option value="乐高">乐高</option>
            <option value="国漫">国漫</option>
            <option value="日漫">日漫</option>
            <option value="油画">油画</option>
            <option value="涂鸦">涂鸦</option>
            <option value="素描">素描</option>
          </select>
        </div>
      </div>
      <div class="generate">
        <button @click="generate">生成</button>
      </div>
    </div>
    <div class="output">
      <div class="generated">
        <img :src="imgUrl" alt="" v-if="imgUrl"/>
        <div v-if="status">{{ status }}</div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const patToken = import.meta.env.VITE_PAT_TOKEN;
const uploadUrl = 'https://api.coze.cn/v1/files/upload';
const workflowUrl = 'https://api.coze.cn/v1/workflow/run';
const workflow_id = '7584046188619284514';

const uniform_number = ref(10);
const uniform_color = ref('红');
const position = ref(0);
const shooting_hand = ref(0);
const style = ref('写实');

const status = ref('');
const imgUrl = ref('');

const generate = async () => {
  status.value = '图片上传中...';
  const file_id = await uploadFile();
  if (!file_id) {
    return;
  }
  status.value = "图片上传成功,正在生成中...";

  const parameters = {
    picture: JSON.stringify({
      file_id,
    }),
    style: style.value,
    uniform_color: uniform_color.value,
    uniform_number: uniform_number.value,
    position: position.value,
    shooting_hand: shooting_hand.value,
  };

  const res = await fetch(workflowUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${patToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      workflow_id,
      parameters
    }),
  });

  const ret = await res.json();
  if (ret.code !== 0) {
    status.value = ret.msg;
    return;
  }

  const data = JSON.parse(ret.data);
  status.value = '';
  imgUrl.value = data.data;
};

const uploadFile = async () => {
  const formData = new FormData();
  const input = uploadImage.value;
  if (!input.files || input.files.length <= 0) {
    return;
  }
  formData.append('file', input.files[0]);

  const res = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  });

  const ret = await res.json();
  if (ret.code !== 0) {
    status.value = ret.msg;
    return;
  }
  return ret.data.id;
};

const uploadImage = ref(null);
const imgPreview = ref('');

onMounted(() => {
  console.log(uploadImage.value);
});

const updateImageData = () => {
  const input = uploadImage.value;
  if (!input.files || input.files.length === 0) {
    return;
  }
  const file = input.files[0];
  const reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onload = (e) => {
    imgPreview.value = e.target.result;
  };
};
</script>

<style scoped>
.container {
  display: flex;
  flex-direction: row;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: .85rem;
}

.preview {
  max-width: 300px;
  margin-bottom: 20px;
}

.settings {
  display: flex;
  flex-direction: row;
  align-items: start;
  justify-content: start;
  margin-top: 1rem;
}

.selection {
  width: 100%;
  text-align: left;
}

.selection input {
  width: 50px;
}

.input {
  display: flex;
  flex-direction: column;
  min-width: 330px;
}

.file-input {
  display: flex;
  margin-bottom: 16px;
}

.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}

button {
  padding: 10px;
  min-width: 200px;
  margin-left: 6px;
  border: solid 1px black;
}

.generate {
  width: 100%;
  margin-top: 16px;
}

.generated {
  width: 400px;
  height: 400px;
  border: solid 1px black;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
}

.output img {
  width: 100%;
}
</style>