feat: 添加私域虚拟人素材功能

- init.py: 新增6个虚拟人函数(create/list/upload/sync等),注册到ServerEnv
- api_mapping: 新增create_group→CreateAssetGroup映射
- 外部API: 5个rl_virtual_*.dspy端点(创建组合/列表/上传/素材列表/状态)
- 前端API: 4个submit/get dspy端点(UI表单提交和数据获取)
- UI页面: 3个页面(创建组合/上传素材/查看素材)
- index.ui: 左侧导航新增虚拟人素材分区(3个按钮)
- load_path.py: RBAC新增virtual页面和api/%路径
- docs: api_downapp.md新增虚拟人API文档(5个端点)
This commit is contained in:
yumoqing 2026-06-02 15:25:14 +08:00
parent fa8b35072d
commit 925f58b025
17 changed files with 1286 additions and 1 deletions

View File

@ -302,6 +302,195 @@ curl -X POST 'https://token.opencomputing.cn/reallife_asset/api/rl_assets.dspy'
---
## 私域虚拟人素材 API
> 虚拟人素材AIGC与真人认证素材使用独立的接口。虚拟人素材无需真人认证流程直接创建素材组合后上传即可。
### 业务流程
1. **创建素材组合**:调用 `rl_virtual_create_group.dspy` 创建 AIGC 类型的素材组合。
2. **查询素材组合**:调用 `rl_virtual_groups.dspy` 获取当前机构下所有虚拟人素材组合。
3. **上传素材**:调用 `rl_virtual_upload.dspy` 上传虚拟人素材到指定组合。
4. **查询素材列表**:调用 `rl_virtual_assets.dspy` 获取组合下的素材。
5. **状态同步**:调用 `rl_virtual_status.dspy` 查询素材处理状态。
---
## 5. 创建虚拟人素材组合
**Endpoint**: `/reallife_asset/api/rl_virtual_create_group.dspy`
### 请求参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `vendor` | 是 | 供应商标识,如 `volcengine` |
| `name` | 是 | 素材组合名称 |
| `description` | 否 | 组合描述 |
| `project_name` | 否 | 项目名称,默认 `default` |
### 请求示例
```http
POST /reallife_asset/api/rl_virtual_create_group.dspy
Authorization: Bearer ***
Content-Type: application/json
{
"vendor": "volcengine",
"name": "虚拟角色A",
"description": "用于Seedance 2.0视频生成"
}
```
### 返回示例
```json
{
"status": "ok",
"data": {
"id": "local-id-xxx",
"vendor_group_id": "volc-group-xxx"
}
}
```
### curl 示例
```bash
curl -X POST 'https://token.opencomputing.cn/reallife_asset/api/rl_virtual_create_group.dspy' \
-H 'Authorization: Bearer *** \
-H 'Content-Type: application/json' \
-d '{"vendor":"volcengine","name":"虚拟角色A","description":"测试组合"}'
```
---
## 6. 查询虚拟人素材组合列表
**Endpoint**: `/reallife_asset/api/rl_virtual_groups.dspy`
自动从 Bearer Token 获取 `org_id`,返回当前机构下所有虚拟人素材组合。
### 请求示例
```http
POST /reallife_asset/api/rl_virtual_groups.dspy
Authorization: Bearer ***
```
### 返回示例
```json
{
"status": "ok",
"data": {
"groups": [
{
"vendor_group_id": "volc-group-xxx",
"vendor": "volcengine",
"name": "虚拟角色A",
"status": "active",
"create_time": "2026-06-02 12:00:00"
}
]
}
}
```
---
## 7. 上传虚拟人素材
**Endpoint**: `/reallife_asset/api/rl_virtual_upload.dspy`
### 请求参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `vendor_group_id` | 是 | 素材组合 ID |
| `source_url` | 是 | 素材 URL 或 base64 data URI |
| `asset_type` | 否 | Image/Video/Audio自动检测 |
| `name` | 否 | 素材名称 |
### 请求示例
```http
POST /reallife_asset/api/rl_virtual_upload.dspy
Authorization: Bearer ***
Content-Type: application/json
{
"vendor_group_id": "volc-group-xxx",
"source_url": "https://example.com/avatar.jpg",
"asset_type": "Image",
"name": "虚拟人正面照"
}
```
### 返回示例
```json
{
"status": "ok",
"data": {
"id": "asset-local-xxx",
"vendor_asset_id": "volc-asset-xxx",
"status": "Processing"
}
}
```
### curl 示例
```bash
curl -X POST 'https://token.opencomputing.cn/reallife_asset/api/rl_virtual_upload.dspy' \
-H 'Authorization: Bearer *** \
-H 'Content-Type: application/json' \
-d '{"vendor_group_id":"volc-group-xxx","source_url":"https://example.com/avatar.jpg","asset_type":"Image","name":"虚拟人正面照"}'
```
---
## 8. 查询虚拟人素材列表
**Endpoint**: `/reallife_asset/api/rl_virtual_assets.dspy`
### 请求参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `vendor_group_id` | 是 | 素材组合 ID |
### 返回示例
```json
{
"status": "ok",
"data": {
"assets": [
{
"id": "asset-local-xxx",
"vendor_asset_id": "volc-asset-xxx",
"name": "虚拟人正面照",
"asset_type": "Image",
"status": "Active",
"url": "https://... (临时下载链接)",
"create_time": "2026-06-02 12:30:00"
}
],
"total": 1
}
}
```
---
## 9. 查询虚拟人素材处理状态
**Endpoint**: `/reallife_asset/api/rl_virtual_status.dspy`
### 请求参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `asset_id` | 是 | 素材 ID |
### 返回示例
```json
{
"status": "ok",
"data": {
"status": "Active",
"url": "https://... (临时下载链接)"
}
}
```
---
## 错误代码说明
| 错误信息 | 原因 | 解决方案 |
@ -312,3 +501,5 @@ curl -X POST 'https://token.opencomputing.cn/reallife_asset/api/rl_assets.dspy'
| `素材不存在或无权访问` | `asset_id` 无效或归属错误 | 检查 ID 是否正确 |
| `未找到对应的认证会话` | `BytedToken` 无效 | 检查回调参数 |
| `尚未完成认证或认证失败` | 认证未完成 | 等待用户完成 H5 认证 |
| `vendor和name为必填参数` | 创建虚拟人组合缺少参数 | 补充 vendor 和 name |
| `创建素材组合失败未返回组合ID` | 供应商 API 未返回有效 ID | 检查供应商配置和网络 |

View File

@ -745,6 +745,365 @@ async def rl_query_groups(org_id):
return {"success": True, "groups": groups}
# ============================================================
# Virtual Human (私域虚拟人) Asset Management — GroupType: AIGC
# ============================================================
async def rl_create_virtual_group(org_id, vendor, name, description="",
project_name="default", user_id=None):
"""Create AIGC virtual human asset group via CreateAssetGroup API."""
params = {
"Name": name,
"Description": description,
"GroupType": "AIGC",
"ProjectName": project_name,
}
result = await _call_vendor(vendor, "create_group", params)
if "error" in result or "Error" in result:
return {"success": False, "message": result.get("error", result.get("Message", "API调用失败"))}
vendor_group_id = result.get("Id", result.get("Result", {}).get("Id", ""))
if not vendor_group_id:
return {"success": False, "message": "创建素材组合失败未返回组合ID"}
# Save to rl_asset_group
dbname = _get_dbname()
db = DBPools()
gid = getID()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
async with db.sqlorContext(dbname) as sor:
await sor.C("rl_asset_group", {
"id": gid,
"org_id": org_id,
"vendor": vendor,
"name": name,
"title": name,
"description": description,
"group_type": "AIGC",
"project_name": project_name,
"status": "active",
"vendor_group_id": vendor_group_id,
"byted_token": "",
"h5_link": "",
"callback_url": "",
"created_by": user_id or "",
"create_time": now,
"update_time": now,
})
# Register in rl_org_group for org isolation
mapping_id = getID()
existing = await sor.R("rl_org_group", {
"org_id": org_id,
"vendor": vendor,
"vendor_group_id": vendor_group_id,
})
if not existing:
await sor.C("rl_org_group", {
"id": mapping_id,
"org_id": org_id,
"vendor": vendor,
"vendor_group_id": vendor_group_id,
"local_group_id": gid,
"name": name,
"status": "active",
"create_time": now,
})
return {
"success": True,
"id": gid,
"vendor_group_id": vendor_group_id,
}
async def rl_list_virtual_groups(org_id):
"""List virtual human (AIGC) groups for an org."""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R("rl_org_group", {"org_id": org_id})
if not recs:
return {"success": True, "groups": []}
groups = []
for r in recs:
local_group_id = r.local_group_id
# Check group_type from rl_asset_group
grp_recs = await sor.R("rl_asset_group", {"id": local_group_id})
if grp_recs and getattr(grp_recs[0], "group_type", "") == "AIGC":
groups.append({
"vendor_group_id": r.vendor_group_id,
"vendor": r.vendor,
"name": getattr(r, "name", ""),
"status": r.status,
"create_time": getattr(r, "create_time", ""),
})
return {"success": True, "groups": groups}
async def rl_sync_virtual_groups_from_vendor(org_id, vendor, project_name="default"):
"""Sync AIGC asset groups from vendor to local DB."""
params = {
"Filter": {"GroupType": "AIGC"},
"PageNumber": 1,
"PageSize": 100,
}
result = await _call_vendor(vendor, "list_groups", params)
items = result.get("Items", result.get("Result", {}).get("Items", []))
synced = 0
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
for item in items:
vgid = item.get("Id", "")
if not vgid:
continue
existing = await sor.R("rl_asset_group", {
"vendor": vendor,
"vendor_group_id": vgid,
})
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if existing:
await sor.U("rl_asset_group", {
"id": existing[0].id,
"name": item.get("Name", ""),
"title": item.get("Title", item.get("Name", "")),
"description": item.get("Description", ""),
"update_time": now,
})
local_gid = existing[0].id
else:
gid = getID()
await sor.C("rl_asset_group", {
"id": gid,
"org_id": org_id,
"vendor": vendor,
"vendor_group_id": vgid,
"name": item.get("Name", ""),
"title": item.get("Title", item.get("Name", "")),
"description": item.get("Description", ""),
"group_type": "AIGC",
"project_name": item.get("ProjectName", project_name),
"status": "active",
"create_time": item.get("CreateTime", now),
"update_time": now,
})
local_gid = gid
# Ensure rl_org_group mapping exists
og_existing = await sor.R("rl_org_group", {
"org_id": org_id,
"vendor": vendor,
"vendor_group_id": vgid,
})
if not og_existing:
await sor.C("rl_org_group", {
"id": getID(),
"org_id": org_id,
"vendor": vendor,
"vendor_group_id": vgid,
"local_group_id": local_gid,
"name": item.get("Name", ""),
"status": "active",
"create_time": now,
})
synced += 1
return {"success": True, "synced": synced}
async def rl_sync_virtual_assets_from_vendor(org_id, local_group_id):
"""Sync assets for a virtual group from vendor to local DB."""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R("rl_asset_group", {"id": local_group_id})
if not recs:
return {"success": False, "message": "素材组合不存在"}
grp = recs[0]
vendor = grp.vendor
vendor_group_id = grp.vendor_group_id
project_name = grp.project_name or "default"
if not vendor_group_id:
return {"success": False, "message": "无供应商端组合ID"}
params = {
"Filter": {"GroupType": "AIGC", "GroupIds": [vendor_group_id]},
"PageNumber": 1,
"PageSize": 100,
}
result = await _call_vendor(vendor, "list_assets", params)
items = result.get("Items", result.get("Result", {}).get("Items", []))
synced = 0
async with db.sqlorContext(dbname) as sor:
for item in items:
vaid = item.get("Id", "")
if not vaid:
continue
existing = await sor.R("rl_asset", {
"vendor": vendor,
"vendor_asset_id": vaid,
})
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if existing:
await sor.U("rl_asset", {
"id": existing[0].id,
"status": item.get("Status", ""),
"url": item.get("URL", ""),
"name": item.get("Name", existing[0].name),
"update_time": now,
})
else:
aid = getID()
await sor.C("rl_asset", {
"id": aid,
"org_id": org_id,
"group_id": local_group_id,
"vendor": vendor,
"vendor_asset_id": vaid,
"asset_type": item.get("AssetType", "Image"),
"name": item.get("Name", ""),
"status": item.get("Status", "Processing"),
"url": item.get("URL", ""),
"asset_uri": f"asset://{vaid}",
"project_name": item.get("ProjectName", project_name),
"create_time": item.get("CreateTime", now),
"update_time": now,
})
synced += 1
return {"success": True, "synced": synced}
async def rl_list_virtual_assets_client(org_id, vendor_group_id):
"""Client API: List assets for a virtual group, sync from vendor first."""
dbname = _get_dbname()
db = DBPools()
# Validate org owns this group
async with db.sqlorContext(dbname) as sor:
grp_recs = await sor.R("rl_org_group", {
"org_id": org_id,
"vendor_group_id": vendor_group_id,
})
if not grp_recs:
return {"success": False, "message": "无效的素材组合或无权访问"}
vendor = grp_recs[0].vendor
local_group_id = grp_recs[0].local_group_id
# Verify it's an AIGC group
async with db.sqlorContext(dbname) as sor:
grp_check = await sor.R("rl_asset_group", {"id": local_group_id})
if grp_check and getattr(grp_check[0], "group_type", "") != "AIGC":
return {"success": False, "message": "该组合不是虚拟人素材组合"}
# Sync from vendor
try:
await rl_sync_virtual_assets_from_vendor(org_id, local_group_id)
except Exception as e:
debug(f"rl_list_virtual_assets_client: vendor sync failed: {e}")
# Query local
async with db.sqlorContext(dbname) as sor:
recs = await sor.R("rl_asset", {
"org_id": org_id,
"group_id": local_group_id,
})
if not recs:
return {"success": True, "assets": [], "total": 0}
assets = []
for r in recs:
assets.append({
"id": r.id,
"name": getattr(r, "name", ""),
"status": getattr(r, "status", ""),
"url": getattr(r, "url", ""),
"asset_type": getattr(r, "asset_type", ""),
"vendor_asset_id": getattr(r, "vendor_asset_id", ""),
"create_time": getattr(r, "create_time", ""),
})
return {"success": True, "assets": assets, "total": len(assets)}
async def rl_upload_virtual_asset(org_id, vendor_group_id, source_url,
asset_type, name, user_id):
"""Upload asset to a virtual (AIGC) group with org validation."""
dbname = _get_dbname()
db = DBPools()
# Validate org owns this group
async with db.sqlorContext(dbname) as sor:
recs = await sor.R("rl_org_group", {
"org_id": org_id,
"vendor_group_id": vendor_group_id,
})
if not recs:
return {"success": False, "message": "无效的素材组合ID或无权访问"}
local_group_id = recs[0].local_group_id
vendor = recs[0].vendor
# Get project_name from group
async with db.sqlorContext(dbname) as sor:
grp_recs = await sor.R("rl_asset_group", {"id": local_group_id})
project_name = grp_recs[0].project_name if grp_recs else "default"
# Call vendor API
params = {
"GroupId": vendor_group_id,
"URL": source_url,
"AssetType": asset_type,
"ProjectName": project_name or "default",
}
if name:
params["Name"] = name
result = await _call_vendor(vendor, "upload_asset", params)
vendor_asset_id = result.get("Id", result.get("Result", {}).get("Id", ""))
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
asset_id = getID()
asset_uri = f"asset://{vendor_asset_id}" if vendor_asset_id else ""
async with db.sqlorContext(dbname) as sor:
await sor.C("rl_asset", {
"id": asset_id,
"org_id": org_id,
"group_id": local_group_id,
"vendor": vendor,
"vendor_asset_id": vendor_asset_id,
"asset_type": asset_type,
"name": name or source_url.split("/")[-1][:50],
"status": "Processing",
"source_url": source_url,
"asset_uri": asset_uri,
"project_name": project_name or "default",
"vendor_response": json.dumps(result, ensure_ascii=False),
"created_by": user_id or "",
"create_time": now,
"update_time": now,
})
has_error = "error" in result or "Error" in result
return {
"success": not has_error,
"id": asset_id,
"vendor_asset_id": vendor_asset_id,
"status": "Processing",
"message": result.get("error", result.get("Message", "")),
}
# ============================================================
# Module loader
# ============================================================
@ -769,4 +1128,12 @@ def load_reallife_asset():
g.rl_handle_callback = rl_handle_callback
g.rl_query_groups = rl_query_groups
# Virtual human (私域虚拟人) AIGC APIs
g.rl_create_virtual_group = rl_create_virtual_group
g.rl_list_virtual_groups = rl_list_virtual_groups
g.rl_sync_virtual_groups_from_vendor = rl_sync_virtual_groups_from_vendor
g.rl_sync_virtual_assets_from_vendor = rl_sync_virtual_assets_from_vendor
g.rl_list_virtual_assets_client = rl_list_virtual_assets_client
g.rl_upload_virtual_asset = rl_upload_virtual_asset
return True

View File

@ -58,6 +58,11 @@ PATHS_LOGINED = [
f"/{MOD}/upload_asset.ui",
f"/{MOD}/view_assets.ui",
# 虚拟人素材页面
f"/{MOD}/virtual_create_group.ui",
f"/{MOD}/virtual_upload_asset.ui",
f"/{MOD}/virtual_view_assets.ui",
# API — 所有 api/ 下的 .dspy脚本内部通过 get_user() 做权限校验)
f"/{MOD}/api/%",
]

View File

@ -11,7 +11,7 @@ INSERT INTO rl_vendor_config (id, vendor, vendor_title, upappid, api_mapping, st
'volcengine',
'火山引擎',
'',
'{"create_session":"CreateVisualValidateSession","check_session":"GetVisualValidateResult","upload_asset":"CreateAsset","get_asset":"GetAsset","delete_asset":"DeleteAsset","delete_group":"DeleteAssetGroup","list_groups":"ListAssetGroups","list_assets":"ListAssets","get_group":"GetAssetGroup","update_asset":"UpdateAsset","update_group":"UpdateAssetGroup"}',
'{"create_session":"CreateVisualValidateSession","check_session":"GetVisualValidateResult","create_group":"CreateAssetGroup","upload_asset":"CreateAsset","get_asset":"GetAsset","delete_asset":"DeleteAsset","delete_group":"DeleteAssetGroup","list_groups":"ListAssetGroups","list_assets":"ListAssets","get_group":"GetAssetGroup","update_asset":"UpdateAsset","update_group":"UpdateAssetGroup"}',
'active',
'https://token.opencomputing.cn/reallife_asset/api/rl_callback.dspy',
'AKLTZWE5YTY1MDRhMmIyNGFlN2JkMzBjN2U0NGFkMWQ5ODM',

View File

@ -0,0 +1,17 @@
org_id = (await get_userorgid()) or '0'
result = await rl_list_virtual_groups(org_id)
groups = result.get('groups', [])
rows = []
for g in groups:
vid = g.get('vendor_group_id', '')
vendor = g.get('vendor', '')
name = g.get('name', '')
if vid:
display = f"{vendor} - {name}" if name else f"{vendor} - {vid}"
rows.append({"value": vid, "text": display})
debug(f"get_virtual_groups: org={org_id}, count={len(rows)}")
return json.dumps(rows, ensure_ascii=False)

View File

@ -0,0 +1,24 @@
# ============================================================
# 查询指定虚拟人素材组合下的素材列表
# 参数: vendor_group_id(必填)
# curl 示例:
# curl -X POST 'https://ai.atvoe.com/reallife_asset/api/rl_virtual_assets.dspy' \
# -H 'Authorization: Bearer *** \
# -d 'vendor_group_id=group-xxx'
# ============================================================
vendor_group_id = params_kw.get("vendor_group_id", "")
if not vendor_group_id:
return json.dumps({"status": "error", "data": {"message": "vendor_group_id为必填参数"}})
try:
org_id = (await get_userorgid()) or "0"
result = await rl_list_virtual_assets_client(org_id, vendor_group_id)
if result.get("success"):
return json.dumps({"status": "ok", "data": {
"assets": result.get("assets", []),
"total": result.get("total", 0),
}})
else:
return json.dumps({"status": "error", "data": {"message": result.get("message", "查询失败")}})
except Exception as e:
return json.dumps({"status": "error", "data": {"message": str(e)}})

View File

@ -0,0 +1,29 @@
# ============================================================
# 创建私域虚拟人素材组合
# 参数: vendor(必填), name(必填), description(选填), project_name(选填,默认default)
# curl 示例:
# curl -X POST 'https://ai.atvoe.com/reallife_asset/api/rl_virtual_create_group.dspy' \
# -H 'Authorization: Bearer *** \
# -d 'vendor=volcengine&name=虚拟角色A&description=测试组合'
# ============================================================
vendor = params_kw.get("vendor", "")
name = params_kw.get("name", "")
description = params_kw.get("description", "")
project_name = params_kw.get("project_name", "default")
if not vendor or not name:
return json.dumps({"status": "error", "data": {"message": "vendor和name为必填参数"}})
try:
org_id = (await get_userorgid()) or "0"
user_id = await get_user()
result = await rl_create_virtual_group(org_id, vendor, name, description, project_name, user_id)
if result.get("success"):
return json.dumps({"status": "ok", "data": {
"id": result.get("id"),
"vendor_group_id": result.get("vendor_group_id"),
}})
else:
return json.dumps({"status": "error", "data": {"message": result.get("message", "创建失败")}})
except Exception as e:
return json.dumps({"status": "error", "data": {"message": str(e)}})

View File

@ -0,0 +1,15 @@
# ============================================================
# 查询当前机构的私域虚拟人素材组合列表
# 参数: 无(自动从Bearer token获取org_id)
# curl 示例:
# curl -X POST 'https://ai.atvoe.com/reallife_asset/api/rl_virtual_groups.dspy' \
# -H 'Authorization: Bearer *** # ============================================================
try:
org_id = (await get_userorgid()) or "0"
result = await rl_list_virtual_groups(org_id)
if result.get("success"):
return json.dumps({"status": "ok", "data": {"groups": result.get("groups", [])}})
else:
return json.dumps({"status": "error", "data": {"message": result.get("message", "查询失败")}})
except Exception as e:
return json.dumps({"status": "error", "data": {"message": str(e)}})

View File

@ -0,0 +1,25 @@
# ============================================================
# 查询虚拟人素材处理状态
# 参数: asset_id(必填)
# curl 示例:
# curl -X POST 'https://ai.atvoe.com/reallife_asset/api/rl_virtual_status.dspy' \
# -H 'Authorization: Bearer *** \
# -d 'asset_id=xxx'
# ============================================================
asset_id = params_kw.get("asset_id", "")
if not asset_id:
return json.dumps({"status": "error", "data": {"message": "asset_id为必填参数"}})
try:
org_id = (await get_userorgid()) or "0"
user_id = await get_user()
result = await rl_sync_asset_status_user(org_id, asset_id, user_id)
if result.get("success"):
return json.dumps({"status": "ok", "data": {
"status": result.get("status"),
"url": result.get("url", ""),
}})
else:
return json.dumps({"status": "error", "data": {"message": result.get("message", "查询失败")}})
except Exception as e:
return json.dumps({"status": "error", "data": {"message": str(e)}})

View File

@ -0,0 +1,34 @@
# ============================================================
# 上传虚拟人素材到私域素材组合
# 参数: vendor_group_id(必填), source_url(必填), asset_type(选填,默认Image), name(选填)
# curl 示例:
# curl -X POST 'https://ai.atvoe.com/reallife_asset/api/rl_virtual_upload.dspy' \
# -H 'Authorization: Bearer *** \
# -d 'vendor_group_id=group-xxx&source_url=https://example.com/image.jpg&asset_type=Image&name=虚拟人正面'
# ============================================================
vendor_group_id = params_kw.get("vendor_group_id", "")
source_url = params_kw.get("source_url", "")
asset_type = params_kw.get("asset_type", "Image")
name = params_kw.get("name", "")
if not vendor_group_id or not source_url:
return json.dumps({"status": "error", "data": {"message": "vendor_group_id和source_url为必填参数"}})
# Handle base64 data URL conversion
if source_url.startswith("data:") or (not source_url.startswith("http") and len(source_url) < 8000):
source_url = await b64media2url(request, source_url)
try:
org_id = (await get_userorgid()) or "0"
user_id = await get_user()
result = await rl_upload_virtual_asset(org_id, vendor_group_id, source_url, asset_type, name, user_id)
if result.get("success"):
return json.dumps({"status": "ok", "data": {
"id": result.get("id"),
"vendor_asset_id": result.get("vendor_asset_id"),
"status": result.get("status"),
}})
else:
return json.dumps({"status": "error", "data": {"message": result.get("message", "上传失败")}})
except Exception as e:
return json.dumps({"status": "error", "data": {"message": str(e)}})

View File

@ -0,0 +1,38 @@
vendor = params_kw.get('vendor', '')
name = params_kw.get('name', '')
description = params_kw.get('description', '')
if not vendor or not name:
return json.dumps({
"widgettype": "Error",
"options": {"title": "错误", "message": "请选择供应商并输入组合名称"}
})
org_id = (await get_userorgid()) or '0'
user_id = (await get_user()) or ''
result = await rl_create_virtual_group(org_id, vendor, name, description, "default", user_id)
if result.get('success'):
vendor_group_id = result.get('vendor_group_id', '')
msg = f"素材组合创建成功!\n组合ID{vendor_group_id}\n现在可以上传虚拟人素材到此组合。"
return json.dumps({
"widgettype": "Message",
"id": "virtual_group_result_popup",
"options": {"title": "创建成功", "message": "", "anchor": "cc"},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"padding": "8px", "gap": "12px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": msg}}
]
}
]
})
else:
return json.dumps({
"widgettype": "Error",
"options": {"title": "创建失败", "message": result.get('message', '未知错误')}
})

View File

@ -0,0 +1,145 @@
vendor_group_id = params_kw.get('vendor_group_id', '')
if not vendor_group_id:
return json.dumps({
"widgettype": "Error",
"options": {"title": "错误", "message": "请选择素材组合"}
})
org_id = (await get_userorgid()) or '0'
result = await rl_list_virtual_assets_client(org_id, vendor_group_id)
if not result.get('success'):
return json.dumps({
"widgettype": "Error",
"options": {"title": "查询失败", "message": result.get('message', '未知错误')}
})
assets = result.get('assets', [])
if not assets:
return json.dumps({
"widgettype": "VBox",
"options": {"padding": "16px", "gap": "12px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "该素材组合下暂无素材,请先上传虚拟人素材。"}
}
]
})
# Build asset cards
cards = []
for a in assets:
status = a.get('status', '')
name = a.get('name', '')
url = a.get('url', '')
atype = a.get('asset_type', '')
create_time = str(a.get('create_time', ''))[:16]
asset_id = a.get('id', '')
vendor_asset_id = a.get('vendor_asset_id', '')
s_lower = status.lower() if status else ''
if s_lower in ('active', 'available', 'ready'):
status_icon = "✅"
elif s_lower in ('processing', 'pending', 'submitted'):
status_icon = "⏳"
elif s_lower in ('failed', 'error'):
status_icon = "❌"
else:
status_icon = "📋"
card_subs = [
{
"widgettype": "HBox",
"options": {"gap": "8px", "alignItems": "center", "marginBottom": "4px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": name or "未命名", "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"text": f"{status_icon} {status}", "cfontsize": 0.9}}
]
},
{
"widgettype": "Text",
"options": {"text": f"类型: {atype} 创建: {create_time}", "cfontsize": 0.9}
},
{
"widgettype": "Text",
"options": {"text": f"Asset URI: asset://{vendor_asset_id}", "cfontsize": 0.85}
}
]
# Media preview
if url:
if atype == 'Image':
card_subs.append({
"widgettype": "Image",
"options": {"url": url, "cwidth": 15, "cheight": 10, "objectFit": "cover", "borderRadius": "4px", "marginTop": "8px"}
})
elif atype == 'Video':
card_subs.append({
"widgettype": "VideoPlayer",
"options": {"url": url, "cwidth": 20, "cheight": 12, "marginTop": "8px"}
})
elif atype == 'Audio':
card_subs.append({
"widgettype": "AudioPlayer",
"options": {"url": url, "marginTop": "8px"}
})
# Buttons
check_url = request.path.rsplit('/', 1)[0] + '/submit_query_status.dspy'
btn_subs = [
{
"widgettype": "Button",
"options": {"label": "刷新状态"},
"binds": [{
"wid": "self", "event": "click",
"actiontype": "script", "target": "self",
"script": "(async function(){" \
"var url='" + check_url + "?_webbricks_=1&asset_id=" + asset_id + "';" \
"var r=await fetch(url);" \
"var j=await r.json();" \
"await bricks.show_resp_message_or_error({json:async function(){return j}});" \
"})()"
}]
}
]
if url:
btn_subs.append({
"widgettype": "Button",
"options": {"label": "下载"},
"binds": [{
"wid": "self", "event": "click",
"actiontype": "script", "target": "self",
"script": "window.open('" + url + "', '_blank')"
}]
})
card_subs.append({
"widgettype": "HBox",
"options": {"gap": "8px", "marginTop": "8px"},
"subwidgets": btn_subs
})
cards.append({
"widgettype": "VBox",
"options": {"css": "card", "padding": "12px", "borderRadius": "8px"},
"subwidgets": card_subs
})
result_widget = {
"widgettype": "VBox",
"options": {"padding": "16px", "gap": "12px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": f"共 {len(assets)} 个虚拟人素材", "fontWeight": "bold"}},
{
"widgettype": "ResponsableBox",
"options": {"gap": "12px", "minWidth": "300px"},
"subwidgets": cards
}
]
}
return json.dumps(result_widget, ensure_ascii=False)

View File

@ -0,0 +1,88 @@
import os
vendor_group_id = params_kw.get('vendor_group_id', '')
source_url = params_kw.get('source_url', '')
asset_type = params_kw.get('asset_type', '')
name = params_kw.get('name', '')
if not vendor_group_id or not source_url:
return json.dumps({
"widgettype": "Error",
"options": {"title": "错误", "message": "请选择素材组合并上传素材文件", "anchor": "cc"}
})
# Validate media file type from path extension
ext = os.path.splitext(source_url.split('?')[0])[1].lower() if source_url else ''
media_map = {
'.jpg': 'Image', '.jpeg': 'Image', '.png': 'Image', '.gif': 'Image', '.bmp': 'Image', '.webp': 'Image', '.svg': 'Image', '.tiff': 'Image', '.heic': 'Image',
'.mp4': 'Video', '.avi': 'Video', '.mov': 'Video', '.wmv': 'Video', '.flv': 'Video', '.mkv': 'Video', '.webm': 'Video',
'.mp3': 'Audio', '.wav': 'Audio', '.aac': 'Audio', '.flac': 'Audio', '.ogg': 'Audio', '.wma': 'Audio', '.m4a': 'Audio',
}
detected_type = media_map.get(ext, '')
if ext and not detected_type:
return json.dumps({
"widgettype": "Error",
"options": {"title": "错误", "message": f"不支持的文件类型: {ext},请上传图片、音频或视频文件", "anchor": "cc"}
})
if not asset_type and detected_type:
asset_type = detected_type
if not asset_type:
asset_type = 'Image'
# Convert base64 / local path to public URL
if source_url.startswith('data:') or (not source_url.startswith('http') and len(source_url) < 8000):
source_url = await b64media2url(request, source_url)
if not source_url:
return json.dumps({
"widgettype": "Error",
"options": {"title": "错误", "message": "素材文件转换失败", "anchor": "cc"}
})
org_id = (await get_userorgid()) or '0'
user_id = (await get_user()) or ''
result = await rl_upload_virtual_asset(org_id, vendor_group_id, source_url, asset_type, name, user_id)
if result.get('success'):
asset_id = result.get('id', '')
vendor_asset_id = result.get('vendor_asset_id', '')
status_text = result.get('status', 'Processing')
msg = f"虚拟人素材已提交,当前状态:{status_text}\n素材ID{asset_id}\n供应商资产ID{vendor_asset_id}"
base_path = request.path.rsplit('/', 1)[0]
check_url = base_path + '/submit_query_status.dspy'
return json.dumps({
"widgettype": "Message",
"id": "virtual_upload_result_popup",
"options": {"title": "上传成功", "message": "", "anchor": "cc"},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"padding": "8px", "gap": "12px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": msg}},
{
"widgettype": "Button",
"options": {"label": "查询处理状态"},
"binds": [{
"wid": "self", "event": "click",
"actiontype": "script", "target": "self",
"script": "(async function(){"
"var url='" + check_url + "?_webbricks_=1&asset_id=" + asset_id + "';"
"var r=await fetch(url);"
"var j=await r.json();"
"await bricks.show_resp_message_or_error({json:async function(){return j}});"
"})()"
}]
}
]
}
]
})
else:
return json.dumps({
"widgettype": "Error",
"options": {"title": "上传失败", "message": result.get('message', '未知错误'), "anchor": "cc"}
})

View File

@ -92,6 +92,84 @@
}
]
}
{% endif %}
,{
"widgettype": "VBox",
"options": {
"padding": "12px 0 8px",
"spacing": 0
},
"subwidgets": [
{
"widgettype": "Title5",
"options": {
"text": "虚拟人素材",
"color": "#94a3b8"
}
}
]
}
{% if is_customer or is_admin %}
,{
"widgettype": "Button",
"options": {
"label": "📁 创建素材组合",
"width": "100%",
"textAlign": "left"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rl_content",
"options": {
"url": "{{entire_url('virtual_create_group.ui')}}"
},
"mode": "replace"
}
]
},
{
"widgettype": "Button",
"options": {
"label": "📤 上传虚拟人素材",
"width": "100%",
"textAlign": "left"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rl_content",
"options": {
"url": "{{entire_url('virtual_upload_asset.ui')}}"
},
"mode": "replace"
}
]
},
{
"widgettype": "Button",
"options": {
"label": "🖼️ 查看虚拟人素材",
"width": "100%",
"textAlign": "left"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rl_content",
"options": {
"url": "{{entire_url('virtual_view_assets.ui')}}"
},
"mode": "replace"
}
]
}
{% endif %}
{% if is_admin %}
,{

View File

@ -0,0 +1,73 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"padding": "16px"
},
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "创建虚拟人素材组合",
"fontWeight": "600",
"marginBottom": "16px"
}
},
{
"widgettype": "Text",
"options": {
"text": "创建素材组合后,可将虚拟人素材(图片/视频/音频上传至该组合用于Seedance 2.0视频生成。",
"cfontsize": 0.9,
"marginBottom": "16px"
}
},
{
"widgettype": "VBox",
"options": {
"padding": "16px",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Form",
"id": "virtual_group_form",
"options": {
"submit_url": "{{entire_url('api/submit_virtual_create_group.dspy')}}",
"fields": [
{
"uitype": "code",
"name": "vendor",
"label": "供应商",
"dataurl": "{{entire_url('api/get_vendor_list.dspy')}}",
"data_field": "value",
"text_field": "text",
"required": true
},
{
"uitype": "str",
"name": "name",
"label": "组合名称",
"placeholder": "例如虚拟角色A",
"required": true
},
{
"uitype": "str",
"name": "description",
"label": "描述",
"placeholder": "可选,素材组合的描述"
}
]
},
"binds": [
{
"wid": "self",
"event": "submited",
"actiontype": "script",
"script": "await bricks.show_resp_message_or_error(event.params)"
}
]
}
]
}
]
}

View File

@ -0,0 +1,81 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"padding": "16px"
},
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "上传虚拟人素材",
"fontWeight": "600",
"marginBottom": "16px"
}
},
{
"widgettype": "VScrollPanel",
"options": {
"height": "600px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"padding": "16px",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Form",
"id": "virtual_upload_form",
"options": {
"submit_url": "{{entire_url('api/submit_virtual_upload.dspy')}}",
"fields": [
{
"uitype": "code",
"name": "vendor_group_id",
"label": "素材组合",
"dataurl": "{{entire_url('api/get_virtual_groups.dspy')}}",
"data_field": "value",
"text_field": "text",
"required": true
},
{
"uitype": "file",
"name": "source_url",
"label": "素材文件",
"accept": "image/*,audio/*,video/*",
"required": true
},
{
"uitype": "code",
"name": "asset_type",
"label": "素材类型",
"dataurl": "{{entire_url('api/get_asset_type_list.dspy')}}",
"data_field": "value",
"text_field": "text"
},
{
"uitype": "str",
"name": "name",
"label": "素材名称",
"placeholder": "可选,方便管理的名称"
}
]
},
"binds": [
{
"wid": "self",
"event": "submited",
"actiontype": "script",
"script": "await bricks.show_resp_message_or_error(event.params)"
}
]
}
]
}
]
}
]
}

View File

@ -0,0 +1,75 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "16px"
},
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "查看虚拟人素材",
"fontWeight": "600",
"marginBottom": "12px"
}
},
{
"widgettype": "Form",
"id": "virtual_view_form",
"options": {
"cheight": 8,
"fields": [
{
"uitype": "code",
"name": "vendor_group_id",
"label": "素材组合",
"dataurl": "{{entire_url('api/get_virtual_groups.dspy')}}",
"data_field": "value",
"text_field": "text",
"required": true
}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "app.rl_virtual_asset_results",
"options": {
"method": "POST",
"url": "{{entire_url('api/submit_virtual_list_assets.dspy')}}"
},
"mode": "replace"
}
]
},
{
"widgettype": "VBox",
"options": {
"css": "filler",
"padding": "0",
"marginTop": "12px"
},
"subwidgets": [
{
"widgettype": "VScrollPanel",
"options": {
"css": "filler"
},
"subwidgets": [
{
"widgettype": "VBox",
"id": "rl_virtual_asset_results",
"options": {
"width": "100%",
"padding": "0"
}
}
]
}
]
}
]
}