From 925f58b02578ae51bb830806193c3ec2258a96f1 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 2 Jun 2026 15:25:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A7=81=E5=9F=9F?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E4=BA=BA=E7=B4=A0=E6=9D=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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个端点) --- docs/api_downapp.md | 191 ++++++++++ reallife_asset/init.py | 367 +++++++++++++++++++ scripts/load_path.py | 5 + scripts/vendor_config_volcengine.sql | 2 +- wwwroot/api/get_virtual_groups.dspy | 17 + wwwroot/api/rl_virtual_assets.dspy | 24 ++ wwwroot/api/rl_virtual_create_group.dspy | 29 ++ wwwroot/api/rl_virtual_groups.dspy | 15 + wwwroot/api/rl_virtual_status.dspy | 25 ++ wwwroot/api/rl_virtual_upload.dspy | 34 ++ wwwroot/api/submit_virtual_create_group.dspy | 38 ++ wwwroot/api/submit_virtual_list_assets.dspy | 145 ++++++++ wwwroot/api/submit_virtual_upload.dspy | 88 +++++ wwwroot/index.ui | 78 ++++ wwwroot/virtual_create_group.ui | 73 ++++ wwwroot/virtual_upload_asset.ui | 81 ++++ wwwroot/virtual_view_assets.ui | 75 ++++ 17 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 wwwroot/api/get_virtual_groups.dspy create mode 100644 wwwroot/api/rl_virtual_assets.dspy create mode 100644 wwwroot/api/rl_virtual_create_group.dspy create mode 100644 wwwroot/api/rl_virtual_groups.dspy create mode 100644 wwwroot/api/rl_virtual_status.dspy create mode 100644 wwwroot/api/rl_virtual_upload.dspy create mode 100644 wwwroot/api/submit_virtual_create_group.dspy create mode 100644 wwwroot/api/submit_virtual_list_assets.dspy create mode 100644 wwwroot/api/submit_virtual_upload.dspy create mode 100644 wwwroot/virtual_create_group.ui create mode 100644 wwwroot/virtual_upload_asset.ui create mode 100644 wwwroot/virtual_view_assets.ui diff --git a/docs/api_downapp.md b/docs/api_downapp.md index d5e89db..3dadfa0 100644 --- a/docs/api_downapp.md +++ b/docs/api_downapp.md @@ -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 | 检查供应商配置和网络 | diff --git a/reallife_asset/init.py b/reallife_asset/init.py index 174a4bc..bd53ae7 100644 --- a/reallife_asset/init.py +++ b/reallife_asset/init.py @@ -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 diff --git a/scripts/load_path.py b/scripts/load_path.py index 8eb819a..4afb161 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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/%", ] diff --git a/scripts/vendor_config_volcengine.sql b/scripts/vendor_config_volcengine.sql index d7fd856..dcc5d0e 100644 --- a/scripts/vendor_config_volcengine.sql +++ b/scripts/vendor_config_volcengine.sql @@ -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', diff --git a/wwwroot/api/get_virtual_groups.dspy b/wwwroot/api/get_virtual_groups.dspy new file mode 100644 index 0000000..745f95b --- /dev/null +++ b/wwwroot/api/get_virtual_groups.dspy @@ -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) diff --git a/wwwroot/api/rl_virtual_assets.dspy b/wwwroot/api/rl_virtual_assets.dspy new file mode 100644 index 0000000..c61bd8d --- /dev/null +++ b/wwwroot/api/rl_virtual_assets.dspy @@ -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)}}) diff --git a/wwwroot/api/rl_virtual_create_group.dspy b/wwwroot/api/rl_virtual_create_group.dspy new file mode 100644 index 0000000..6076eef --- /dev/null +++ b/wwwroot/api/rl_virtual_create_group.dspy @@ -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)}}) diff --git a/wwwroot/api/rl_virtual_groups.dspy b/wwwroot/api/rl_virtual_groups.dspy new file mode 100644 index 0000000..9efad5a --- /dev/null +++ b/wwwroot/api/rl_virtual_groups.dspy @@ -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)}}) diff --git a/wwwroot/api/rl_virtual_status.dspy b/wwwroot/api/rl_virtual_status.dspy new file mode 100644 index 0000000..f05d590 --- /dev/null +++ b/wwwroot/api/rl_virtual_status.dspy @@ -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)}}) diff --git a/wwwroot/api/rl_virtual_upload.dspy b/wwwroot/api/rl_virtual_upload.dspy new file mode 100644 index 0000000..e14c032 --- /dev/null +++ b/wwwroot/api/rl_virtual_upload.dspy @@ -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)}}) diff --git a/wwwroot/api/submit_virtual_create_group.dspy b/wwwroot/api/submit_virtual_create_group.dspy new file mode 100644 index 0000000..8ae1ddb --- /dev/null +++ b/wwwroot/api/submit_virtual_create_group.dspy @@ -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', '未知错误')} + }) diff --git a/wwwroot/api/submit_virtual_list_assets.dspy b/wwwroot/api/submit_virtual_list_assets.dspy new file mode 100644 index 0000000..97c887f --- /dev/null +++ b/wwwroot/api/submit_virtual_list_assets.dspy @@ -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) diff --git a/wwwroot/api/submit_virtual_upload.dspy b/wwwroot/api/submit_virtual_upload.dspy new file mode 100644 index 0000000..0001476 --- /dev/null +++ b/wwwroot/api/submit_virtual_upload.dspy @@ -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"} + }) diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 899ea08..89c757f 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -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 %} ,{ diff --git a/wwwroot/virtual_create_group.ui b/wwwroot/virtual_create_group.ui new file mode 100644 index 0000000..5fd1d71 --- /dev/null +++ b/wwwroot/virtual_create_group.ui @@ -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)" + } + ] + } + ] + } + ] +} diff --git a/wwwroot/virtual_upload_asset.ui b/wwwroot/virtual_upload_asset.ui new file mode 100644 index 0000000..ad7491b --- /dev/null +++ b/wwwroot/virtual_upload_asset.ui @@ -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)" + } + ] + } + ] + } + ] + } + ] +} diff --git a/wwwroot/virtual_view_assets.ui b/wwwroot/virtual_view_assets.ui new file mode 100644 index 0000000..baa888f --- /dev/null +++ b/wwwroot/virtual_view_assets.ui @@ -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" + } + } + ] + } + ] + } + ] +}