你有没有想过,只用一张普通照片,就能让 AI 自动生成一张穿着定制队服、摆出专业姿势的冰球运动员插画?这听起来像科幻,但在今天,借助 Coze 平台的工作流能力,它已经可以轻松实现。
但问题来了:我们有了这个神奇的工作流,前端该怎么调用它?
第一步:直觉会骗人——“直接传图不就行了吗?”
假设你在 Coze 上搭建了一个工作流,它接收一张用户上传的照片,再加上几个参数(比如队服颜色、号码、风格等),然后输出一张风格化后的冰球运动员图像。作为前端开发者,你的第一反应可能是:
“那我用
fetch把图片和参数一起 POST 给工作流接口,不就完事了?”
听起来很合理。可当你真的去试,你会发现:Coze 的工作流并不接受原始文件、Base64 字符串,甚至不认本地路径。你传过去的图片,它根本“看不见”。
为什么?因为 Coze 的工作流运行在一个隔离的云端环境里,它无法访问你用户的硬盘,也无法解析未经注册的二进制数据。它只认一种东西:文件 ID。
第二步:真相浮现——先上传,再引用
于是我们意识到:要让工作流“看到”这张图,必须先把图交给 Coze 自己的文件系统。
流程变成了两步:
- 先把用户选中的图片 上传到 Coze 的文件服务,拿到一个唯一的
file_id; - 再调用工作流时,把
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_id 和 parameters,而文件参数必须以 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
})
});
为什么 picture 要 JSON.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>