chore: sync latest workspace changes
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 02:08:26 +08:00
parent d34e66da25
commit be5b9aef12
83 changed files with 29590 additions and 631 deletions

558
SPU详情接口文档2.0.md Normal file
View File

@ -0,0 +1,558 @@
# AI 生图平台素材接口文档
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览下载链接`
> 当前主域名:`https://sc.jiansun.vip`
> 当前资源主域名:`https://s3.jiansun.vip`
> 备用资源域名:`https://s3.yydd.ai`(备用 / 回切用,非默认对接地址)
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
## 1. 基础信息
- 接口基地址:`https://sc.jiansun.vip/api/external/`
- 鉴权方式:请求头 `X-API-Key`
- 请求方式:`GET`
- 返回格式:`application/json`
- 主接口路径:`/api/external/standard-model-white-background`
- 默认资源类型:`white-background`
### 1.1 当前网络口径
- 接口查询、鉴权和下载 token 入口统一使用 `https://sc.jiansun.vip`
- `task-detail` 分支返回的预览图直链,当前默认资源域名使用 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 当前只保留为备用资源域名;除非单独通知切换,否则调用方继续以 `https://s3.jiansun.vip` 为准
返回统一格式:
```json
{
"success": true,
"data": {}
}
```
失败时:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "错误说明"
}
}
```
### 1.2 响应诊断头
为便于调用方排查“接口慢”到底是服务端慢还是链路慢,当前接口额外返回以下响应头:
| 响应头 | 说明 |
|---|---|
| `X-Trace-Id` | 本次请求追踪 ID,排查时可提供给素材平台定位服务端日志 |
| `X-Server-Duration-Ms` | 服务端实际处理耗时,单位毫秒 |
| `Server-Timing` | 标准服务端耗时头,内容与上面一致 |
| `X-Resource-Type` | 本次实际走的资源分支 |
| `X-Request-Sku` | 本次实际识别后的 SKU;未传时不返回 |
| `X-Request-Spu` | 本次实际识别后的 SPU;未传时不返回 |
## 2. 通用请求规则
### 2.1 请求头
| 参数 | 必填 | 说明 |
|---|---|---|
| `X-API-Key` | 是 | 外部接口访问密钥 |
### 2.2 查询参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `resourceType` | 否 | 资源类型。可选 `white-background` 或 `task-detail`。不传时默认 `white-background` |
| `sku` | 条件必填 | `sku` 和 `spu` 二选一即可 |
| `spu` | 条件必填 | `sku` 和 `spu` 二选一即可 |
补充说明:
- `sku`、`spu` 都按大小写不敏感查询,建议调用方统一传大写。
- 当前服务端会先把 `sku`、`spu` 标准化为大写后再做精确匹配,建议调用方直接传大写,避免自身重复大小写转换。
- 如果 `sku` 和 `spu` 同时传入,接口优先按 `spu` 处理。
- `shopName` 当前与 `categoryName` 含义一致,都是“店铺”。
## 3. 白底图查询
### 3.1 适用场景
- `resourceType=white-background`
- 不传 `resourceType` 时也走这一分支,兼容老调用方
- 支持两种查询方式:
- `sku`:直接拿到该 SKU 的完整白底图信息
- `spu`:先拿到该 SPU 下全部 SKU 清单,再让用户选 SKU
---
### 3.2 按 SKU 查询白底图
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=white-background&sku={SKU}
X-API-Key: your-api-key
```
兼容老调用方式:
```http
GET /api/external/standard-model-white-background?sku={SKU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "white-background",
"sku": "TEXT001452",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"productModel": "TEXT0014",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"whiteBackgroundUploaded": true,
"whiteBackgroundComplete": false,
"whiteBackgroundFixedCount": 2,
"whiteBackgroundOtherCount": 1,
"whiteBackgroundCount": 3,
"missingViews": [
{
"key": "leftBack45",
"label": "左后45°"
}
],
"whiteBackground": {
"fixed": [
{
"key": "leftFront45",
"label": "左前45°",
"fileName": "左前45°.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
},
{
"key": "rightFront45",
"label": "右前45°",
"fileName": "右前45°.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy"
}
],
"other": [
{
"key": "other-1",
"label": "其他图片1",
"fileName": "detail-1.png",
"fileSize": 123456,
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=zzz"
}
]
},
"downloadUrls": {
"zip": "https://sc.jiansun.vip/api/external/download?token=zip-token"
}
}
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `resourceType` | 固定为 `white-background` |
| `sku` | 实际命中的 SKU |
| `materialId` | 标准模型素材 ID |
| `productModel` | 所属 SPU |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `whiteBackgroundUploaded` | 是否已有白底图 |
| `whiteBackgroundComplete` | 固定 8 个角度是否已补齐 |
| `whiteBackgroundFixedCount` | 已有固定角度图数量 |
| `whiteBackgroundOtherCount` | 已有 other 图数量 |
| `whiteBackgroundCount` | 白底图总数 |
| `missingViews` | 还缺少的固定角度图 |
| `whiteBackground.fixed` | 固定角度白底图列表 |
| `whiteBackground.other` | 其他白底图列表 |
| `downloadUrls.zip` | 当前 SKU 白底图 ZIP 下载链接 |
固定角度顺序固定为:
1. `leftFront45`
2. `rightFront45`
3. `leftBack45`
4. `rightBack45`
5. `front`
6. `back`
7. `left`
8. `right`
单张图片对象字段:
| 字段 | 说明 |
|---|---|
| `key` | 固定图为固定角度 key,other 图为 `other-*` |
| `label` | 图片说明 |
| `fileName` | 文件名 |
| `fileSize` | 文件大小,单位字节 |
| `downloadUrl` | 单张白底图下载链接 |
---
### 3.3 按 SPU 查询白底图 SKU 清单
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=white-background&spu={SPU}
X-API-Key: your-api-key
```
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "white-background",
"spu": "TEXT0014",
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"skuCount": 2,
"skuList": [
{
"skuName": "TEXT001452",
"skuId": "sku-id-1",
"hasWhiteBackground": true,
"whiteBackgroundCount": 2,
"previewImage": {
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
"fileName": "右前45°.png",
"fileSize": 123456
}
},
{
"skuName": "TEXT001453",
"skuId": "sku-id-2",
"hasWhiteBackground": false,
"whiteBackgroundCount": 0,
"previewImage": null
}
]
}
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `resourceType` | 固定为 `white-background` |
| `spu` | 实际命中的 SPU |
| `materialId` | 标准模型素材 ID |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `skuCount` | 该 SPU 下 SKU 数量 |
| `skuList[].skuName` | SKU 名称 |
| `skuList[].skuId` | SKU 记录 ID |
| `skuList[].hasWhiteBackground` | 是否已有任意白底图 |
| `skuList[].whiteBackgroundCount` | 当前固定角度白底图数量 |
| `skuList[].previewImage` | 右前 45° 预览图;如果没有该角度则为 `null` |
说明:
- 这个接口主要给 AI 平台先展示 SKU 列表用。
- 选定 SKU 后,再调用“按 SKU 查询白底图”接口拿完整白底图信息。
## 4. 任务-详情整套查询
### 4.1 适用场景
- `resourceType=task-detail`
- 按 `任务-详情` 素材类型直接查询,不再要求先存在 `标准模型`
- 只返回:
- `materialType='任务-详情'`
- 同时拥有主图、SKU 图、详情图
- `deletedAt=null`
的候选素材
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
- 当前默认返回的资源直链域名是 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 保留备用说明,但不是当前默认示例地址
---
### 4.2 按 SPU 查询任务-详情整套候选
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=task-detail&spu={SPU}
X-API-Key: your-api-key
```
注意:
- `task-detail` 响应顶层的 `categoryName` / `shopName` 是便捷字段,取本次返回候选列表中的第一条候选素材。
- 如果 AI 平台要展示候选素材的实际店铺,请优先读取 `candidates[].categoryName` 或 `candidates[].shopName`。
- 如果同一个 SPU 下有多条候选素材,且它们店铺不同,请以各自 `candidates[]` 内的字段为准。
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "task-detail",
"spu": "SPU001",
"sku": null,
"categoryName": "旗舰店",
"shopName": "旗舰店",
"candidateCount": 1,
"candidates": [
{
"materialId": "material-2",
"taskNo": "TASK202601141713525624",
"productModel": "SPU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"sceneName": "客厅",
"uploadedAt": "2026-03-18T10:00:00.000Z",
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
]
}
}
]
}
}
```
---
### 4.3 按 SKU 查询任务-详情整套候选
**请求**
```http
GET /api/external/standard-model-white-background?resourceType=task-detail&sku={SKU}
X-API-Key: your-api-key
```
**处理逻辑**
- 先通过 `SKU -> 标准模型 SPU`
- 再按这个 SPU 查询所有整套 `任务-详情` 候选
- 顶层 `categoryName` / `shopName` 是便捷字段,取本次返回候选列表中的第一条候选素材
- 如果 AI 平台要展示候选素材的实际店铺,请优先读取 `candidates[].categoryName` 或 `candidates[].shopName`
**成功响应示例**
```json
{
"success": true,
"data": {
"resourceType": "task-detail",
"spu": "SPU001",
"sku": "SKU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"candidateCount": 1,
"candidates": [
{
"materialId": "material-2",
"taskNo": "TASK202601141713525624",
"productModel": "SPU001",
"categoryName": "旗舰店",
"shopName": "旗舰店",
"sceneName": "客厅",
"uploadedAt": "2026-03-18T10:00:00.000Z",
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
]
}
}
]
}
}
```
**候选素材字段说明**
| 字段 | 说明 |
|---|---|
| `materialId` | 任务-详情素材 ID |
| `taskNo` | 任务号 |
| `productModel` | 对应 SPU |
| `categoryName` | 店铺 |
| `shopName` | 店铺,当前与 `categoryName` 相同 |
| `sceneName` | 场景 |
| `uploadedAt` | 上传时间,ISO 8601 格式 |
| `isFullSet` | 是否整套;当前规则为“同时拥有主图、SKU 图、详情图” |
| `downloadUrls.main` | 主图预览直链数组 |
| `downloadUrls.sku` | SKU 图预览直链数组 |
| `downloadUrls.detail` | 详情图预览直链数组 |
| `downloadUrls.all` | `main + sku + detail` 合并后的数组 |
说明:
- `task-detail` 分支返回的是**全部候选**,不是只返回一条。
- 预览链接可以直接打开或下载。
- 历史 `任务-详情` 会按同一套资产规则判断,不再依赖人工勾选。
- 顶层 `categoryName` / `shopName` 是便捷字段;如果要给用户展示候选素材的真实店铺,请优先使用 `candidates[].shopName`。
## 5. 下载说明
### 5.1 白底图
白底图返回的是下载链接:
- 单张图:`whiteBackground.fixed[*].downloadUrl` / `whiteBackground.other[*].downloadUrl`
- 整包 ZIP:`downloadUrls.zip`
调用方式:
```http
GET https://sc.jiansun.vip/api/external/download?token=xxx
```
### 5.2 任务-详情整套
任务-详情返回的是可直接访问的预览图直链数组:
- `downloadUrls.main`
- `downloadUrls.sku`
- `downloadUrls.detail`
- `downloadUrls.all`
这一分支**不返回 zip**,也**不需要再调下载 token 接口**。
补充说明:
- 当前默认回包中的预览图资源域名为 `https://s3.jiansun.vip`
- `https://s3.yydd.ai` 仅保留为备用资源域名说明,不作为当前默认对接地址
## 6. 错误码
### 6.1 通用错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 401 | `INVALID_API_KEY` | API Key 无效或缺失 |
| 400 | `MISSING_PARAM` | 没有传 `sku` 或 `spu` |
| 400 | `RESOURCE_TYPE_INVALID` | `resourceType` 不是 `white-background` 或 `task-detail` |
| 500 | `SERVER_ERROR` | 服务器内部错误 |
### 6.2 白底图分支错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供白底图 |
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
| 404 | `NO_SKUS` | 该 SPU 下没有 SKU 数据 |
| 404 | `WHITE_BACKGROUND_NOT_FOUND` | 该 SKU 暂无白底图 |
### 6.3 任务-详情分支错误
| HTTP 状态码 | 错误码 | 说明 |
|---|---|---|
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供任务-详情整套素材 |
| 404 | `TASK_DETAIL_NOT_FOUND` | 该 SPU 下,或该 SKU 所属 SPU 下暂无任务-详情素材 |
| 404 | `FULL_SET_NOT_FOUND` | 有任务-详情素材,但没有任何一条同时拥有主图、SKU 图、详情图 |
## 7. cURL 示例
### 7.1 按 SKU 查询白底图
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?sku=TEXT001452" \
-H "X-API-Key: your-api-key"
```
### 7.2 按 SPU 查询白底图 SKU 清单
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=white-background&spu=TEXT0014" \
-H "X-API-Key: your-api-key"
```
### 7.3 按 SPU 查询任务-详情整套候选
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&spu=SPU001" \
-H "X-API-Key: your-api-key"
```
### 7.4 按 SKU 查询任务-详情整套候选
```bash
curl -X GET \
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&sku=SKU001" \
-H "X-API-Key: your-api-key"
```
## 8. 接入建议
- 老的白底图调用方可以不改,继续传 `sku` 即可。
- 如果是 AI 生图平台新流程,建议:
- 白底图:`spu -> sku -> 白底图详情`
- 任务-详情:直接按 `spu` 或 `sku` 查整套候选
- `task-detail` 返回多个候选时,建议调用方按 `uploadedAt` 再做一次前端展示排序或筛选。
- `task-detail` 使用的是预览图直链,不适合再按源文件 zip 的思路处理。
## 9. 当前联调状态
截至 `2026-03-21`,正式环境已部署并验证通过的内容:
- `/api/external/standard-model-white-background` 已支持 `white-background` 和 `task-detail`
- 不传 `resourceType` 时,白底图老调用方式保持兼容
- `task-detail` 已支持 `spu` / `sku` 两种查询
- 返回中已补充 `shopName`
- `任务-详情` 整套字段 `isFullSet` 已改为按主图 / SKU 图 / 详情图自动判定
- 对外文档默认资源域名已统一到 `https://s3.jiansun.vip`,同时保留 `https://s3.yydd.ai` 作为备用说明
- 相关测试已通过:`standard-model-white-background-route`、`task-detail-full-set`、`url-rewrite`
- 构建验证已通过:`npm run build`
- 正式环境健康检查已通过:`/api/health`
- 正式环境真实调用已验证:
- `resourceType=task-detail&spu=CTG0290` 返回 `200`
- `resourceType=task-detail&sku=DNZ025802` 返回 `200`
- 白底图 `spu=CTG0149` 返回 `200`

View File

@ -0,0 +1,2 @@
__pycache__/
archive/

View File

@ -0,0 +1,88 @@
# Is legal the same as legitimate: AI reimplementation and the erosion of copyleft
- **Date:** March 9, 2026
- **Author:** Hong Minhee
- **Source context:** _Hong Minhee on Things_ (English / 日本語 / 朝鮮語 (國漢文) / 한국어 (한글))
- **Archive note:** This copy was normalized from user-provided text for this repository's research/archive context. Site navigation/footer language links were converted into metadata.
Last week, Dan Blanchard, the maintainer of chardet—a Python library for detecting text encodings used by roughly 130 million projects a month—released a new version. Version 7.0 is 48 times faster than its predecessor, supports multiple cores, and was redesigned from the ground up. Anthropic's Claude is listed as a contributor. The license changed from LGPL to MIT.
Blanchard's account is that he never looked at the existing source code directly. He fed only the API and the test suite to Claude and asked it to reimplement the library from scratch. The resulting code shares less than 1.3% similarity with any prior version, as measured by JPlag. His conclusion: this is an independent new work, and he is under no obligation to carry forward the LGPL. Mark Pilgrim, the library's original author, opened a GitHub issue to object. The LGPL requires that modifications be distributed under the same license, and a reimplementation produced with ample exposure to the original codebase cannot, in Pilgrim's view, pass as a clean-room effort.
The dispute drew responses from two prominent figures in the open source world. Armin Ronacher, the creator of Flask, welcomed the relicensing. Salvatore Sanfilippo (antirez), the creator of Redis, published a broader defense of AI reimplementation, grounding it in copyright law and the history of the GNU project. Both conclude, by different routes, that what Blanchard did is legitimate. I respect both writers, and I think both are wrong—or more precisely, both are evading the question that actually matters.
That question is this: does legal mean legitimate? Neither piece answers it. Both move from “this is legally permissible” to “this is therefore fine,” without pausing at the gap between those two claims. Law sets a floor; clearing it does not mean the conduct is right. That gap is where this essay begins.
## The analogy points the wrong way
Antirez builds his case on history. When the GNU project reimplemented the UNIX userspace, it was lawful. So was Linux. Copyright law prohibits copying “protected expressions”—the actual code, its structure, its specific mechanisms—but it does not protect ideas or behavior. AI-assisted reimplementation occupies the same legal ground. Therefore, it is lawful.
The legal analysis is largely correct, and I am not disputing it. The problem lies in what antirez does next: he presents the legal conclusion as if it were also a social one, and uses a historical analogy that, examined more carefully, argues against his own position.
When GNU reimplemented the UNIX userspace, the vector ran from proprietary to free. Stallman was using the limits of copyright law to turn proprietary software into free software. The ethical force of that project did not come from its legal permissibility—it came from the direction it was moving, from the fact that it was expanding the commons. That is why people cheered.
The vector in the chardet case runs the other way. Software protected by a copyleft license—one that guarantees users the right to study, modify, and redistribute derivative works under the same terms—has been reimplemented under a permissive license that carries no such guarantee. This is not a reimplementation that expands the commons. It is one that removes the fencing that protected the commons. Derivative works built on chardet 7.0 are under no obligation to share their source code. That obligation, which applied to a library downloaded 130 million times a month, is now gone.
Antirez does not address this directional difference. He invokes the GNU precedent, but that precedent is a counterexample to his conclusion, not a supporting one.
## Does the GPL work against sharing?
Ronacher's argument is different. He discloses upfront that he has a stake in the outcome: “I personally have a horse in the race here because I too wanted chardet to be under a non-GPL license for many years. So consider me a very biased person in that regard.” He goes on to write that he considers “the GPL to run against that spirit by restricting what can be done with it”—the spirit being that society is better off when we share.
This claim rests on a fundamental misreading of what the GPL does.
Start with what the GPL actually prohibits. It does not prohibit keeping source code private. It imposes no constraint on privately modifying GPL software and using it yourself. The GPL's conditions are triggered only by distribution. If you distribute modified code, or offer it as a networked service, you must make the source available under the same terms. This is not a restriction on sharing. It is a condition placed on sharing: if you share, you must share in kind.
The requirement that improvements be returned to the commons is not a mechanism that suppresses sharing. It is a mechanism that makes sharing recursive and self-reinforcing. The claim that imposing contribution obligations on users of a commons undermines sharing culture does not hold together logically.
The contrast with the MIT license clarifies the point. Under MIT, anyone may take code, improve it, and close it off into a proprietary product. You can receive from the commons without giving back. If Ronacher calls this structure “more share-friendly,” he is using a concept of sharing with a specific directionality built in: sharing flows toward whoever has more capital and more engineers to take advantage of it.
The historical record bears this out. In the 1990s, companies routinely absorbed GPL code into proprietary products—not because they had chosen permissive licenses, but because copyleft enforcement was slack. The strengthening of copyleft mechanisms closed that gap. For individual developers and small projects without the resources to compete on anything but reciprocity, copyleft was what made the exchange approximately fair.
The creator of Flask knows this distinction. If he elides it anyway, the argument is not naïve—it is convenient.
## A self-refuting example
The most interesting moment in Ronacher's piece is not the argument but a detail he mentions in passing: Vercel reimplemented GNU Bash using AI and published it, then got visibly upset when Cloudflare reimplemented Next.js the same way.
Ronacher notes this as an irony and moves on. But the irony cuts deeper than he lets on. Next.js is MIT licensed. Cloudflare's vinext did not violate any license—it did exactly what Ronacher calls a contribution to the culture of openness, applied to a permissively licensed codebase. Vercel's reaction had nothing to do with license infringement; it was purely competitive and territorial. The implicit position is: reimplementing GPL software as MIT is a victory for sharing, but having our own MIT software reimplemented by a competitor is cause for outrage. This is what the claim that permissive licensing is “more share-friendly” than copyleft looks like in practice. The spirit of sharing, it turns out, runs in one direction only: outward from oneself.
Ronacher registers the contradiction and does not stop. “This development plays into my worldview,” he writes. When you present evidence that cuts against your own position, acknowledge it, and then proceed to your original conclusion unchanged, that is a signal that the conclusion preceded the argument.
## Legality and social legitimacy are different registers
Back to the question posed at the start. Is legal the same as legitimate?
Antirez closes his careful legal analysis as though it settles the matter. Ronacher acknowledges that “there is an obvious moral question here, but that isn't necessarily what I'm interested in.” Both pieces treat legal permissibility as a proxy for social legitimacy. But law only says what conduct it will not prevent—it does not certify that conduct as right. Aggressive tax minimization that never crosses into illegality may still be widely regarded as antisocial. A pharmaceutical company that legally acquires a patent on a long-generic drug and raises the price a hundredfold has done something legal, but that does not make it fine. Legality is a necessary condition; it is not a sufficient one.
In the chardet case, the distinction is sharper still. What the LGPL protected was not Blanchard's labor alone. It was a social compact agreed to by everyone who contributed to the library over twelve years. The terms of that compact were: if you take this and build on it, you share back under the same terms. This compact operated as a legal instrument, yes, but it was also the foundation of trust that made contribution rational. The fact that a reimplementation may qualify legally as a new work, and the fact that it breaks faith with the original contributors, are separate questions. If a court eventually rules in Blanchard's favor, that ruling will tell us what the law permits. It will not tell us that the act was right.
Zoë Kooyman, executive director of the FSF, put it plainly: “Refusing to grant others the rights you yourself received as a user is highly antisocial, no matter what method you use.”
## Whose perspective is the default?
Reading this debate, I keep returning to a question about position. From where are these two writers looking at the situation?
Antirez created Redis. Ronacher created Flask. Both are figures at the center of the open source ecosystem, with large audiences and well-established reputations. For them, falling costs of AI reimplementation means something specific: it is easier to reimplement things they want in a different form. Ronacher says explicitly that he had begun reimplementing GNU Readline precisely because of its copyleft terms.
For the people who have spent years contributing to a library like chardet, the same shift in costs means something else entirely: the copyleft protection around their contributions can be removed. The two writers are speaking from the former position to people in the latter, telling them that this was always lawful, that historical precedent supports it, and that the appropriate response is adaptation.
When positional asymmetry of this kind is ignored, and the argument is presented as universal analysis, what you get is not analysis but rationalization. Both writers arrive at conclusions that align precisely with their own interests. Readers should hold that fact in mind.
## What this fight points toward
Bruce Perens, who wrote the original Open Source Definition, told The Register: “The entire economics of software development are dead, gone, over, kaput!” He meant it as an alarm. Antirez, from a similar assessment of the situation, draws the conclusion: adapt. Ronacher says he finds the direction exciting.
None of the three responses addresses the central question. When copyleft becomes technically easier to circumvent, does that make it less necessary, or more?
I think more. What the GPL protected was not the scarcity of code but the freedom of users. The fact that producing code has become cheaper does not make it acceptable to use that code as a vehicle for eroding freedom. If anything, as the friction of reimplementation disappears, so does the friction of stripping copyleft from anything left exposed. The erosion of enforcement capacity is a legal problem. It does not touch the underlying normative judgment.
That judgment is this: those who take from the commons owe something back to the commons. The principle does not change depending on whether a reimplementation takes five years or five days. No court ruling on AI-generated code will alter its social weight.
This is where law and community norms diverge. Law is made slowly, after the fact, reflecting existing power arrangements. The norms that open source communities built over decades did not wait for court approval. People chose the GPL when the law offered them no guarantee of its enforcement, because it expressed the values of the communities they wanted to belong to. Those values do not expire when the law changes.
In previous writing, I argued for a training copyleft (TGPL) as the next step in this line of development. The chardet situation suggests the argument has to go further: to a specification copyleft covering the layer below source code. If source code can now be generated from a specification, the specification is where the essential intellectual content of a GPL project resides. Blanchard's own claim—that he worked only from the test suite and API without reading the source—is, paradoxically, an argument for protecting that test suite and API specification under copyleft terms.
The history of the GPL is the history of licensing tools evolving in response to new forms of exploitation: GPLv2 to GPLv3, then AGPL. What drove each evolution was not a court ruling but a community reaching a value judgment first and then seeking legal instruments to express it. The same sequence is available now. Whatever courts eventually decide about AI reimplementation, the question we need to answer first is not a legal one. It is a social one. Do those who take from the commons owe something back? I think they do. That judgment does not require a verdict.
What makes the pieces by antirez and Ronacher worth reading is not that they are right. It is that they make visible, with unusual clarity, what they are choosing not to see. When legality is used as a substitute for a value judgment, the question that actually matters gets buried in the footnotes of a law it has already outgrown.

View File

@ -0,0 +1,106 @@
# Claude Code Python Porting Workspace
> The primary `src/` tree in this repository is now dedicated to **Python porting work**. The March 31, 2026 Claude Code source exposure is part of the project's background, but the tracked repository is now centered on Python source rather than the exposed TypeScript snapshot.
---
## Porting Status
The main source tree is now Python-first.
- `src/` contains the active Python porting workspace
- `tests/` verifies the current Python workspace
- the exposed snapshot is no longer part of the tracked repository state
The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
## Why this rewrite exists
I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
This repository now focuses on Python porting work instead.
## Repository Layout
```text
.
├── src/ # Python porting workspace
│ ├── __init__.py
│ ├── commands.py
│ ├── main.py
│ ├── models.py
│ ├── port_manifest.py
│ ├── query_engine.py
│ ├── task.py
│ └── tools.py
├── tests/ # Python verification
├── assets/omx/ # OmX workflow screenshots
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
└── README.md
```
## Python Workspace Overview
The new Python `src/` tree currently provides:
- **`port_manifest.py`** — summarizes the current Python workspace structure
- **`models.py`** — dataclasses for subsystems, modules, and backlog state
- **`commands.py`** — Python-side command port metadata
- **`tools.py`** — Python-side tool port metadata
- **`query_engine.py`** — renders a Python porting summary from the active workspace
- **`main.py`** — a CLI entrypoint for manifest and summary output
## Quickstart
Render the Python porting summary:
```bash
python3 -m src.main summary
```
Print the current Python workspace manifest:
```bash
python3 -m src.main manifest
```
List the current Python modules:
```bash
python3 -m src.main subsystems --limit 16
```
Run verification:
```bash
python3 -m unittest discover -s tests -v
```
## Related Essay
- [*Is legal the same as legitimate: AI reimplementation and the erosion of copyleft*](https://writings.hongminhee.org/2026/03/legal-vs-legitimate/)
The essay is dated **March 9, 2026**, so it should be read as companion analysis that predates the **March 31, 2026** source exposure that motivated this rewrite direction.
## Built with `oh-my-codex`
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
- **`$team` mode:** used for coordinated parallel review and architectural feedback
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
### OmX workflow screenshots
![OmX workflow screenshot 1](assets/omx/omx-readme-review-1.png)
*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
![OmX workflow screenshot 2](assets/omx/omx-readme-review-2.png)
*Split-pane review and verification flow during the final README wording pass.*
## Ownership / Affiliation Disclaimer
- This repository does **not** claim ownership of the original Claude Code source material.
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,16 @@
"""Python porting workspace for the Claude Code rewrite effort."""
from .commands import PORTED_COMMANDS, build_command_backlog
from .port_manifest import PortManifest, build_port_manifest
from .query_engine import QueryEnginePort
from .tools import PORTED_TOOLS, build_tool_backlog
__all__ = [
'PortManifest',
'QueryEnginePort',
'PORTED_COMMANDS',
'PORTED_TOOLS',
'build_command_backlog',
'build_port_manifest',
'build_tool_backlog',
]

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from .models import PortingBacklog, PortingModule
PORTED_COMMANDS = (
PortingModule('main', 'Expose a Python CLI for manifest and backlog reporting', 'src/main.py', 'implemented'),
PortingModule('summary', 'Render a Markdown overview of the current porting workspace', 'src/query_engine.py', 'implemented'),
PortingModule('subsystems', 'List the current Python modules participating in the rewrite', 'src/port_manifest.py', 'implemented'),
)
def build_command_backlog() -> PortingBacklog:
return PortingBacklog(title='Command surface', modules=list(PORTED_COMMANDS))

View File

@ -0,0 +1,38 @@
from __future__ import annotations
import argparse
from .port_manifest import build_port_manifest
from .query_engine import QueryEnginePort
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description='Python porting workspace for the Claude Code rewrite effort')
subparsers = parser.add_subparsers(dest='command', required=True)
subparsers.add_parser('summary', help='render a Markdown summary of the Python porting workspace')
subparsers.add_parser('manifest', help='print the current Python workspace manifest')
list_parser = subparsers.add_parser('subsystems', help='list the current Python modules in the workspace')
list_parser.add_argument('--limit', type=int, default=16)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
manifest = build_port_manifest()
if args.command == 'summary':
print(QueryEnginePort(manifest).render_summary())
return 0
if args.command == 'manifest':
print(manifest.to_markdown())
return 0
if args.command == 'subsystems':
for subsystem in manifest.top_level_modules[: args.limit]:
print(f'{subsystem.name} {subsystem.file_count} {subsystem.notes}')
return 0
parser.error(f'unknown command: {args.command}')
return 2
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Subsystem:
name: str
path: str
file_count: int
notes: str
@dataclass(frozen=True)
class PortingModule:
name: str
responsibility: str
source_hint: str
status: str = 'planned'
@dataclass
class PortingBacklog:
title: str
modules: list[PortingModule] = field(default_factory=list)
def summary_lines(self) -> list[str]:
return [
f'- {module.name} [{module.status}] — {module.responsibility} (from {module.source_hint})'
for module in self.modules
]

View File

@ -0,0 +1,52 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from .models import Subsystem
DEFAULT_SRC_ROOT = Path(__file__).resolve().parent
@dataclass(frozen=True)
class PortManifest:
src_root: Path
total_python_files: int
top_level_modules: tuple[Subsystem, ...]
def to_markdown(self) -> str:
lines = [
f'Port root: `{self.src_root}`',
f'Total Python files: **{self.total_python_files}**',
'',
'Top-level Python modules:',
]
for module in self.top_level_modules:
lines.append(f'- `{module.name}` ({module.file_count} files) — {module.notes}')
return '\n'.join(lines)
def build_port_manifest(src_root: Path | None = None) -> PortManifest:
root = src_root or DEFAULT_SRC_ROOT
files = [path for path in root.rglob('*.py') if path.is_file()]
counter = Counter(
path.relative_to(root).parts[0] if len(path.relative_to(root).parts) > 1 else path.name
for path in files
if path.name != '__pycache__'
)
notes = {
'__init__.py': 'package export surface',
'main.py': 'CLI entrypoint',
'port_manifest.py': 'workspace manifest generation',
'query_engine.py': 'port orchestration summary layer',
'commands.py': 'command backlog metadata',
'tools.py': 'tool backlog metadata',
'models.py': 'shared dataclasses',
'task.py': 'task-level planning structures',
}
modules = tuple(
Subsystem(name=name, path=f'src/{name}', file_count=count, notes=notes.get(name, 'Python port support module'))
for name, count in counter.most_common()
)
return PortManifest(src_root=root, total_python_files=len(files), top_level_modules=modules)

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
from .commands import build_command_backlog
from .port_manifest import PortManifest, build_port_manifest
from .tools import build_tool_backlog
@dataclass
class QueryEnginePort:
manifest: PortManifest
@classmethod
def from_workspace(cls) -> 'QueryEnginePort':
return cls(manifest=build_port_manifest())
def render_summary(self) -> str:
command_backlog = build_command_backlog()
tool_backlog = build_tool_backlog()
sections = [
'# Python Porting Workspace Summary',
'',
self.manifest.to_markdown(),
'',
f'{command_backlog.title}:',
*command_backlog.summary_lines(),
'',
f'{tool_backlog.title}:',
*tool_backlog.summary_lines(),
]
return '\n'.join(sections)

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PortingTask:
title: str
detail: str
completed: bool = False

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from .models import PortingBacklog, PortingModule
PORTED_TOOLS = (
PortingModule('port_manifest', 'Inspect the active Python source tree and summarize the current rewrite surface', 'src/port_manifest.py', 'implemented'),
PortingModule('backlog_models', 'Represent subsystem and backlog metadata as Python dataclasses', 'src/models.py', 'implemented'),
PortingModule('query_engine', 'Coordinate Python-facing rewrite summaries and reporting', 'src/query_engine.py', 'implemented'),
)
def build_tool_backlog() -> PortingBacklog:
return PortingBacklog(title='Tool surface', modules=list(PORTED_TOOLS))

View File

@ -0,0 +1,34 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from src.port_manifest import build_port_manifest
from src.query_engine import QueryEnginePort
class PortingWorkspaceTests(unittest.TestCase):
def test_manifest_counts_python_files(self) -> None:
manifest = build_port_manifest()
self.assertGreaterEqual(manifest.total_python_files, 7)
self.assertTrue(manifest.top_level_modules)
def test_query_engine_summary_mentions_workspace(self) -> None:
summary = QueryEnginePort.from_workspace().render_summary()
self.assertIn('Python Porting Workspace Summary', summary)
self.assertIn('Command surface', summary)
self.assertIn('Tool surface', summary)
def test_cli_summary_runs(self) -> None:
result = subprocess.run(
[sys.executable, '-m', 'src.main', 'summary'],
check=True,
capture_output=True,
text=True,
)
self.assertIn('Python Porting Workspace Summary', result.stdout)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,181 @@
# 桥豆麻衣酱 App 近似源码提取说明
## 结论
已从安装包 `/Applications/桥豆麻衣酱.app` 的可执行文件中成功提取出前端静态资源。
这不是开发者原始仓库源码,而是:
- Tauri 桌面壳里的前端打包产物
- 可直接检索的 JS/CSS/worker/wasm 资源
- 足够用于做接口、权限、业务逻辑、模型支持范围的审计
当前共提取出 `29` 个资源,清单见:
- `manifest.json`
## 目录
- `app.arm64`
- 从通用 Mach-O 中切出来的 arm64 可执行文件
- `extract_tauri_assets.js`
- 本次用于提取内嵌资源的脚本
- `manifest.json`
- 每个资源的偏移、压缩大小、解压大小、输出路径
- `extracted/assets/`
- 解压出的前端资源
## 最重要的文件
- `extracted/assets/index-m-NWkaV-.js`
- 主前端 bundle,约 `4.0 MB`
- React + Vite 风格打包产物
- 绝大多数业务逻辑都在这里
- `extracted/assets/index-CgHmA0qR.css`
- 主样式文件,约 `182 KB`
- `extracted/assets/thumbnailWorker-Iu1AjlwR.js`
- 缩略图 worker,负责图片缩放和 JPEG 缩略图生成
- `extracted/assets/ort.bundle.min-OfoG_cy9.mjs`
- ONNX Runtime Web bundle
- `extracted/assets/ort.webgpu.bundle.min-CEayb2S6.mjs`
- ONNX Runtime WebGPU bundle
- `extracted/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm`
- ONNX Runtime wasm 二进制,约 `23.9 MB`
## 从 bundle 中确认到的内容
### 1. 这确实是 Tauri 桌面应用
在主 bundle 中能直接看到 Tauri 调用痕迹和桌面特有命令,比如:
- `download_file_bypass_cors`
- `proxy_fetch`
- `start_local_server`
可在主文件里直接搜这些关键字。
### 2. 它不是纯聊天壳,而是带图像/视频工作流能力
主 bundle 中能搜到很多工作流节点和媒体能力相关关键词,例如:
- `gen-image`
- `gen-video`
- `storyboard-node`
- `inpaint-crop`
- `motion-control`
- `custom-agent`
- `local-save`
这说明它有比较完整的图像、视频、分镜、局部重绘、本地保存等功能。
### 3. 默认端点和多供应商能力都在前端 bundle 里
主 bundle 中可见默认端点配置:
- `https://ai.comfly.chat`
- `https://ai.t8star.cn`
- `https://api.midjourney.com`
还可以看到对多个供应商和模型系的支持痕迹,例如:
- `openai`
- `gemini`
- `deepseek`
- `doubao`
- `jimeng`
- `kling`
- `vidu`
- `zhipu`
- `moonshot`
- `qwen`
- `minimax`
- `midjourney`
- `suno`
### 4. 图片审核地址的默认值写在前端状态初始化里
主 bundle 内能直接看到默认审核地址:
- `http://118.196.112.236:3428/api/moderation/image`
同时它还读取这几个本地存储键:
- `qiaodoumayi_seedance_moderation_url`
- `qiaodoumayi_seedance_moderation_user_id`
- `qiaodoumayi_seedance_moderation_key_hex`
这说明图片审核地址并不是仅服务端下发,也可能由本地配置覆盖。
### 5. 本地保存能力通过本地服务和文件落地配合实现
主 bundle 中可见:
- `start_local_server`
- `qiaodoumayi_download_directory`
这说明它支持启动本地接收器/本地服务,再结合用户设置的下载目录做文件保存。
## 建议优先看的位置
### 主 bundle
文件:
- `extracted/assets/index-m-NWkaV-.js`
建议优先搜索这些词:
- `qiaodoumayi_`
- `seedance`
- `moderation`
- `download_file_bypass_cors`
- `proxy_fetch`
- `start_local_server`
- `runninghub`
- `api.openai.com`
- `generativelanguage.googleapis.com`
- `dashscope.aliyuncs.com`
### 缩略图 worker
文件:
- `extracted/assets/thumbnailWorker-Iu1AjlwR.js`
这个文件很短,适合先看,能快速确认:
- 它会 `fetch` 图片
- 用 `createImageBitmap` 和 `OffscreenCanvas` 做缩放
- 最终转成 JPEG data URL 返回给主线程
## 已确认的几个关键定位点
以下行号来自提取后的 bundle,可作为检索起点:
- `index-m-NWkaV-.js:924`
- `qiaodoumayi_seedance_moderation_url`
- `index-m-NWkaV-.js:1673`
- `download_file_bypass_cors`
- `index-m-NWkaV-.js:2068`
- `start_local_server`
注意:
- 这是打包文件,很多行非常长
- 行号只能作为“进入附近区域”的参考,不等于原始源码位置
## 局限
- 没找到 sourcemap
- 没恢复出原始 React/TypeScript 目录结构
- 变量名、模块边界、注释都已经过打包压缩处理
- 目前恢复的是“近似源码”,不是开发者仓库
## 怎么继续深挖
如果要继续做更细的审计,下一步建议按下面顺序:
1. 以 `index-m-NWkaV-.js` 为主,按 `qiaodoumayi_`、`seedance`、`proxy`、`download` 搜配置和命令入口
2. 把出现的外连域名、接口路径、Tauri 命令整理成一张表
3. 继续从本地存储和日志交叉验证哪些配置在这台机器上真正被启用
4. 对最敏感的几条链路单独追踪:图片审核、本地代理、本地保存、文件下载绕过 CORS

View File

@ -0,0 +1,181 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const INPUT = "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/index-m-NWkaV-.js";
const OUTPUT_JSON = "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/bundle-intel.json";
const source = fs.readFileSync(INPUT, "utf8");
function unique(values) {
return Array.from(new Set(values));
}
function safeEvalObjectLiteral(literal) {
return vm.runInNewContext(`(${literal})`, Object.create(null), {
timeout: 1000,
});
}
function extractBetween(startMarker, endMarker) {
const start = source.indexOf(startMarker);
if (start < 0) return null;
const from = start + startMarker.length;
const end = source.indexOf(endMarker, from);
if (end < 0) return null;
return source.slice(from, end);
}
function normalizeUrl(url) {
return String(url || "").replace(/[.,;]+$/, "").trim();
}
function isLikelyAppRelevantUrl(url) {
if (!url || url === "http://" || url === "https://") return false;
return !(
url.includes("w3.org/") ||
url.includes("apache.org/") ||
url.includes("reactjs.org/") ||
url.includes("github.com/") ||
url.includes("raw.github.com/") ||
url.includes("developer.mozilla.org/") ||
url.includes("feross.org") ||
url.includes("stuk.github.io/") ||
url.includes("stuartk.com/jszip")
);
}
const storageKeys = unique(
Array.from(source.matchAll(/qiaodoumayi_[A-Za-z0-9_]+/g)).map((match) => match[0])
).sort();
const tauriCommands = unique(
Array.from(source.matchAll(/yn\("([a-zA-Z0-9_:-]+)"/g)).map((match) => match[1])
).sort();
const requireHints = unique(
Array.from(source.matchAll(/\.\.\/[A-Za-z0-9_./-]+/g)).map((match) => match[0])
).sort();
const allUrls = unique(
Array.from(source.matchAll(/https?:\/\/[^\s"'`\\)<>,]+/g)).map((match) => normalizeUrl(match[0]))
).sort();
const appRelevantUrls = allUrls.filter(isLikelyAppRelevantUrl);
const domains = unique(
appRelevantUrls
.map((url) => {
try {
return new URL(url).host;
} catch {
return null;
}
})
.filter(Boolean)
).sort();
let quickConnectConfig = {};
const quickConnectLiteral = extractBetween("DV=", ";function npe");
if (quickConnectLiteral) {
quickConnectConfig = safeEvalObjectLiteral(quickConnectLiteral);
}
let nodeLabelMap = {};
const nodeLabelMatch = source.match(/function Rm\(e\)\{return(\{[\s\S]*?\})\[e\]\|\|"节点"\}/);
if (nodeLabelMatch) {
nodeLabelMap = safeEvalObjectLiteral(nodeLabelMatch[1]);
}
const nodeSizeMap = {};
const nodeSizeMatch = source.match(/function jO\(e\)\{switch\(e\)\{([\s\S]*?)default:return\{w:(\d+),h:(\d+)\}\}\}/);
if (nodeSizeMatch) {
const body = nodeSizeMatch[1];
const defaultSize = { w: Number(nodeSizeMatch[2]), h: Number(nodeSizeMatch[3]) };
nodeSizeMap.default = defaultSize;
const groupedCases = body.matchAll(/((?:case"[^"]+":)+)return\{w:(\d+),h:(\d+)\}/g);
for (const groupedCase of groupedCases) {
const labels = Array.from(groupedCase[1].matchAll(/case"([^"]+)":/g)).map((match) => match[1]);
const size = { w: Number(groupedCase[2]), h: Number(groupedCase[3]) };
for (const label of labels) {
nodeSizeMap[label] = size;
}
}
}
const quickConnectTypes = Object.keys(quickConnectConfig);
const nodeTypes = unique([
...Object.keys(nodeLabelMap),
...Object.keys(nodeSizeMap).filter((key) => key !== "default"),
...quickConnectTypes,
...quickConnectTypes.flatMap((type) =>
(quickConnectConfig[type]?.output || []).map((item) => item.type)
),
]).sort();
const nodeCatalog = nodeTypes.map((type) => ({
type,
label: nodeLabelMap[type] || null,
size: nodeSizeMap[type] || nodeSizeMap.default || null,
quickConnectOutputs: (quickConnectConfig[type]?.output || []).map((item) => ({
type: item.type,
label: item.label,
})),
}));
const indexedDb = {
databaseName: source.includes('"qiaodoumayi_projects"') ? "qiaodoumayi_projects" : null,
version: source.includes('qiaodoumayi_projects",Ioe=2') ? 2 : null,
stores: unique(
Array.from(source.matchAll(/"(project_meta|project_payload|project_assets)"/g)).map(
(match) => match[1]
)
),
};
const snippets = {};
for (const term of [
"qiaodoumayi_seedance_moderation_url",
"download_file_bypass_cors",
"start_local_server",
"qiaodoumayi_projects",
"qiaodoumayi_script_projects",
"qiaodoumayi_saved_skills",
"qiaodoumayi_characters",
"https://ai.comfly.chat/sora/v1/characters",
"https://www.runninghub.cn",
]) {
const index = source.indexOf(term);
snippets[term] =
index >= 0
? {
index,
preview: source.slice(Math.max(0, index - 180), Math.min(source.length, index + 260)),
}
: null;
}
const result = {
input: INPUT,
stats: {
bytes: Buffer.byteLength(source, "utf8"),
lines: source.split("\n").length,
hasSourceMap: /sourceMappingURL/.test(source),
},
storageKeys,
tauriCommands,
requireHints,
urls: {
appRelevant: appRelevantUrls,
all: allUrls,
domains,
},
indexedDb,
quickConnectConfig,
nodeLabelMap,
nodeSizeMap,
nodeCatalog,
snippets,
};
fs.writeFileSync(OUTPUT_JSON, `${JSON.stringify(result, null, 2)}\n`);
console.log(`Wrote ${OUTPUT_JSON}`);

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,116 @@
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const INPUT =
"/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/app.arm64";
const OUTPUT_ROOT =
"/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted";
const MANIFEST =
"/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/manifest.json";
const ASSET_REGEX =
/\/assets\/[A-Za-z0-9 _.@/-]+\.(?:mjs|wasm|webp|png|svg|css|js|ico)(?=[^A-Za-z0-9_.@/-]|$)/g;
const INDEX_HTML_REGEX = /\/index\.html/g;
function collectOffsets(text, regex) {
const hits = [];
let match;
while ((match = regex.exec(text)) !== null) {
hits.push({
value: match[0],
offset: match.index,
});
}
return hits;
}
function uniqueByOffset(items) {
const seen = new Set();
return items.filter((item) => {
if (seen.has(item.offset)) {
return false;
}
seen.add(item.offset);
return true;
});
}
function ensureDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function main() {
const buffer = fs.readFileSync(INPUT);
const text = buffer.toString("latin1");
const assets = uniqueByOffset(collectOffsets(text, ASSET_REGEX)).sort(
(a, b) => a.offset - b.offset,
);
const indexHtmlHits = uniqueByOffset(collectOffsets(text, INDEX_HTML_REGEX)).sort(
(a, b) => a.offset - b.offset,
);
if (!assets.length) {
throw new Error("No embedded /assets/* entries were found.");
}
const nextBoundaries = [...assets.map((a) => a.offset), ...indexHtmlHits.map((a) => a.offset)]
.sort((a, b) => a - b);
const manifest = [];
for (const asset of assets) {
const contentStart = asset.offset + Buffer.byteLength(asset.value);
const nextOffset = nextBoundaries.find((offset) => offset > asset.offset);
if (!nextOffset) {
manifest.push({
asset: asset.value,
offset: asset.offset,
status: "skipped_no_boundary",
});
continue;
}
const compressed = buffer.subarray(contentStart, nextOffset);
try {
const decompressed = zlib.brotliDecompressSync(compressed);
const outputPath = path.join(OUTPUT_ROOT, asset.value.replace(/^\//, ""));
ensureDir(outputPath);
fs.writeFileSync(outputPath, decompressed);
manifest.push({
asset: asset.value,
offset: asset.offset,
nextOffset,
compressedBytes: compressed.length,
decompressedBytes: decompressed.length,
outputPath,
status: "ok",
});
} catch (error) {
manifest.push({
asset: asset.value,
offset: asset.offset,
nextOffset,
compressedBytes: compressed.length,
status: "decompress_failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
fs.mkdirSync(OUTPUT_ROOT, { recursive: true });
fs.writeFileSync(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`);
const ok = manifest.filter((item) => item.status === "ok").length;
const failed = manifest.filter((item) => item.status !== "ok").length;
console.log(`Extracted: ${ok}`);
console.log(`Failed: ${failed}`);
console.log(`Manifest: ${MANIFEST}`);
}
main();

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -0,0 +1 @@
(function(){"use strict";async function h(l,e,n){if(typeof createImageBitmap>"u"||typeof OffscreenCanvas>"u")throw new Error("createImageBitmap or OffscreenCanvas not supported in this worker");const s=await fetch(l);if(!s.ok)throw new Error("Failed to fetch image data");const d=await s.blob(),t=await createImageBitmap(d);try{let a=t.width,r=t.height;if(a>e||r>e){const i=Math.min(e/a,e/r);a=Math.round(a*i),r=Math.round(r*i)}const f=new OffscreenCanvas(a,r),o=f.getContext("2d");if(!o)throw new Error("Failed to get OffscreenCanvas context");o.imageSmoothingEnabled=!0,o.imageSmoothingQuality="high",o.drawImage(t,0,0,a,r);const g=await f.convertToBlob({type:"image/jpeg",quality:n});return await new Promise((i,u)=>{const c=new FileReader;c.onloadend=()=>i(c.result),c.onerror=u,c.readAsDataURL(g)})}finally{t.close()}}self.onmessage=async l=>{const e=l.data;try{if(e.type==="generate"){const n=await h(e.imageData,e.maxSize,e.quality),s={type:"result",id:e.id,thumbnailData:n};self.postMessage(s)}else if(e.type==="batch-generate"&&e.batch){const s=(await Promise.allSettled(e.batch.map(t=>h(t.imageData,t.maxSize,t.quality).then(a=>({id:t.id,thumbnailData:a})).catch(a=>({id:t.id,error:a.message}))))).map((t,a)=>{var r;return t.status==="fulfilled"?t.value:{id:e.batch[a].id,error:((r=t.reason)==null?void 0:r.message)||"Unknown error"}}),d={type:"batch-result",id:e.id,batch:s};self.postMessage(d)}}catch(n){const s={type:e.type==="generate"?"result":"batch-result",id:e.id,thumbnailData:"",error:n instanceof Error?n.message:String(n)};self.postMessage(s)}}})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,263 @@
[
{
"asset": "/assets/index-m-NWkaV-.js",
"offset": 4359604,
"nextOffset": 5205083,
"compressedBytes": 845454,
"decompressedBytes": 4047131,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/index-m-NWkaV-.js",
"status": "ok"
},
{
"asset": "/assets/suno baise--dMPbHiP.webp",
"offset": 5205083,
"nextOffset": 5535535,
"compressedBytes": 330420,
"decompressedBytes": 5138,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/suno baise--dMPbHiP.webp",
"status": "ok"
},
{
"asset": "/assets/thumbnailWorker-Iu1AjlwR.js",
"offset": 5535535,
"nextOffset": 5536567,
"compressedBytes": 997,
"decompressedBytes": 1572,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/thumbnailWorker-Iu1AjlwR.js",
"status": "ok"
},
{
"asset": "/assets/gemini-color-BRjDr-0b.webp",
"offset": 5536567,
"nextOffset": 5844908,
"compressedBytes": 308307,
"decompressedBytes": 15722,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/gemini-color-BRjDr-0b.webp",
"status": "ok"
},
{
"asset": "/assets/openai-DM5htlBi.webp",
"offset": 5844908,
"nextOffset": 6097573,
"compressedBytes": 252637,
"decompressedBytes": 15628,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/openai-DM5htlBi.webp",
"status": "ok"
},
{
"asset": "/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm",
"offset": 6097573,
"nextOffset": 10035082,
"compressedBytes": 3937460,
"decompressedBytes": 23914392,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm",
"status": "ok"
},
{
"asset": "/assets/openai bai-C9wdsUvK.webp",
"offset": 10035082,
"nextOffset": 10050199,
"compressedBytes": 15085,
"decompressedBytes": 15756,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/openai bai-C9wdsUvK.webp",
"status": "ok"
},
{
"asset": "/assets/ort.bundle.min-OfoG_cy9.mjs",
"offset": 10050199,
"nextOffset": 10148887,
"compressedBytes": 98653,
"decompressedBytes": 399902,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/ort.bundle.min-OfoG_cy9.mjs",
"status": "ok"
},
{
"asset": "/assets/midjourney bai-4Fw3hs7F.webp",
"offset": 10148887,
"nextOffset": 10213919,
"compressedBytes": 64996,
"decompressedBytes": 15956,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/midjourney bai-4Fw3hs7F.webp",
"status": "ok"
},
{
"asset": "/assets/midjourney-BOG0L-7m.webp",
"offset": 10213919,
"nextOffset": 10388773,
"compressedBytes": 174822,
"decompressedBytes": 15882,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/midjourney-BOG0L-7m.webp",
"status": "ok"
},
{
"asset": "/assets/doubao-color-BfZ9o9VO.webp",
"offset": 10388773,
"nextOffset": 10592561,
"compressedBytes": 203754,
"decompressedBytes": 19890,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/doubao-color-BfZ9o9VO.webp",
"status": "ok"
},
{
"asset": "/assets/qwen-color-ig8k3JUL.webp",
"offset": 10592561,
"nextOffset": 11246775,
"compressedBytes": 654182,
"decompressedBytes": 19066,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/qwen-color-ig8k3JUL.webp",
"status": "ok"
},
{
"asset": "/assets/kling-color-O6Fl0YZh.webp",
"offset": 11246775,
"nextOffset": 11275612,
"compressedBytes": 28804,
"decompressedBytes": 28800,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/kling-color-O6Fl0YZh.webp",
"status": "ok"
},
{
"asset": "/assets/minimax-color-Dx2mFylD.webp",
"offset": 11275612,
"nextOffset": 11486472,
"compressedBytes": 210825,
"decompressedBytes": 13020,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/minimax-color-Dx2mFylD.webp",
"status": "ok"
},
{
"asset": "/assets/index-CgHmA0qR.css",
"offset": 11486472,
"nextOffset": 11511029,
"compressedBytes": 24531,
"decompressedBytes": 182008,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/index-CgHmA0qR.css",
"status": "ok"
},
{
"asset": "/assets/ort.webgpu.bundle.min-CEayb2S6.mjs",
"offset": 11511029,
"nextOffset": 11609722,
"compressedBytes": 98651,
"decompressedBytes": 399923,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/ort.webgpu.bundle.min-CEayb2S6.mjs",
"status": "ok"
},
{
"asset": "/assets/deepseek-color-XniBTJPh.webp",
"offset": 11609722,
"nextOffset": 11871348,
"compressedBytes": 261590,
"decompressedBytes": 20426,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/deepseek-color-XniBTJPh.webp",
"status": "ok"
},
{
"asset": "/assets/suno-Xv1k_XXC.webp",
"offset": 11871348,
"nextOffset": 12859194,
"compressedBytes": 987820,
"decompressedBytes": 4954,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/suno-Xv1k_XXC.webp",
"status": "ok"
},
{
"asset": "/assets/moonshot-Cy56VaSZ.webp",
"offset": 12859194,
"nextOffset": 12869861,
"compressedBytes": 10637,
"decompressedBytes": 11248,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/moonshot-Cy56VaSZ.webp",
"status": "ok"
},
{
"asset": "/assets/grok bai-CiCMAJ0q.webp",
"offset": 12869861,
"nextOffset": 13298951,
"compressedBytes": 429060,
"decompressedBytes": 10538,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/grok bai-CiCMAJ0q.webp",
"status": "ok"
},
{
"asset": "/assets/vidu-color-DfcYl6hz.webp",
"offset": 13298951,
"nextOffset": 14410560,
"compressedBytes": 1111577,
"decompressedBytes": 26920,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/vidu-color-DfcYl6hz.webp",
"status": "ok"
},
{
"asset": "/assets/zhipu-color-PY4a2Lc6.webp",
"offset": 14410560,
"nextOffset": 14563629,
"compressedBytes": 153036,
"decompressedBytes": 22086,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/zhipu-color-PY4a2Lc6.webp",
"status": "ok"
},
{
"asset": "/assets/flux bai-CYYe7fkT.webp",
"offset": 14563629,
"nextOffset": 14571059,
"compressedBytes": 7400,
"decompressedBytes": 8146,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/flux bai-CYYe7fkT.webp",
"status": "ok"
},
{
"asset": "/assets/nanobanana-color-Re8s94pO.webp",
"offset": 14571059,
"nextOffset": 14584320,
"compressedBytes": 13223,
"decompressedBytes": 13366,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/nanobanana-color-Re8s94pO.webp",
"status": "ok"
},
{
"asset": "/assets/jimeng-color-CtBciSlW.webp",
"offset": 14584320,
"nextOffset": 14604596,
"compressedBytes": 20242,
"decompressedBytes": 20274,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/jimeng-color-CtBciSlW.webp",
"status": "ok"
},
{
"asset": "/assets/bytedance-color-CPzTHojG.webp",
"offset": 14604596,
"nextOffset": 14611653,
"compressedBytes": 7020,
"decompressedBytes": 7016,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/bytedance-color-CPzTHojG.webp",
"status": "ok"
},
{
"asset": "/assets/grok-DEnnqgaa.webp",
"offset": 14611653,
"nextOffset": 15025424,
"compressedBytes": 413745,
"decompressedBytes": 10334,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/grok-DEnnqgaa.webp",
"status": "ok"
},
{
"asset": "/assets/flux-CzJA5PDw.webp",
"offset": 15025424,
"nextOffset": 15104855,
"compressedBytes": 79405,
"decompressedBytes": 8072,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/flux-CzJA5PDw.webp",
"status": "ok"
},
{
"asset": "/assets/moonshot baise-CDuFHouK.webp",
"offset": 15104855,
"nextOffset": 15397413,
"compressedBytes": 292522,
"decompressedBytes": 11172,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/moonshot baise-CDuFHouK.webp",
"status": "ok"
}
]

View File

@ -0,0 +1,598 @@
# 桥豆麻衣酱 App 复刻导向功能结构报告
## 目标
这份报告不是单纯做逆向记录,而是回答一个更实际的问题:
- 这个 App 到底由哪些核心子系统组成
- 哪些能力最值得优先复刻到我们项目的无限画布
- 我们当前项目已经有什么
- 还缺什么
配套机器可读情报见:
- `bundle-intel.json`
- `README.md`
- `extracted/assets/index-m-NWkaV-.js`
## 一句话结论
“桥豆麻衣酱”不是一个简单的聊天壳,它更像一个:
- `Tauri 桌面壳`
- `无限画布 / 工作流节点编辑器`
- `多供应商图像/视频/角色/分镜生产平台`
- `带本地缓存、本地导出、本地保存、本地代理能力的创作工作台`
从提取到的前端 bundle 看,它的产品结构已经明显超过“聊天 + 生图”阶段,核心是“节点化创作系统”。
## 1. 结构总览
### A. 桌面壳与原生能力
前端 bundle 中直接调用的 Tauri 命令包括:
- `download_file_bypass_cors`
- `proxy_fetch`
- `proxy_fetch_raw`
- `proxy_dashscope_request`
- `start_local_server`
- `open_folder_in_explorer`
- `export_project`
- `import_project`
- `export_categorized_project`
- `import_categorized_project`
- `read_file_as_base64`
- `write_base64_to_file`
- `write_base64_to_files_batch`
- `write_file_chunked`
- `process_large_data_rust`
- `rust_cache_get`
- `rust_cache_set`
- `rust_cache_remove`
这说明它不是“前端页面 + 远程 API”这么简单,而是有明确的桌面工作站属性:
- 本地文件读写
- 本地代理请求
- 本地导入导出
- 本地缓存
- 大数据处理交给 Rust
### B. 项目与持久化层
前端 bundle 里能明确看到一个 IndexedDB 工程库:
- 数据库名:`qiaodoumayi_projects`
- 版本:`2`
- Store:
- `project_meta`
- `project_payload`
- `project_assets`
这点非常关键,它说明这个 App 的工程化不是只靠 localStorage,而是分成了:
- 项目元信息
- 项目完整 payload
- 项目资源资产
这对无限画布非常重要,因为一旦你要支持:
- 多节点
- 分镜
- 图像/视频结果
- 历史恢复
- 复杂项目切换
单纯靠后端对话消息或一个大 JSON 是不够的。
### C. 配置与本地状态层
bundle 中存在大量 `qiaodoumayi_*` 配置键,能看出它的本地状态体系非常丰富。按功能大致分组如下。
#### 基础平台配置
- `qiaodoumayi_api_configs`
- `qiaodoumayi_global_key`
- `qiaodoumayi_global_base_url`
- `qiaodoumayi_active_endpoint`
- `qiaodoumayi_last_used_model`
- `qiaodoumayi_model_settings_memory`
#### 画布与 UI 偏好
- `qiaodoumayi_show_canvas_grid`
- `qiaodoumayi_show_canvas_minimap`
- `qiaodoumayi_show_canvas_snap`
- `qiaodoumayi_canvas_bg_color_light`
- `qiaodoumayi_canvas_bg_color_dark`
- `qiaodoumayi_left_toolbar_collapsed`
- `qiaodoumayi_right_toolbar_collapsed`
- `qiaodoumayi_viewport_size`
- `qiaodoumayi_theme`
- `qiaodoumayi_chat_width`
- `qiaodoumayi_chat_font_size`
#### 项目 / 内容 / 历史
- `qiaodoumayi_projects`
- `qiaodoumayi_project_name`
- `qiaodoumayi_history`
- `qiaodoumayi_content_store`
- `qiaodoumayi_thumbnail_store`
- `qiaodoumayi_chat_sessions`
#### 资源库 / 提示词库 / 技能库
- `qiaodoumayi_prompt_library`
- `qiaodoumayi_agent_prompt_library`
- `qiaodoumayi_saved_skills`
- `qiaodoumayi_rh_app_library`
- `qiaodoumayi_pose_library_panel`
#### 角色 / 剧本 / 视频素材相关
- `qiaodoumayi_characters`
- `qiaodoumayi_seedance_roles`
- `qiaodoumayi_kling_subjects`
- `qiaodoumayi_script_projects`
- `qiaodoumayi_script_current_id`
#### 本地保存与缓存
- `qiaodoumayi_download_directory`
- `qiaodoumayi_saved_folder_history`
- `qiaodoumayi_cache_dir`
- `qiaodoumayi_cache_directory_config`
- `qiaodoumayi_autosave_enabled`
- `qiaodoumayi_autosave_interval_minutes`
#### 审核与特殊服务
- `qiaodoumayi_seedance_moderation_url`
- `qiaodoumayi_seedance_moderation_user_id`
- `qiaodoumayi_seedance_moderation_key_hex`
- `qiaodoumayi_jimeng_super_resolution_url`
- `qiaodoumayi_jimeng_super_resolution_api_key`
结论是:这个 App 有比较完整的“本地工作台态”,不是一个一次性生成器。
## 2. 工作流画布才是核心
### 节点系统规模
从 bundle 中恢复出的节点目录至少有 `34` 个节点类型。它不是简单的“图片节点 + 视频节点”,而是工作流节点系统。
比较关键的一批节点如下:
- `input-image`
- `video-input`
- `text-node`
- `novel-input`
- `video-analyze`
- `storyboard-node`
- `storyboard-chart-node`
- `table-editor-node`
- `gen-image`
- `gen-video`
- `gen-music`
- `motion-control`
- `camera-movement`
- `professional-camera`
- `inpaint-crop`
- `inpaint-stitch`
- `local-save`
- `preview`
- `custom-agent`
- `rh-app`
- `rh-comfy`
- `jimeng-super-resolution`
- `create-character`
- `create-scene`
- `generate-character-image`
- `generate-character-video`
- `generate-scene-image`
- `generate-scene-video`
- `extract-characters-scenes`
- `character-description`
- `scene-description`
- `doodle-canvas`
- `canvas-node`
- `image-compare`
### 节点中文名与默认尺寸
bundle 中还能直接恢复出节点显示名和默认尺寸,说明画布布局系统已经很成熟。
一些典型例子:
- `storyboard-node`:`分镜节点`,默认 `600x500`
- `custom-agent`:`自定义代理`,默认 `500x800`
- `video-analyze`:`视频分析`,默认 `400x500`
- `gen-image`:`生成图片`,默认 `360x340`
- `gen-video`:`生成视频`,默认 `360x340`
- `local-save`:`本地保存`,默认 `320x380`
- `jimeng-super-resolution`:`智能超清`,默认 `320x280`
- `inpaint-crop`:`裁剪局部重绘`,默认 `340x380`
- `doodle-canvas`:`涂鸦画板`,默认 `500x300`
这说明它的节点系统并不是运行时随便拼出来的,而是有预定义的产品级节点目录。
### 快速连接关系
bundle 中还恢复出了一个 `QUICK_CONNECT_CONFIG`,也就是“节点之间下一步建议怎么连”。
几条非常有价值的链路如下:
- `text-node -> gen-image / gen-video / custom-agent`
- `input-image -> gen-image / gen-video / storyboard-node / inpaint-crop`
- `video-input -> preview / rh-app`
- `video-analyze -> storyboard-node / gen-image`
- `table-editor-node -> storyboard-node`
- `storyboard-node -> gen-image / gen-video`
- `storyboard-chart-node -> preview`
- `inpaint-stitch -> preview / local-save`
- `novel-input -> custom-agent / text-node / rh-app`
这基本已经把它的产品思路暴露出来了:
- 文本和图像不是终点,而是工作流入口
- 视频分析可以进入分镜
- 分镜又可以再生图、再生视频
- 最后再进入预览和保存
这正是一个“无限画布 + 创作编排器”的产品结构。
## 3. 它的内容生产链路
### A. 图像/视频生成
从 bundle 中可见大量供应商和模型痕迹:
- `OpenAI`
- `Gemini`
- `DeepSeek`
- `Doubao`
- `Jimeng`
- `Kling`
- `Vidu`
- `Zhipu`
- `Moonshot`
- `Qwen`
- `MiniMax`
- `Midjourney`
- `Suno`
默认端点与内置端点包括:
- `https://ai.comfly.chat`
- `https://ai.t8star.cn`
- `https://api.midjourney.com`
- `https://api.openai.com`
- `https://generativelanguage.googleapis.com`
- `https://dashscope.aliyuncs.com`
这说明它本质上是一个“多后端编排前端”,不是单模型专用 UI。
### B. 角色库与分镜库
bundle 中能明确看到:
- 角色库缓存:`qiaodoumayi_characters`
- 远程角色入口:`https://ai.comfly.chat/sora/v1/characters`
- 剧本工程:`qiaodoumayi_script_projects`
- 剧本当前项目:`qiaodoumayi_script_current_id`
- `storyboard-node`
- `storyboard-chart-node`
- `extract-characters-scenes`
这说明它很重视:
- 角色一致性
- 从视频或文本抽结构
- 从剧本进入画布
- 从画布进入分镜
### C. 视频理解与视频工作流
bundle 中可见 `video-analyze` 节点,以及连接视频输入、关键帧、分镜节点的逻辑痕迹。还能看到:
- `motion-control`
- `camera-movement`
- `professional-camera`
- `generate-character-video`
- `generate-scene-video`
这意味着视频在它这里不是“最终结果播放器”,而是完整工作流的一部分。
### D. 技能 / Agent / RH 应用
bundle 中可见:
- `custom-agent`
- `qiaodoumayi_saved_skills`
- `qiaodoumayi_agent_prompt_library`
- `qiaodoumayi_rh_app_library`
- `runninghub`
- `rh-app`
- `rh-comfy`
而且还能看到对 Skill 市场的介绍性内容,这说明:
- 它不是只有模型参数页
- 它还有“技能 / 应用 / Agent 编排”的产品层
## 4. 它的本地桌面工作站特征
### 本地保存链路
bundle 中能直接看到:
- `start_local_server`
- `qiaodoumayi_download_directory`
- `open_folder_in_explorer`
这说明它把“生成完成后的文件落地”当成产品一等公民,而不是交给浏览器下载框完事。
### 本地代理链路
bundle 中能直接看到:
- `proxy_fetch`
- `proxy_fetch_raw`
- `proxy_dashscope_request`
- `http://127.0.0.1:41600/proxy`
- `http://127.0.0.1:9527`
- `http://127.0.0.1:9528`
- `http://127.0.0.1:9529`
- `http://127.0.0.1:9530`
这说明它桌面端很可能在做:
- 统一代理请求
- 规避前端直连限制
- 衔接本地保存
- 为多供应商请求做本地协调
### 文件导入导出
bundle 中明确有:
- `export_project`
- `import_project`
- `export_categorized_project`
- `import_categorized_project`
这意味着项目不是“只存在于当前设备当前会话”,而是支持工程级交换。
## 5. 审核与风控链路
bundle 与本机配置都表明有一条图片审核配置:
- `qiaodoumayi_seedance_moderation_url`
- 默认值:`http://118.196.112.236:3428/api/moderation/image`
更重要的不是这个地址本身,而是它体现出:
- 它对图像生成/图像上传流程做了专门审核位
- 审核是可配置项,不一定完全写死在服务端
如果后续你要复刻到自己的项目里,这条链路可以先抽象成:
- `preflight moderation hook`
而不是一开始就复刻它的具体实现。
## 6. 和我们当前项目的差距
### 我们已经有的基础
从当前项目代码看,我们已经有一个“可编辑无限画布”的基础框架:
- 画布节点类型定义在 `types/canvas/index.ts`
- 画布状态与节点/边管理在 `store/canvas/index.ts`
- 画布渲染在 `components/canvas/CanvasWorkspace.tsx`
- 画布服务层在 `services/canvas/index.ts`
当前项目已有的节点类型是:
- `message`
- `image`
- `video`
- `text`
- `scene-fusion`
- `model3d`
- `world3d`
并且已经具备一批图片侧能力:
- 上传
- 生图
- 抠图
- 扩图
- 框选抠图
- 文本编辑
- 图层拆分
- 场景融合
- 3D 结果展示
这意味着我们不是从零开始。
### 当前最明显的缺口
和“桥豆麻衣酱”相比,我们现在缺的不是单个 API,而是“工作流层”。
最明显的差距有:
#### 1. 节点目录不够完整
我们现在的 NodeType 还偏“媒体结果节点”,而桥豆那边已经是“生产流程节点”。
缺失最明显的一批:
- `input-image`
- `video-input`
- `video-analyze`
- `storyboard-node`
- `storyboard-chart-node`
- `table-editor-node`
- `custom-agent`
- `local-save`
- `preview`
- `motion-control`
- `camera-movement`
- `professional-camera`
- `create-character`
- `create-scene`
- `generate-character-image`
- `generate-character-video`
- `generate-scene-image`
- `generate-scene-video`
#### 2. 缺少工作流连接语义
我们当前画布有边,但边的语义还很轻,更多像自由连线。
桥豆那边已经存在:
- 节点输入输出能力
- 快速连接建议
- 按节点类型组织的下一步操作
也就是“有图结构”,但我们还没有“有工作流协议”。
#### 3. 缺少工程级项目资产层
我们当前项目侧重后端接口和画布状态保存;桥豆那边明显还有:
- 项目 meta
- 项目 payload
- 项目 assets
- 本地缓存
- 本地缩略图
- 导入导出
这对大项目体验差异会非常明显。
#### 4. 缺少角色/剧本/分镜三套资源库
桥豆明显把这三类做成一级能力:
- 角色库
- 剧本项目
- 分镜节点
而我们现在更像“图像生成画布”,还不是“叙事创作画布”。
## 7. 最值得优先复刻的部分
如果目标是尽快把桥豆的核心能力迁移到我们的无限画布,我建议按下面顺序做。
### 第一阶段:先补工作流骨架
先不要一口气复刻所有节点,先补最核心的工作流层:
- 节点注册表
- 节点默认尺寸
- 节点输入输出 schema
- 快速连接配置
- 节点执行状态机
- 结果节点与中间节点区分
优先节点建议:
- `text-node`
- `input-image`
- `gen-image`
- `gen-video`
- `preview`
- `local-save`
### 第二阶段:补分镜与视频链路
这是桥豆和普通画布产品差异最大的地方:
- `video-input`
- `video-analyze`
- `storyboard-node`
- `storyboard-chart-node`
- `table-editor-node`
有了这批节点,画布就从“结果展示面板”升级成“创作流程面板”。
### 第三阶段:补角色与剧本资产层
这部分能把产品从“工具”拉到“系统”:
- 角色库
- 剧本项目
- 角色一致性生图/生视频
- 场景节点
### 第四阶段:补桌面工作站能力
如果以后你们也走桌面端,这一层再跟进:
- 本地保存目录
- 本地导入导出
- 本地代理
- 本地缓存
Web 版可以先抽象接口,不需要第一天就复刻 Tauri。
## 8. 对我们项目最直接的落地建议
### 建议 1
把当前无限画布从“自由画板节点”升级为“节点注册表驱动”。
也就是新增一个统一的节点目录:
- `type`
- `label`
- `defaultSize`
- `inputPorts`
- `outputPorts`
- `capabilities`
- `quickActions`
### 建议 2
把现有 `image` / `video` 节点保留,但视为“结果节点”,再新增“流程节点”。
这会比强行把所有功能都塞进 `image` 节点更可持续。
### 建议 3
优先把桥豆的 `QUICK_CONNECT_CONFIG` 翻译成我们自己的配置文件。
因为这一步几乎不依赖后端,却能立刻让无限画布的操作逻辑接近桥豆。
### 建议 4
先抽象“审核钩子”“本地保存钩子”“代理请求钩子”,不要一开始绑定具体厂商。
这样后面无论是 Web、桌面版还是服务端代理版,都能接进去。
## 9. 现在已经产出的资产
你现在可以直接继续用下面这些文件:
- `analysis/qiaodou-extract/README.md`
- 提取说明与入口文件
- `analysis/qiaodou-extract/bundle-intel.json`
- 机器可读情报,适合继续做脚本分析
- `analysis/qiaodou-extract/analyze_bundle.js`
- 可重复运行的 bundle 分析脚本
- `analysis/qiaodou-extract/extracted/assets/index-m-NWkaV-.js`
- 主 bundle,适合继续按关键词深挖
## 最终判断
如果你的目标是“把桥豆麻衣酱的核心功能复刻到我们项目的无限画布”,那接下来最该复刻的不是某个单独接口,而是它这三层结构:
- `工作流节点体系`
- `项目/资产持久化体系`
- `角色/分镜/视频分析资源体系`
一旦这三层搭起来,具体接 OpenAI、Gemini、Kling、Jimeng、Vidu 这些后端,反而是后面的工作。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
"use client";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
export default function VideoCanvasRedirectPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const shouldCreateNew = searchParams.get("new") === "1";
const openVideoCanvas = async () => {
if (shouldCreateNew) {
const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`);
return;
}
try {
const response = await fetch("/api/canvas?workspaceType=video", {
cache: "no-store",
});
if (response.ok) {
const result = await response.json();
const items = result?.data || [];
if (Array.isArray(items) && items.length > 0) {
const sorted = [...items].sort((a, b) => {
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime();
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime();
return bTime - aTime;
});
const latest = sorted[0];
if (latest?.id) {
router.replace(`/canvas/${latest.id}?mode=video&workspaceType=video`);
return;
}
}
}
} catch (_error) {
// ignore and fallback
}
const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`);
};
void openVideoCanvas();
}, [router, searchParams]);
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">正在打开视频画布...</span>
</div>
</div>
);
}

View File

@ -5,6 +5,14 @@ import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
function resolveWorkspaceType(input: unknown) {
return input === "agent" || input === "video" ? input : "canvas";
}
function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorkspaceType>) {
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
}
// 获取单个画布 // 获取单个画布
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@ -53,6 +61,10 @@ export async function PUT(
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const { title, description, thumbnail, canvasData } = body; const { title, description, thumbnail, canvasData } = body;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType)
: undefined;
const syncedCanvasData = const syncedCanvasData =
canvasData !== undefined canvasData !== undefined
? await syncCanvasNodesForProject({ ? await syncCanvasNodesForProject({
@ -79,6 +91,7 @@ export async function PUT(
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail; if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData; if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db const [updatedProject] = await db
@ -100,8 +113,9 @@ export async function PUT(
.values({ .values({
id, // 使用指定的 ID id, // 使用指定的 ID
userId: session.user.id, userId: session.user.id,
title: title || '未命名画布', title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
description, description,
workspaceType: workspaceType || "canvas",
canvasData: syncedCanvasData || { canvasData: syncedCanvasData || {
nodes: [], nodes: [],
edges: [], edges: [],

View File

@ -32,6 +32,9 @@ function inferExt(url: string, contentType: string): string {
if (type.includes("gif")) return "gif"; if (type.includes("gif")) return "gif";
if (type.includes("bmp")) return "bmp"; if (type.includes("bmp")) return "bmp";
if (type.includes("tiff")) return "tiff"; if (type.includes("tiff")) return "tiff";
if (type.includes("mp4")) return "mp4";
if (type.includes("webm")) return "webm";
if (type.includes("quicktime")) return "mov";
const cleanUrl = String(url || "").split("?")[0] || ""; const cleanUrl = String(url || "").split("?")[0] || "";
const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/); const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/);

View File

@ -0,0 +1,112 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
export const maxDuration = 300;
interface GenerateVideoRequest {
prompt?: string;
mode?: VideoGenerationMode;
images?: string[];
resolution?: VideoResolution;
ratio?: VideoRatio;
duration?: number;
watermark?: boolean;
cameraFixed?: boolean;
seed?: number;
returnLastFrame?: boolean;
preferredModelRef?: string;
}
function inferMode(images: string[]): VideoGenerationMode {
if (images.length >= 3) return "reference-images";
if (images.length >= 2) return "first-last-frame";
if (images.length === 1) return "first-frame";
return "text-to-video";
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json().catch(() => ({}))) as GenerateVideoRequest;
const prompt = String(body.prompt || "").trim();
if (!prompt) {
return NextResponse.json(
{ success: false, error: "请输入视频提示词" },
{ status: 400 }
);
}
const inputImages = Array.isArray(body.images)
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
: [];
const { kept: reachableImages, dropped: unreachableImages } =
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
if (inputImages.length > 0 && reachableImages.length === 0) {
return NextResponse.json(
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
{ status: 400 }
);
}
const mode = body.mode || inferMode(reachableImages);
const result = await executeDirectVideoGeneration({
prompt,
mode,
images: reachableImages,
resolution: body.resolution,
ratio: body.ratio,
duration:
typeof body.duration === "number" && Number.isFinite(body.duration)
? body.duration
: undefined,
watermark: body.watermark,
cameraFixed: body.cameraFixed,
seed:
typeof body.seed === "number" && Number.isFinite(body.seed) ? body.seed : undefined,
returnLastFrame: body.returnLastFrame,
preferredModelRef:
typeof body.preferredModelRef === "string" ? body.preferredModelRef : undefined,
});
if (!result.success || !result.videoUrl) {
return NextResponse.json(
{ success: false, error: "视频生成失败,请稍后重试" },
{ status: 502 }
);
}
return NextResponse.json({
success: true,
data: {
url: result.videoUrl,
metadata: result.metadata || null,
modelDisplayName: result.modelDisplayName,
modelId: result.modelId,
modelRef: result.modelRef,
mode,
images: reachableImages,
droppedImages: unreachableImages,
},
});
} catch (error) {
console.error("[Canvas Generate Video] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "生成视频失败",
},
{ status: 500 }
);
}
}

View File

@ -5,6 +5,14 @@ import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { and, eq, desc } from 'drizzle-orm'; import { and, eq, desc } from 'drizzle-orm';
function resolveWorkspaceType(input: unknown) {
return input === "agent" || input === "video" ? input : "canvas";
}
function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorkspaceType>) {
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
}
// 获取画布列表 // 获取画布列表
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@ -13,13 +21,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
} }
const workspaceType = resolveWorkspaceType(
request.nextUrl.searchParams.get("workspaceType")
);
const projects = await db const projects = await db
.select() .select()
.from(canvasProject) .from(canvasProject)
.where( .where(
and( and(
eq(canvasProject.userId, session.user.id), eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "canvas") eq(canvasProject.workspaceType, workspaceType)
) )
) )
.orderBy(desc(canvasProject.updatedAt)); .orderBy(desc(canvasProject.updatedAt));
@ -41,13 +53,15 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { title, description, canvasData } = body; const { title, description, canvasData } = body;
const workspaceType = resolveWorkspaceType(body?.workspaceType);
const [insertedProject] = await db const [insertedProject] = await db
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
userId: session.user.id, userId: session.user.id,
title: title || '未命名画布', title: title || getDefaultWorkspaceTitle(workspaceType),
description, description,
workspaceType,
canvasData: canvasData || { canvasData: canvasData || {
nodes: [], nodes: [],
edges: [], edges: [],

View File

@ -0,0 +1,415 @@
import { generateText } from "ai";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { getAiModelAgentCodingModelId, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export const maxDuration = 180;
const requestSchema = z.object({
mode: z.enum(["video-analysis", "character-scene-extraction", "agent-plan"]),
prompt: z.string().trim().optional(),
imageUrls: z.array(z.string().trim()).optional(),
videoUrls: z.array(z.string().trim()).optional(),
nodeTitle: z.string().trim().optional(),
});
function sanitizeUrls(value: unknown, limit = 6) {
if (!Array.isArray(value)) return [];
return value
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, limit);
}
function supportsVision(model: {
supportsVision?: boolean | null;
capabilities?: unknown;
defaultParams?: unknown;
}) {
return (
Boolean(model.supportsVision) ||
isAiModelMultimodal({
capabilities: model.capabilities,
defaultParams: model.defaultParams,
})
);
}
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
const matched = String(text || "").match(/\{[\s\S]*\}/);
if (!matched) return null;
try {
return JSON.parse(matched[0]) as T;
} catch {
return null;
}
}
function normalizeText(value: unknown) {
return String(value || "").trim();
}
function normalizeStringList(value: unknown, limit = 8) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, limit)
)
);
}
function normalizeShotIdeas(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const title = normalizeText(raw.title || `镜头 ${index + 1}`);
const description = normalizeText(raw.description || raw.prompt || raw.summary);
if (!title && !description) return null;
return {
title: title || `镜头 ${index + 1}`,
description,
};
})
.filter(
(item): item is { title: string; description: string } => Boolean(item)
)
.slice(0, 6);
}
function normalizeCharacters(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const name = normalizeText(raw.name || raw.title);
const description = normalizeText(raw.description || raw.summary);
const prompt = normalizeText(raw.prompt);
if (!name && !description && !prompt) return null;
return {
name: name || "未命名角色",
description,
prompt,
look: normalizeText(raw.look) || undefined,
wardrobe: normalizeText(raw.wardrobe) || undefined,
signatureAction: normalizeText(raw.signatureAction) || undefined,
traits: normalizeStringList(raw.traits, 6),
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.slice(0, 8);
}
function normalizeScenes(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const name = normalizeText(raw.name || raw.title);
const description = normalizeText(raw.description || raw.summary);
const prompt = normalizeText(raw.prompt);
if (!name && !description && !prompt) return null;
return {
name: name || "未命名场景",
description,
prompt,
mood: normalizeText(raw.mood) || undefined,
lighting: normalizeText(raw.lighting) || undefined,
props: normalizeStringList(raw.props, 6),
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.slice(0, 8);
}
function buildVideoAnalysisPrompt(params: {
nodeTitle?: string;
prompt: string;
imageCount: number;
videoCount: number;
}) {
return [
"你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。",
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
`上游参考图数量:${params.imageCount}`,
`上游视频数量:${params.videoCount}`,
params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
"JSON 结构如下:",
`{
"summary": "一句话总结当前视频/镜头风格,不超过120字",
"highlights": ["3-5条镜头分析要点"],
"shotIdeas": [
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
],
"visualMotifs": ["2-4个可复用的视觉母题"],
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
]
.filter(Boolean)
.join("\n");
}
function buildCharacterScenePrompt(params: {
nodeTitle?: string;
prompt: string;
imageCount: number;
}) {
return [
"你是视频画布工作流里的角色场景提取节点,需要从脚本、设定和参考图里提取角色与场景资产。",
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
`参考图数量:${params.imageCount}`,
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请根据参考图提取角色和场景线索。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
"JSON 结构如下:",
`{
"summary": "一句话总结当前故事世界与资产线索",
"characters": [
{
"name": "角色名",
"description": "角色外观与身份简介",
"look": "外貌特征",
"wardrobe": "服装/道具",
"traits": ["性格关键词"],
"signatureAction": "代表动作",
"prompt": "可直接用于生成角色图/角色视频的提示词"
}
],
"scenes": [
{
"name": "场景名",
"description": "空间、布景和氛围简介",
"mood": "情绪与气氛",
"lighting": "布光特征",
"props": ["核心道具"],
"prompt": "可直接用于生成场景图/场景视频的提示词"
}
],
"relationships": ["角色与场景或角色之间的关系"],
"recommendedNextSteps": ["建议连接的后续节点动作"]
}`,
"角色和场景都要尽量具体,避免空泛形容词。",
]
.filter(Boolean)
.join("\n");
}
function buildAgentPlanPrompt(params: {
nodeTitle?: string;
prompt: string;
imageCount: number;
videoCount: number;
}) {
return [
"你是视频画布里的自定义代理节点,需要把当前创作目标拆成可执行的工作流计划。",
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
`参考图数量:${params.imageCount}`,
`视频数量:${params.videoCount}`,
params.prompt ? `上游需求:\n${params.prompt}` : "没有额外脚本,请基于当前上下文给出稳妥计划。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
"JSON 结构如下:",
`{
"summary": "一句话说明这个代理链的目标",
"agentPlan": ["3-6个执行步骤"],
"deliverables": ["2-4个预期交付物"],
"recommendedNodes": ["建议继续连接的节点类型或能力名"]
}`,
"步骤要偏工作流视角,而不是泛泛而谈。",
]
.filter(Boolean)
.join("\n");
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: "请求参数不合法" },
{ status: 400 }
);
}
const prompt = normalizeText(parsed.data.prompt);
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
return NextResponse.json(
{ success: false, error: "请至少提供脚本、图片或视频上下文" },
{ status: 400 }
);
}
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
]);
const compatibleModels = allModels.filter(
(item) => !item.model.isSubModel && item.model.isEnabled
);
let selectedModel =
imageUrls.length > 0 &&
agentModel &&
supportsVision(agentModel.model)
? agentModel
: imageUrls.length > 0
? compatibleModels.find((item) => supportsVision(item.model))
: agentModel || compatibleModels[0];
if (!selectedModel) {
selectedModel = compatibleModels[0];
}
if (!selectedModel) {
return NextResponse.json(
{ success: false, error: "当前站点没有可用的分析模型" },
{ status: 500 }
);
}
const useVision = imageUrls.length > 0 && supportsVision(selectedModel.model);
const codingModelId = getAiModelAgentCodingModelId(selectedModel.model);
const model = await getLanguageModelById(selectedModel.model.id, codingModelId);
if (!model) {
return NextResponse.json(
{ success: false, error: "分析模型初始化失败" },
{ status: 500 }
);
}
const promptText =
parsed.data.mode === "video-analysis"
? buildVideoAnalysisPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
videoCount: videoUrls.length,
})
: parsed.data.mode === "character-scene-extraction"
? buildCharacterScenePrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
})
: buildAgentPlanPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
videoCount: videoUrls.length,
});
const referenceImages = useVision
? (
await inlineVisionImages(imageUrls, {
maxImages: 4,
maxSide: 960,
timeoutMs: 12000,
retries: 1,
loggerPrefix: "[Canvas Workflow Analyze]",
})
).inlineDataUrls
: [];
const { text } = await generateText({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: promptText },
...referenceImages.map((image) => ({ type: "image" as const, image })),
],
},
],
temperature: 0.2,
maxTokens: 1600,
} as any);
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
const summary =
normalizeText(parsedJson.summary) ||
normalizeText(text).slice(0, 160);
if (parsed.data.mode === "video-analysis") {
return NextResponse.json({
success: true,
data: {
summary,
highlights: normalizeStringList(parsedJson.highlights, 6),
shotIdeas: normalizeShotIdeas(parsedJson.shotIdeas),
visualMotifs: normalizeStringList(parsedJson.visualMotifs, 6),
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
model: selectedModel.model.displayName,
raw: text,
},
});
}
if (parsed.data.mode === "character-scene-extraction") {
return NextResponse.json({
success: true,
data: {
summary,
characters: normalizeCharacters(parsedJson.characters),
scenes: normalizeScenes(parsedJson.scenes),
relationships: normalizeStringList(parsedJson.relationships, 8),
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
model: selectedModel.model.displayName,
raw: text,
},
});
}
return NextResponse.json({
success: true,
data: {
summary,
agentPlan: normalizeStringList(parsedJson.agentPlan, 8),
deliverables: normalizeStringList(parsedJson.deliverables, 6),
recommendedNodes: normalizeStringList(parsedJson.recommendedNodes, 6),
model: selectedModel.model.displayName,
raw: text,
},
});
} catch (error) {
console.error("[Canvas Workflow Analyze] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "工作流分析失败",
},
{ status: 500 }
);
}
}

View File

@ -552,6 +552,7 @@ export async function GET(request: NextRequest) {
const prompt = searchParams.get("prompt") || "图片变清晰处理"; const prompt = searchParams.get("prompt") || "图片变清晰处理";
const chatId = searchParams.get("chatId"); const chatId = searchParams.get("chatId");
const messageId = searchParams.get("messageId"); const messageId = searchParams.get("messageId");
const sourceImageUrl = searchParams.get("sourceImageUrl");
const enhanceConfig = await getSystemSetting({ key: "enhance_api_config" }); const enhanceConfig = await getSystemSetting({ key: "enhance_api_config" });
if (!enhanceConfig?.value) { if (!enhanceConfig?.value) {
@ -603,6 +604,7 @@ export async function GET(request: NextRequest) {
prompt, prompt,
chatId, chatId,
messageId, messageId,
sourceImageUrls: sourceImageUrl ? [sourceImageUrl] : undefined,
}); });
return NextResponse.json(responsePayload); return NextResponse.json(responsePayload);

File diff suppressed because it is too large Load Diff

View File

@ -81,7 +81,8 @@ type DragState =
}; };
const PREVIEW_MIN_HEIGHT = 340; const PREVIEW_MIN_HEIGHT = 340;
const LABEL_PLACEHOLDER = "例如:120cm / 50cm"; const DIMENSION_UNIT = "CM";
const LABEL_PLACEHOLDER = "例如:120";
const IMAGE_EXTENSION_REGEX = /\.(png|jpg|jpeg|webp)$/i; const IMAGE_EXTENSION_REGEX = /\.(png|jpg|jpeg|webp)$/i;
const DIMENSION_SUFFIX_REGEX = /-尺寸标注(?=\.)/; const DIMENSION_SUFFIX_REGEX = /-尺寸标注(?=\.)/;
const DIMENSION_LINE_COLOR = "#666b73"; const DIMENSION_LINE_COLOR = "#666b73";
@ -154,7 +155,18 @@ function getDimensionLength(annotation: DimensionAnnotation) {
} }
function formatDimensionValue(value: number) { function formatDimensionValue(value: number) {
return Number.isInteger(value) ? `${value}cm` : `${value.toFixed(1)}cm`; return Number.isInteger(value)
? `${value}${DIMENSION_UNIT}`
: `${value.toFixed(1)}${DIMENSION_UNIT}`;
}
function normalizeDimensionInput(value: string) {
return String(value || "")
.trim()
.replace(/厘米|公分/gi, "")
.replace(/cm/gi, "")
.replace(/\s+/g, "")
.replace(/[^0-9.]/g, "");
} }
function extractAutoDimensionValues( function extractAutoDimensionValues(
@ -282,9 +294,9 @@ function getDimensionLabel(params: {
autoDimensionValues?: AutoDimensionValues | null; autoDimensionValues?: AutoDimensionValues | null;
}) { }) {
const { annotation, annotations, autoDimensionValues } = params; const { annotation, annotations, autoDimensionValues } = params;
const customLabel = String(annotation.label || "").trim(); const customLabel = normalizeDimensionInput(annotation.label);
if (customLabel) { if (customLabel) {
return customLabel; return `${customLabel}${DIMENSION_UNIT}`;
} }
return getAutoDimensionLabel({ return getAutoDimensionLabel({
@ -1274,8 +1286,8 @@ export function AgentPsdTemplateExportPreview({
<div className="font-medium text-sm text-white">尺寸文字</div> <div className="font-medium text-sm text-white">尺寸文字</div>
<div className="text-white/45 text-xs"> <div className="text-white/45 text-xs">
{autoDimensionValues {autoDimensionValues
? "留空时会优先使用商品尺寸信息自动显示 cm,可继续手动覆盖。" ? "留空时会优先使用商品尺寸信息自动显示 CM;手动时只输入数值即可。"
: "当前没有拿到商品尺寸信息,请直接手动输入 cm 尺寸。"} : "当前没有拿到商品尺寸信息,请直接输入数值,系统会自动补 CM。"}
</div> </div>
</div> </div>
{getDimensionLabel({ {getDimensionLabel({
@ -1293,20 +1305,31 @@ export function AgentPsdTemplateExportPreview({
</div> </div>
) : null} ) : null}
</div> </div>
<Input <div className="relative">
className="border-white/10 bg-black/35 text-white placeholder:text-white/28" <Input
onChange={(event) => className="border-white/10 bg-black/35 pr-14 text-white placeholder:text-white/28"
setAnnotations((current) => inputMode="decimal"
current.map((annotation) => onChange={(event) =>
annotation.id === selectedAnnotation.id setAnnotations((current) =>
? { ...annotation, label: event.target.value } current.map((annotation) =>
: annotation annotation.id === selectedAnnotation.id
? {
...annotation,
label: normalizeDimensionInput(
event.target.value
),
}
: annotation
)
) )
) }
} placeholder={LABEL_PLACEHOLDER}
placeholder={LABEL_PLACEHOLDER} value={normalizeDimensionInput(selectedAnnotation.label)}
value={selectedAnnotation.label} />
/> <span className="-translate-y-1/2 pointer-events-none absolute top-1/2 right-3 text-[11px] text-white/42">
{DIMENSION_UNIT}
</span>
</div>
</div> </div>
) : ( ) : (
<div className="text-sm text-white/55"> <div className="text-sm text-white/55">

File diff suppressed because it is too large Load Diff

View File

@ -11,22 +11,34 @@ import { canvasService } from "@/services/canvas";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { CanvasProject } from "@/types/canvas"; import type { CanvasProject, CanvasWorkspaceType } from "@/types/canvas";
interface CanvasFileListPanelProps { interface CanvasFileListPanelProps {
currentId: string; currentId: string;
onClose: () => void; onClose: () => void;
workspaceType?: CanvasWorkspaceType;
workspaceLabel?: string;
createUrl?: string;
buildProjectUrl?: (id: string) => string;
} }
export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelProps) { export function CanvasFileListPanel({
currentId,
onClose,
workspaceType = "canvas",
workspaceLabel = "画布",
createUrl = "/canvas?new=1",
buildProjectUrl = (id) => `/canvas/${id}`,
}: CanvasFileListPanelProps) {
const router = useRouter(); const router = useRouter();
const isVideoWorkspace = workspaceType === "video";
const [projects, setProjects] = useState<CanvasProject[]>([]); const [projects, setProjects] = useState<CanvasProject[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// 加载项目列表 // 加载项目列表
const loadProjects = async () => { const loadProjects = async () => {
setIsLoading(true); setIsLoading(true);
const result = await canvasService.getProjects(); const result = await canvasService.getProjects(workspaceType);
if (result.success && result.data) { if (result.success && result.data) {
// 按更新时间降序排序 // 按更新时间降序排序
const sorted = result.data.sort((a, b) => const sorted = result.data.sort((a, b) =>
@ -39,12 +51,11 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
useEffect(() => { useEffect(() => {
loadProjects(); loadProjects();
}, []); }, [workspaceType]);
// 创建新画布 // 创建新画布
const handleCreateNew = async () => { const handleCreateNew = async () => {
const newId = crypto.randomUUID(); router.push(createUrl);
router.push(`/canvas/${newId}`);
}; };
// 切换画布 // 切换画布
@ -53,7 +64,7 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
onClose(); onClose();
return; return;
} }
const targetUrl = `/canvas/${id}`; const targetUrl = buildProjectUrl(id);
onClose(); // 关闭文件列表面板 onClose(); // 关闭文件列表面板
// 强制整页跳转,避免仅更新 URL 不刷新 // 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => { setTimeout(() => {
@ -75,7 +86,7 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
if (id === currentId) { if (id === currentId) {
const next = projects.find(p => p.id !== id); const next = projects.find(p => p.id !== id);
if (next) { if (next) {
router.push(`/canvas/${next.id}`); router.push(buildProjectUrl(next.id));
} else { } else {
handleCreateNew(); handleCreateNew();
} }
@ -86,10 +97,15 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full min-h-0 flex-col bg-transparent">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b shrink-0"> <div
<h2 className="font-semibold text-lg">已生成文件列表</h2> className={cn(
"flex shrink-0 items-center justify-between p-4",
isVideoWorkspace ? "border-b border-border/60" : "border-b"
)}
>
<h2 className="font-semibold text-lg">{workspaceLabel}项目列表</h2>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -108,9 +124,15 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <div
className={cn(
"py-12 text-center text-muted-foreground",
isVideoWorkspace &&
"rounded-[24px] border border-dashed border-border/60 bg-background/68 px-4"
)}
>
<FileImage className="h-12 w-12 mx-auto mb-3 opacity-20" /> <FileImage className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>暂无画布历史</p> <p>暂无{workspaceLabel}历史</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -118,7 +140,7 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
className="mt-4" className="mt-4"
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
新建画布 新建{workspaceLabel}
</Button> </Button>
</div> </div>
) : ( ) : (
@ -128,15 +150,28 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
key={project.id} key={project.id}
onClick={() => handleSwitch(project.id)} onClick={() => handleSwitch(project.id)}
className={cn( className={cn(
"group flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all", "group flex cursor-pointer items-center gap-3 rounded-2xl p-3 transition-all",
"hover:bg-muted/50", isVideoWorkspace
? "border border-border/60 bg-background/72 shadow-sm hover:-translate-y-0.5 hover:border-primary/30 hover:bg-background/88 hover:shadow-md"
: "hover:bg-muted/50",
project.id === currentId project.id === currentId
? "bg-primary/5 ring-1 ring-primary/20" ? isVideoWorkspace
: "bg-card" ? "border-primary/35 bg-background ring-1 ring-primary/10"
: "bg-primary/5 ring-1 ring-primary/20"
: isVideoWorkspace
? ""
: "bg-card"
)} )}
> >
{/* 缩略图 */} {/* 缩略图 */}
<div className="h-14 w-14 rounded-lg bg-muted/50 overflow-hidden shrink-0 border"> <div
className={cn(
"h-14 w-14 shrink-0 overflow-hidden border",
isVideoWorkspace
? "rounded-xl border-border/60 bg-background/80"
: "rounded-lg bg-muted/50"
)}
>
{project.thumbnail ? ( {project.thumbnail ? (
<img <img
src={buildImageProxyUrl(project.thumbnail, { src={buildImageProxyUrl(project.thumbnail, {
@ -167,7 +202,7 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
"font-medium text-sm truncate", "font-medium text-sm truncate",
project.id === currentId && "text-primary" project.id === currentId && "text-primary"
)}> )}>
{project.title || "未命名画布"} {project.title || `未命名${workspaceLabel}`}
</h4> </h4>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(new Date(project.updatedAt), { {formatDistanceToNow(new Date(project.updatedAt), {
@ -181,7 +216,12 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity" className={cn(
"h-8 w-8 text-muted-foreground hover:text-destructive transition-opacity",
isVideoWorkspace
? "opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
: "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => handleDelete(e, project.id)} onClick={(e) => handleDelete(e, project.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View File

@ -4,17 +4,18 @@ import React, { useRef, useState, useCallback, useMemo } from "react";
import { Rnd } from "react-rnd"; import { Rnd } from "react-rnd";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { CanvasNode, Position } from "@/types/canvas"; import type { CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode"; import { ImageNode } from "./nodes/ImageNode";
import { Model3DNode } from "./nodes/Model3DNode"; import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode"; import { TextNode } from "./nodes/TextNode";
import { VideoNode } from "./nodes/VideoNode"; import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { World3DNode } from "./nodes/World3DNode"; import { World3DNode } from "./nodes/World3DNode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
import { Download, Minus, Plus } from "lucide-react"; import { Download, GitBranch, Minus, Plus } from "lucide-react";
// 性能优化:判断节点是否在视口内 // 性能优化:判断节点是否在视口内
function isNodeInViewport( function isNodeInViewport(
@ -40,9 +41,17 @@ function isNodeInViewport(
interface CanvasWorkspaceProps { interface CanvasWorkspaceProps {
occlusionRight?: number; occlusionRight?: number;
workspaceLabel?: string;
emptyStateDescription?: string;
workspaceVariant?: "canvas" | "video";
} }
export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) { export function CanvasWorkspace({
occlusionRight = 0,
workspaceLabel = "画布",
emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas",
}: CanvasWorkspaceProps) {
const SNAP_THRESHOLD = 10; const SNAP_THRESHOLD = 10;
const SNAP_GAP = 12; const SNAP_GAP = 12;
const SELECTION_COLOR = "#27a8ff"; const SELECTION_COLOR = "#27a8ff";
@ -50,6 +59,16 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
const IMAGE_HANDLE_SIZE = 9.6; const IMAGE_HANDLE_SIZE = 9.6;
const IMAGE_HANDLE_OFFSET = IMAGE_HANDLE_SIZE / 2; const IMAGE_HANDLE_OFFSET = IMAGE_HANDLE_SIZE / 2;
const SNAP_GUIDE_COLOR = "#ff8a3d"; const SNAP_GUIDE_COLOR = "#ff8a3d";
const WORKFLOW_EDGE_COLOR = "#94a3b8";
const WORKFLOW_EDGE_ACTIVE_COLOR = "#334155";
const WORKFLOW_HANDLE_SIZE = 16;
const isWorkflowCanvas = workspaceVariant === "video";
const overlayPanelClassName = isWorkflowCanvas
? "rounded-full border border-border/60 bg-background/92 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
: "bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground";
const zoomOverlayClassName = isWorkflowCanvas
? "absolute bottom-4 right-4 rounded-full border border-border/60 bg-background/92 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
: "absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50";
type SnapGuideLines = { type SnapGuideLines = {
vertical?: { x: number; top: number; bottom: number }; vertical?: { x: number; top: number; bottom: number };
@ -64,6 +83,11 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
const [isFileDragActive, setIsFileDragActive] = useState(false); const [isFileDragActive, setIsFileDragActive] = useState(false);
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false); const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null); const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [connectionDraft, setConnectionDraft] = useState<{
sourceId: string;
current: Position;
} | null>(null);
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const dragPointerRef = useRef<Position>({ x: 0, y: 0 }); const dragPointerRef = useRef<Position>({ x: 0, y: 0 });
@ -78,6 +102,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
// Store state // Store state
const nodes = useCanvasStore(canvasSelectors.nodes); const nodes = useCanvasStore(canvasSelectors.nodes);
const edges = useCanvasStore(canvasSelectors.edges);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const viewport = useCanvasStore(canvasSelectors.viewport); const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool); const tool = useCanvasStore(canvasSelectors.tool);
@ -181,6 +206,8 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
const selectNodes = useCanvasStore((state) => state.selectNodes); const selectNodes = useCanvasStore((state) => state.selectNodes);
const clearSelection = useCanvasStore((state) => state.clearSelection); const clearSelection = useCanvasStore((state) => state.clearSelection);
const addNode = useCanvasStore((state) => state.addNode); const addNode = useCanvasStore((state) => state.addNode);
const addEdge = useCanvasStore((state) => state.addEdge);
const deleteEdge = useCanvasStore((state) => state.deleteEdge);
const moveNode = useCanvasStore((state) => state.moveNode); const moveNode = useCanvasStore((state) => state.moveNode);
const resizeNode = useCanvasStore((state) => state.resizeNode); const resizeNode = useCanvasStore((state) => state.resizeNode);
const pushHistory = useCanvasStore((state) => state.pushHistory); const pushHistory = useCanvasStore((state) => state.pushHistory);
@ -400,6 +427,68 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
}); });
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]); }, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({
x: node.position.x,
y: node.position.y + node.size.height / 2,
}),
[]
);
const getNodeOutputAnchor = useCallback(
(node: CanvasNode): Position => ({
x: node.position.x + node.size.width,
y: node.position.y + node.size.height / 2,
}),
[]
);
const toScreenPosition = useCallback(
(point: Position): Position => ({
x: point.x * viewport.zoom + viewport.x,
y: point.y * viewport.zoom + viewport.y,
}),
[viewport.x, viewport.y, viewport.zoom]
);
const buildWorkflowPath = useCallback((source: Position, target: Position) => {
const deltaX = Math.abs(target.x - source.x);
const controlOffset = Math.max(48, Math.min(180, deltaX * 0.5));
return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`;
}, []);
const renderedEdges = useMemo(() => {
return edges
.map((edge) => {
const sourceNode = nodes.find((node) => node.id === edge.source);
const targetNode = nodes.find((node) => node.id === edge.target);
if (!sourceNode || !targetNode) return null;
const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode));
const targetPoint = toScreenPosition(getNodeInputAnchor(targetNode));
return {
edge,
sourcePoint,
targetPoint,
path: buildWorkflowPath(sourcePoint, targetPoint),
midPoint: {
x: (sourcePoint.x + targetPoint.x) / 2,
y: (sourcePoint.y + targetPoint.y) / 2,
},
};
})
.filter(
(
item
): item is {
edge: CanvasEdge;
sourcePoint: Position;
targetPoint: Position;
path: string;
midPoint: Position;
} => Boolean(item)
);
}, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]);
// 处理画布点击(取消选择) // 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => { const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return; if (hasBlockingDialogOpen()) return;
@ -408,6 +497,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
suppressCanvasClickRef.current = false; suppressCanvasClickRef.current = false;
return; return;
} }
setSelectedEdgeId(null);
if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
const now = Date.now(); const now = Date.now();
const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320; const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320;
@ -423,6 +513,9 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return; if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return; if (!isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
return;
}
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) { if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
setIsPanning(true); setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y }); setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
@ -438,7 +531,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
setMarqueeStart(start); setMarqueeStart(start);
setMarqueeEnd(start); setMarqueeEnd(start);
} }
}, [clearBrowserTextSelection, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]); }, [clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
// 处理画布平移 // 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
@ -448,6 +541,23 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
} }
if (!isEventFromCanvasContainer(e.target)) return; if (!isEventFromCanvasContainer(e.target)) return;
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
if (connectionDraft) {
e.preventDefault();
setConnectionDraft((current) =>
current
? {
...current,
current: {
x: (e.clientX - viewport.x - (containerRef.current?.getBoundingClientRect().left || 0)) /
viewport.zoom,
y: (e.clientY - viewport.y - (containerRef.current?.getBoundingClientRect().top || 0)) /
viewport.zoom,
},
}
: null
);
return;
}
if (isPanning) { if (isPanning) {
e.preventDefault(); e.preventDefault();
setViewport({ setViewport({
@ -461,11 +571,32 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
e.preventDefault(); e.preventDefault();
setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY)); setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY));
} }
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]); }, [connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition, viewport.x, viewport.y, viewport.zoom]);
// 处理画布平移结束 // 处理画布平移结束
const handleMouseUp = useCallback((e?: React.MouseEvent) => { const handleMouseUp = useCallback((e?: React.MouseEvent) => {
if (e && !isEventFromCanvasContainer(e.target)) return; if (e && !isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
if (e) {
const releaseTarget = document.elementFromPoint(e.clientX, e.clientY);
const handleEl =
releaseTarget instanceof HTMLElement
? releaseTarget.closest("[data-canvas-input-handle]")
: null;
const targetNodeId =
handleEl instanceof HTMLElement ? handleEl.dataset.nodeId || null : null;
if (targetNodeId && targetNodeId !== connectionDraft.sourceId) {
addEdge({
source: connectionDraft.sourceId,
target: targetNodeId,
type: "default",
});
setSelectedEdgeId(null);
}
}
setConnectionDraft(null);
return;
}
setIsPanning(false); setIsPanning(false);
setSnapGuides(null); setSnapGuides(null);
clearBrowserTextSelection(); clearBrowserTextSelection();
@ -503,7 +634,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
setIsMarqueeSelecting(false); setIsMarqueeSelecting(false);
setMarqueeStart(null); setMarqueeStart(null);
setMarqueeEnd(null); setMarqueeEnd(null);
}, [clearBrowserTextSelection, clearSelection, isEventFromCanvasContainer, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]); }, [addEdge, clearBrowserTextSelection, clearSelection, connectionDraft, isEventFromCanvasContainer, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]);
// 新增图片后自动居中展示 // 新增图片后自动居中展示
const centerNodeInView = useCallback((nodeId: string) => { const centerNodeInView = useCallback((nodeId: string) => {
@ -813,9 +944,34 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
} }
}, [isDownloadSubmitting, selectedImageNodes]); }, [isDownloadSubmitting, selectedImageNodes]);
React.useEffect(() => {
if (!selectedEdgeId) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
deleteEdge(selectedEdgeId);
setSelectedEdgeId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [deleteEdge, selectedEdgeId]);
// 渲染节点 // 渲染节点
const renderNode = (node: CanvasNode) => { const renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id); const isSelected = selectedNodeIds.includes(node.id);
const showWorkflowHandles = isWorkflowCanvas && (tool === "draw" || isSelected);
const isImageNode = node.type === "image"; const isImageNode = node.type === "image";
const enableImageResizeHandles = const enableImageResizeHandles =
isSelected && tool === "select" && isImageNode isSelected && tool === "select" && isImageNode
@ -1016,15 +1172,70 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setSelectedEdgeId(null);
selectNode(node.id, e.shiftKey); selectNode(node.id, e.shiftKey);
}} }}
> >
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="w-full h-full" className="group relative w-full h-full"
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
> >
{showWorkflowHandles ? (
<>
<button
type="button"
data-canvas-no-drag="true"
data-canvas-input-handle="true"
data-node-id={node.id}
className={cn(
"absolute top-1/2 z-20 rounded-full border-2 border-background/95 bg-foreground shadow-[0_10px_20px_-14px_rgba(15,23,42,0.7)] transition-all",
tool === "draw"
? "scale-100 opacity-100 ring-4 ring-primary/10"
: "scale-90 opacity-75"
)}
style={{
left: -(WORKFLOW_HANDLE_SIZE / 2),
marginTop: -(WORKFLOW_HANDLE_SIZE / 2),
width: WORKFLOW_HANDLE_SIZE,
height: WORKFLOW_HANDLE_SIZE,
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
aria-label="输入连接点"
/>
<button
type="button"
data-canvas-no-drag="true"
className={cn(
"absolute top-1/2 z-20 rounded-full border-2 border-background/95 bg-foreground shadow-[0_10px_20px_-14px_rgba(15,23,42,0.7)] transition-all",
tool === "draw"
? "scale-100 opacity-100 ring-4 ring-primary/10"
: "scale-90 opacity-75"
)}
style={{
right: -(WORKFLOW_HANDLE_SIZE / 2),
marginTop: -(WORKFLOW_HANDLE_SIZE / 2),
width: WORKFLOW_HANDLE_SIZE,
height: WORKFLOW_HANDLE_SIZE,
}}
onMouseDown={(event) => {
if (!isWorkflowCanvas) return;
event.preventDefault();
event.stopPropagation();
const outputAnchor = getNodeOutputAnchor(node);
setConnectionDraft({
sourceId: node.id,
current: outputAnchor,
});
}}
aria-label="输出连接点"
/>
</>
) : null}
{node.type === 'image' && ( {node.type === 'image' && (
<ImageNode node={node} isSelected={isSelected} /> <ImageNode node={node} isSelected={isSelected} />
)} )}
@ -1034,6 +1245,9 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
{node.type === 'video' && ( {node.type === 'video' && (
<VideoNode node={node} isSelected={isSelected} /> <VideoNode node={node} isSelected={isSelected} />
)} )}
{node.type === 'workflow' && (
<WorkflowNode node={node} isSelected={isSelected} />
)}
{node.type === 'model3d' && ( {node.type === 'model3d' && (
<Model3DNode node={node} isSelected={isSelected} /> <Model3DNode node={node} isSelected={isSelected} />
)} )}
@ -1056,6 +1270,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
className={cn( className={cn(
"relative w-full h-full overflow-hidden overscroll-none touch-none bg-background", "relative w-full h-full overflow-hidden overscroll-none touch-none bg-background",
tool === 'pan' && "cursor-grab", tool === 'pan' && "cursor-grab",
tool === 'draw' && "cursor-crosshair",
isPanning && "cursor-grabbing" isPanning && "cursor-grabbing"
)} )}
onClick={handleCanvasClick} onClick={handleCanvasClick}
@ -1069,9 +1284,36 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
> >
{isWorkflowCanvas ? (
<>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-foreground opacity-[0.028]" />
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(circle at top left, rgba(59,130,246,0.08), transparent 24%), radial-gradient(circle at bottom right, rgba(148,163,184,0.1), transparent 26%)",
}}
/>
</>
) : null}
{isFileDragActive && ( {isFileDragActive && (
<div className="absolute inset-0 pointer-events-none z-30 flex items-center justify-center bg-primary/5 border-2 border-dashed border-primary/40 rounded-lg"> <div
<div className="px-4 py-2 rounded-md bg-background/90 text-sm text-primary shadow-sm"> className={cn(
"absolute inset-0 z-30 flex items-center justify-center pointer-events-none border-2 border-dashed",
isWorkflowCanvas
? "rounded-[32px] border-primary/30 bg-primary/5"
: "rounded-lg border-primary/40 bg-primary/5"
)}
>
<div
className={cn(
"px-4 py-2 text-sm text-primary shadow-sm",
isWorkflowCanvas
? "rounded-full border border-primary/20 bg-background/92 backdrop-blur-xl"
: "rounded-md bg-background/90"
)}
>
松开鼠标即可添加图片到画布 松开鼠标即可添加图片到画布
</div> </div>
</div> </div>
@ -1174,14 +1416,92 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
</> </>
)} )}
{isWorkflowCanvas ? (
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
{renderedEdges.map(({ edge, path }) => {
const isSelected = selectedEdgeId === edge.id;
return (
<g key={edge.id}>
{isSelected ? (
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
strokeLinecap="round"
strokeWidth={10}
style={{ pointerEvents: "none" }}
/>
) : null}
<path
d={path}
fill="none"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeDasharray={edge.type === "reference" ? "6 6" : undefined}
strokeLinecap="round"
strokeWidth={isSelected ? 4 : 3}
className="cursor-pointer transition-all"
style={{ pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
setSelectedEdgeId(edge.id);
clearSelection();
}}
onDoubleClick={(event) => {
event.stopPropagation();
deleteEdge(edge.id);
setSelectedEdgeId(null);
}}
/>
</g>
);
})}
{connectionDraft ? (() => {
const sourceNode = nodes.find((node) => node.id === connectionDraft.sourceId);
if (!sourceNode) return null;
const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode));
const targetPoint = toScreenPosition(connectionDraft.current);
return (
<g>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke="rgba(59, 130, 246, 0.14)"
strokeLinecap="round"
strokeWidth={8}
style={{ pointerEvents: "none" }}
/>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke={WORKFLOW_EDGE_COLOR}
strokeDasharray="8 6"
strokeLinecap="round"
strokeWidth={3}
style={{ pointerEvents: "none" }}
/>
<circle
cx={targetPoint.x}
cy={targetPoint.y}
r={5}
fill="white"
stroke={WORKFLOW_EDGE_ACTIVE_COLOR}
strokeWidth={2}
style={{ pointerEvents: "none" }}
/>
</g>
);
})() : null}
</svg>
) : null}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */} {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<div className="absolute inset-0" style={{ overflow: 'visible' }}> <div className="absolute inset-0 z-[2]" style={{ overflow: 'visible' }}>
{visibleNodes.map(renderNode)} {visibleNodes.map(renderNode)}
</div> </div>
{/* 性能指示器(节点数量大于 50 时显示) */} {/* 性能指示器(节点数量大于 50 时显示) */}
{nodes.length >= 50 && ( {nodes.length >= 50 && (
<div className="absolute top-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground"> <div className={cn("absolute top-4 right-4", overlayPanelClassName)}>
渲染: {visibleNodes.length}/{nodes.length} 渲染: {visibleNodes.length}/{nodes.length}
</div> </div>
)} )}
@ -1189,10 +1509,25 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
{/* 空状态提示 */} {/* 空状态提示 */}
{nodes.length === 0 && ( {nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center text-muted-foreground"> {isWorkflowCanvas ? (
<p className="text-lg mb-2">画布是空的</p> <div className="rounded-[30px] border border-border/60 bg-background/90 px-8 py-6 text-center shadow-[0_24px_60px_-34px_rgba(15,23,42,0.35)] backdrop-blur-xl">
<p className="text-sm">点击顶部"添加"按钮开始创作</p> <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-border/60 bg-background text-foreground shadow-sm">
</div> <GitBranch className="h-5 w-5" />
</div>
<p className="text-lg font-semibold text-foreground">{workspaceLabel}还是空的</p>
<p className="mt-2 max-w-[360px] text-sm leading-6 text-muted-foreground">
{emptyStateDescription}
</p>
<p className="mt-3 text-xs text-muted-foreground/80">
可以先从左上角模板起手,也可以直接添加节点后用 `L` 连线。
</p>
</div>
) : (
<div className="text-center text-muted-foreground">
<p className="text-lg mb-2">{workspaceLabel}还是空的</p>
<p className="text-sm">{emptyStateDescription}</p>
</div>
)}
</div> </div>
)} )}
@ -1200,9 +1535,13 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
{selectedImageNodes.length > 0 && ( {selectedImageNodes.length > 0 && (
<div className="absolute bottom-16 left-4 z-30" data-canvas-overlay> <div className="absolute bottom-16 left-4 z-30" data-canvas-overlay>
<Button <Button
variant="secondary" variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm" size="sm"
className="h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60" className={cn(
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
isWorkflowCanvas &&
"rounded-full bg-background/92 backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
)}
onClick={handleDownloadSelectedImages} onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting} disabled={isDownloadSubmitting}
title="下载为文件夹压缩包" title="下载为文件夹压缩包"
@ -1214,13 +1553,18 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
)} )}
<div <div
className="absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50" className={cn(
zoomOverlayClassName,
isWorkflowCanvas
? "flex items-center gap-1.5 px-1.5 py-1.5"
: "flex items-center gap-1 px-1 py-1 border border-border/50"
)}
data-canvas-overlay data-canvas-overlay
> >
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className={cn("h-7 w-7", isWorkflowCanvas && "rounded-full")}
onClick={() => { onClick={() => {
const newZoom = Math.max(0.1, viewport.zoom - 0.1); const newZoom = Math.max(0.1, viewport.zoom - 0.1);
setViewport({ zoom: newZoom }); setViewport({ zoom: newZoom });
@ -1232,7 +1576,10 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 min-w-[50px] text-xs font-medium" className={cn(
"h-7 min-w-[50px] text-xs font-medium",
isWorkflowCanvas && "rounded-full px-3"
)}
onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })} onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })}
title="重置缩放" title="重置缩放"
> >
@ -1241,7 +1588,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className={cn("h-7 w-7", isWorkflowCanvas && "rounded-full")}
onClick={() => { onClick={() => {
const newZoom = Math.min(4, viewport.zoom + 0.1); const newZoom = Math.min(4, viewport.zoom + 0.1);
setViewport({ zoom: newZoom }); setViewport({ zoom: newZoom });

View File

@ -0,0 +1,547 @@
"use client";
import {
Bot,
Clapperboard,
FileOutput,
Film,
LayoutPanelTop,
ScanSearch,
Sparkles,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
getVideoWorkflowNodeDefinition,
getVideoWorkflowQuickConnectOutputs,
} from "@/lib/video-workflow/catalog";
import type { CanvasNode, WorkflowNodeData } from "@/types/canvas";
interface WorkflowNodeProps {
node: CanvasNode;
isSelected: boolean;
}
function toAlphaColor(color: string | undefined, alpha: number) {
const normalized = String(color || "").trim();
if (!normalized.startsWith("#")) return undefined;
const hex = normalized.slice(1);
if (hex.length !== 3 && hex.length !== 6) return undefined;
const full = hex.length === 3 ? hex.split("").map((char) => char + char).join("") : hex;
const int = Number.parseInt(full, 16);
if (Number.isNaN(int)) return undefined;
const red = (int >> 16) & 255;
const green = (int >> 8) & 255;
const blue = int & 255;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const GROUP_META = {
input: {
label: "输入",
icon: LayoutPanelTop,
},
analysis: {
label: "分析",
icon: ScanSearch,
},
storyboard: {
label: "分镜",
icon: Film,
},
generation: {
label: "生成",
icon: Sparkles,
},
character: {
label: "角色场景",
icon: Clapperboard,
},
tool: {
label: "工具",
icon: Bot,
},
output: {
label: "输出",
icon: FileOutput,
},
} as const;
const STATUS_LABELS = {
pending: "待配置",
generating: "处理中",
completed: "已完成",
failed: "异常",
} as const;
export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
const data = node.data as WorkflowNodeData;
const definition = getVideoWorkflowNodeDefinition(data.templateType);
const metadata = (data.metadata || {}) as Record<string, any>;
const quickConnectOutputs =
data.templateType && definition
? getVideoWorkflowQuickConnectOutputs(definition.type)
: [];
const resolvedGroup = (data.group || definition?.group || "tool") as keyof typeof GROUP_META;
const groupMeta = GROUP_META[resolvedGroup] || GROUP_META.tool;
const GroupIcon = groupMeta.icon;
const accentColor = data.accentColor || definition?.accentColor || "#e2e8f0";
const title = data.title || definition?.label || "工作流节点";
const description = data.description || definition?.description || "等待补充节点配置。";
const previewUrl =
String(metadata.previewMediaUrl || metadata.resultMediaUrl || "").trim();
const previewKind =
String(metadata.previewMediaKind || metadata.resultMediaKind || "").trim() === "video"
? "video"
: "image";
const storyboardShots = Array.isArray(metadata.storyboardShots)
? metadata.storyboardShots.slice(0, 4)
: [];
const analysisHighlights = Array.isArray(metadata.analysisHighlights)
? metadata.analysisHighlights.slice(0, 4)
: [];
const compareItems = Array.isArray(metadata.compareItems)
? metadata.compareItems.slice(0, 4)
: [];
const tableColumns = Array.isArray(metadata.tableColumns)
? metadata.tableColumns.slice(0, 4)
: [];
const tableRows = Array.isArray(metadata.tableRows)
? metadata.tableRows.slice(0, 4)
: [];
const characters = Array.isArray(metadata.characters)
? metadata.characters.slice(0, 3)
: [];
const scenes = Array.isArray(metadata.scenes) ? metadata.scenes.slice(0, 3) : [];
const characterAssets = Array.isArray(metadata.characterAssets)
? metadata.characterAssets.slice(0, 3)
: [];
const sceneAssets = Array.isArray(metadata.sceneAssets)
? metadata.sceneAssets.slice(0, 3)
: [];
const agentPlan = Array.isArray(metadata.agentPlan)
? metadata.agentPlan.slice(0, 4)
: [];
const deliverables = Array.isArray(metadata.deliverables)
? metadata.deliverables.slice(0, 4)
: [];
const linkedAgentProjectId = String(metadata.linkedAgentProjectId || "").trim();
const linkedAgentProjectTitle = String(metadata.linkedAgentProjectTitle || "").trim();
const importedAgentResultCount = Math.max(Number(metadata.importedAgentResultCount || 0), 0);
const pendingAgentResultCount = Math.max(Number(metadata.pendingAgentResultCount || 0), 0);
const agentResultGroups = Array.isArray(metadata.agentResultGroups)
? metadata.agentResultGroups.slice(0, 4)
: [];
const batchShotCount = Math.max(
Number(metadata.batchShotCount || 0),
storyboardShots.length
);
const batchResultCount = Number(metadata.batchResultCount || 0);
const batchFailedCount = Number(metadata.batchFailedCount || 0);
const batchMode =
String(metadata.batchMode || "").trim() === "gen-video" ? "视频批量" : "图片批量";
const accentGlow = toAlphaColor(accentColor, 0.18) || "rgba(59, 130, 246, 0.12)";
const accentLine = toAlphaColor(accentColor, 0.3) || "rgba(59, 130, 246, 0.2)";
const sectionClassName =
"rounded-2xl border border-border/60 bg-background/74 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]";
const mutedKickerClassName =
"text-[11px] uppercase tracking-[0.18em] text-muted-foreground/80";
const itemClassName = "rounded-xl border border-border/50 bg-background/88 px-3 py-2";
const badgeClassName = "border-border/60 bg-background/82 text-foreground/80";
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-[28px] border border-border/60 bg-background/95 shadow-[0_22px_55px_-30px_rgba(15,23,42,0.38)] backdrop-blur-xl transition-all",
isSelected &&
"border-primary/55 ring-2 ring-primary/15 shadow-[0_26px_60px_-28px_rgba(59,130,246,0.28)]"
)}
>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at top left, ${accentGlow} 0%, transparent 42%), linear-gradient(180deg, ${accentLine} 0%, transparent 18%)`,
}}
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-border/70" />
<div className="relative flex h-full flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-border/60 bg-background/90 shadow-sm">
<GroupIcon className="h-4 w-4" />
</span>
<span>{groupMeta.label}</span>
</div>
<div>
<h3 className="text-base font-semibold leading-tight text-foreground">{title}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
</div>
</div>
<Badge variant="outline" className={badgeClassName}>
{STATUS_LABELS[data.status] || STATUS_LABELS.pending}
</Badge>
</div>
<div className={sectionClassName}>
<div className={mutedKickerClassName}>节点标识</div>
<div className="mt-1 font-mono text-xs text-foreground/80">{data.templateType}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{data.summary || "把这个节点接在上游素材之后,用来承接处理或输出。"}
</p>
</div>
{batchShotCount > 0 || batchResultCount > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>批量执行</div>
<div className="mt-2 flex flex-wrap gap-2">
{batchShotCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
镜头 {batchShotCount}
</Badge>
) : null}
{batchResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
结果 {batchResultCount}
</Badge>
) : null}
{batchFailedCount > 0 ? (
<Badge
variant="outline"
className="border-amber-200/70 bg-amber-50/80 text-amber-700"
>
失败 {batchFailedCount}
</Badge>
) : null}
{batchResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
{batchMode}
</Badge>
) : null}
</div>
</div>
) : null}
{previewUrl ? (
<div className="overflow-hidden rounded-2xl border border-border/60 bg-slate-950/95 shadow-[0_16px_40px_-28px_rgba(15,23,42,0.65)]">
{previewKind === "video" ? (
<video
src={previewUrl}
className="h-36 w-full object-cover"
controls
muted
playsInline
/>
) : (
<img
src={previewUrl}
alt={title}
className="h-36 w-full object-cover"
/>
)}
</div>
) : null}
{compareItems.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>对比结果</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{compareItems.map((item, index) => {
const compareItem = (item || {}) as Record<string, any>;
const kind =
String(compareItem.kind || "").trim() === "video" ? "video" : "image";
const url = String(compareItem.url || "").trim();
if (!url) {
return null;
}
return (
<div
key={`${data.templateType}-compare-${index}`}
className="overflow-hidden rounded-2xl border border-border/60 bg-background/88"
>
<div className="border-b border-border/60 px-3 py-2">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{String(compareItem.label || `对比 ${index + 1}`)}
</div>
<div className="mt-1 truncate text-xs font-medium text-foreground/90">
{String(compareItem.title || `素材 ${index + 1}`)}
</div>
</div>
{kind === "video" ? (
<video
src={url}
className="h-24 w-full object-cover"
controls
muted
playsInline
/>
) : (
<img
src={url}
alt={String(compareItem.title || `对比素材 ${index + 1}`)}
className="h-24 w-full object-cover"
/>
)}
</div>
);
})}
</div>
</div>
) : null}
{storyboardShots.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>镜头草案</div>
<div className="mt-2 space-y-2">
{storyboardShots.map((item, index) => {
const shot = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-shot-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(shot.title || `镜头 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(shot.description || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{tableColumns.length > 0 && tableRows.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>结构化表格</div>
<div className="mt-2 flex flex-wrap gap-2">
{tableColumns.map((item, index) => {
const column = (item || {}) as Record<string, any>;
return (
<Badge
key={`${data.templateType}-column-${index}`}
variant="outline"
className={badgeClassName}
>
{String(column.label || column.key || `列 ${index + 1}`)}
</Badge>
);
})}
</div>
<div className="mt-2 space-y-2">
{tableRows.map((item, index) => {
const row = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-row-${index}`} className={itemClassName}>
{tableColumns.map((columnItem, columnIndex) => {
const column = (columnItem || {}) as Record<string, any>;
const columnKey = String(column.key || `col-${columnIndex}`).trim();
const label = String(column.label || columnKey || `列 ${columnIndex + 1}`).trim();
const value = String(row[columnKey] || "").trim();
if (!value) {
return null;
}
return (
<div
key={`${data.templateType}-row-${index}-column-${columnIndex}`}
className={columnIndex > 0 ? "mt-2" : ""}
>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div className="mt-1 text-xs leading-5 text-foreground/85">
{value}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
) : null}
{analysisHighlights.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>分析摘要</div>
<div className="mt-2 space-y-2">
{analysisHighlights.map((item, index) => (
<div
key={`${data.templateType}-analysis-${index}`}
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
>
{String(item || "")}
</div>
))}
</div>
</div>
) : null}
{characters.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>角色线索</div>
<div className="mt-2 space-y-2">
{characters.map((item, index) => {
const character = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-character-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(character.name || character.title || `角色 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(character.description || character.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{scenes.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>场景线索</div>
<div className="mt-2 space-y-2">
{scenes.map((item, index) => {
const scene = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-scene-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(scene.name || scene.title || `场景 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(scene.description || scene.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{characterAssets.length > 0 || sceneAssets.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>资产沉淀</div>
<div className="mt-2 space-y-2">
{[...characterAssets, ...sceneAssets].slice(0, 4).map((item, index) => {
const asset = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-asset-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(asset.name || asset.title || `资产 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(asset.description || asset.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{agentPlan.length > 0 || deliverables.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>代理计划</div>
<div className="mt-2 space-y-2">
{agentPlan.map((item, index) => (
<div
key={`${data.templateType}-plan-${index}`}
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
>
{String(item || "")}
</div>
))}
{deliverables.map((item, index) => (
<div
key={`${data.templateType}-deliverable-${index}`}
className="rounded-xl border border-dashed border-border/60 bg-background/82 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
交付物: {String(item || "")}
</div>
))}
</div>
</div>
) : null}
{linkedAgentProjectId ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>Agent 工作台</div>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className={badgeClassName}>
已连接
</Badge>
{importedAgentResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
已回流 {importedAgentResultCount}
</Badge>
) : null}
{pendingAgentResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
待回流 {pendingAgentResultCount}
</Badge>
) : null}
</div>
<div className="mt-2 text-xs font-medium leading-5 text-foreground/90">
{linkedAgentProjectTitle || linkedAgentProjectId}
</div>
</div>
) : null}
{agentResultGroups.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>Agent 结果</div>
<div className="mt-2 space-y-2">
{agentResultGroups.map((item, index) => {
const group = (item || {}) as Record<string, any>;
const label = String(group.label || `结果组 ${index + 1}`).trim();
const count = Math.max(Number(group.count || 0), 0);
const importedCount = Math.max(Number(group.importedCount || 0), 0);
return (
<div
key={`${data.templateType}-agent-group-${index}`}
className={itemClassName}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold text-foreground/90">{label}</div>
<div className="text-[11px] text-muted-foreground">
{importedCount}/{count}
</div>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
共 {count} 个结果,已回流 {importedCount} 个。
</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-auto space-y-2">
<div className={mutedKickerClassName}>下一步推荐</div>
{quickConnectOutputs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{quickConnectOutputs.slice(0, 4).map((item) => (
<span
key={`${data.templateType}-${item.type}`}
className="rounded-full border border-border/60 bg-background/90 px-2.5 py-1 text-xs text-foreground/80 shadow-sm"
>
{item.label}
</span>
))}
</div>
) : (
<div className="rounded-full border border-dashed border-border/70 bg-background/72 px-3 py-1.5 text-xs text-muted-foreground">
当前节点通常作为流程终点或独立处理节点
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1537,6 +1537,19 @@ export function LovartHome({
<TooltipContent side="right">智能体</TooltipContent> <TooltipContent side="right">智能体</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/video-canvas")}
title="视频画布"
>
<Clapperboard className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">视频画布</TooltipContent>
</Tooltip>
{siteSettingsData?.productPlanningEnabled !== false ? ( {siteSettingsData?.productPlanningEnabled !== false ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3 } from "lucide-react"; import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3, Clapperboard } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
@ -141,6 +141,7 @@ export function TopNavbar({
if (trimmed === "精选榜") return "榜单"; if (trimmed === "精选榜") return "榜单";
if (trimmed === "图片编辑") return "编辑"; if (trimmed === "图片编辑") return "编辑";
if (trimmed === "智能体") return "智能"; if (trimmed === "智能体") return "智能";
if (trimmed === "视频画布") return "视频";
if (trimmed === "无限画布") return "画布"; if (trimmed === "无限画布") return "画布";
if (trimmed === "我的图册") return "图册"; if (trimmed === "我的图册") return "图册";
if (trimmed === "商品策划") return "策划"; if (trimmed === "商品策划") return "策划";
@ -481,6 +482,18 @@ export function TopNavbar({
</button> </button>
)} )}
<button
className={navItemBaseClass}
onClick={() => {
window.location.href = "/video-canvas";
}}
type="button"
title="视频画布"
>
<Clapperboard className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
</button>
{/* 无限画布 */} {/* 无限画布 */}
<button <button
className={navItemBaseClass} className={navItemBaseClass}

View File

@ -70,41 +70,47 @@ SelectScrollDownButton.displayName =
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", style, ...props }, ref) => ( >(({ className, children, position = "popper", style, ...props }, ref) => {
<SelectPrimitive.Portal> const resolvedBackgroundColor = style?.backgroundColor ?? "var(--popover)"
<SelectPrimitive.Content const resolvedColor = style?.color ?? "var(--popover-foreground)"
ref={ref}
className={cn( return (
"relative z-[10000] isolate max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]", <SelectPrimitive.Portal>
position === "popper" && <SelectPrimitive.Content
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", ref={ref}
className
)}
style={{
backgroundColor: "var(--popover)",
color: "var(--popover-foreground)",
...style,
}}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn( className={cn(
"rounded-[inherit] bg-inherit p-1", "relative z-[10000] isolate max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" && position === "popper" &&
"w-full min-w-[var(--radix-select-trigger-width)]" "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)} )}
style={{ style={{
backgroundColor: "var(--popover)", backgroundColor: resolvedBackgroundColor,
color: resolvedColor,
...style,
}} }}
position={position}
{...props}
> >
{children} <SelectScrollUpButton />
</SelectPrimitive.Viewport> <SelectPrimitive.Viewport
<SelectScrollDownButton /> className={cn(
</SelectPrimitive.Content> "rounded-[inherit] bg-inherit p-1",
</SelectPrimitive.Portal> position === "popper" &&
)) "w-full min-w-[var(--radix-select-trigger-width)]"
)}
style={{
backgroundColor: resolvedBackgroundColor,
color: resolvedColor,
}}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
})
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<

View File

@ -0,0 +1,448 @@
# 商品策划工具迁移评估与实施方案
## 1. 结论摘要
“旧由壹点源码”中的“商品策划工具”可以迁移到当前项目,且推荐迁移。
但它不适合直接把旧的 `HTML + 全局 JS + Express 路由` 原封不动搬进当前项目。当前项目是 `Next.js App Router + React 19 + NextAuth + Drizzle/Postgres + 现有对象存储能力`,旧模块则严重依赖:
- 大量页面级全局变量
- `DOMContentLoaded` 初始化链
- 直接操作 DOM
- 旧系统的 `/api/auth/me`、`/api/cloud-storage/*`、`/api/evaluation/analyze`
- 旧系统的 token/管理员权限判断方式
所以最稳妥的方案不是“复制页面”,而是:
- 复用旧模块的业务逻辑、字段结构、处理流程
- 重写页面层、状态管理层、接口层
- 统一接入当前项目的鉴权、导航、存储和 API 规范
整体可行性评估:`高`
- 页面迁移可行性:`高`
- 前端逻辑迁移可行性:`中高`
- 后端接口迁移可行性:`高`
- 直接无改造搬运可行性:`低`
## 2. 已识别的旧模块范围
旧模块入口:
- `旧由壹点源码/public/keyword-analyzer.html`
旧模块实际不是单页小工具,而是一个“商品策划工作台”,至少包含以下能力:
1. 关键词拆词与需求分层分析
2. 云端模板/源数据选择与上传
3. 评价分析
4. 问大家分析
5. 竞品分析
6. 关键词洞察
7. ID 销量查询
关键旧前端文件:
- `旧由壹点源码/public/js/word-segmentation.js`
- `旧由壹点源码/public/js/data-processing.js`
- `旧由壹点源码/public/js/ui-interaction.js`
- `旧由壹点源码/public/js/cloud-storage-manager.js`
- `旧由壹点源码/public/js/taobao-data-viewer.js`
- `旧由壹点源码/public/js/taobao-rate-analyzer.js`
- `旧由壹点源码/public/js/taobao-qa-analyzer.js`
- `旧由壹点源码/public/js/taobao-competitor-analyzer.js`
- `旧由壹点源码/public/js/keyword-insight.js`
- `旧由壹点源码/public/js/id-sales-query.js`
关键旧后端文件:
- `旧由壹点源码/server/routes/api/taobao-proxy.js`
- `旧由壹点源码/server/routes/api/jd-proxy.js`
- `旧由壹点源码/server/routes/evaluation.js`
- `旧由壹点源码/server/routes/cloud-storage.js`
## 3. 当前项目适配情况
当前项目已有的基础能力:
- 页面框架:`Next.js App Router`
- UI 组件:`components/ui/*`
- 鉴权:`app/(auth)/auth.ts`,基于 `NextAuth`
- 导航壳:`app/(chat)/layout.tsx`、`components/chat-shell.tsx`、`components/top-navbar.tsx`
- 服务端 API 约定:`app/api/**/route.ts`
- 对象存储能力:`lib/storage/service.ts`
- CSV 处理能力:`papaparse`
当前项目缺失但迁移所需的能力:
- 商品策划工具专用页面
- 淘宝/京东代理 API
- 问大家/评价聚合 API
- AI 文本分析 API
- 关键词洞察代理 API
- 模板文件管理接口
- Excel 读写依赖和导出能力
## 4. 为什么不能直接复制旧页面
### 4.1 页面组织方式不兼容
旧页面是传统静态页模式:
- 页面内联大量样式和脚本
- 使用 `window.XXX = ...`
- 多个 `DOMContentLoaded` 重复初始化
- 依赖固定 DOM id 与插入顺序
当前项目是组件化模式:
- Server Component + Client Component 分层
- 由 React 控制状态和生命周期
- 由 `route.ts` 承接后端 API
直接复制会导致:
- 初始化顺序不可控
- 维护成本极高
- 与现有导航壳、主题、权限体系脱节
- 后续很难继续迭代
### 4.2 鉴权方式不兼容
旧模块依赖:
- `localStorage token`
- `/api/auth/me`
- Express 中间件 `requireAuth`
当前项目依赖:
- `NextAuth` session
- 服务器端 `auth()`
所以模板上传、AI 分析、代理接口权限都需要重写。
### 4.3 后端接口契约不完整复用
旧接口虽然能作为参考,但存在这些问题:
- 有旧系统专用中间件
- 有硬编码或半硬编码外部 API 依赖
- 有一部分接口路径当前项目根本不存在
- `evaluation.js` 体量很大,包含旧系统上下文,不适合整体照搬
## 5. 推荐迁移策略
推荐采用:
`业务逻辑迁移 + React 页面重构 + Next API 重建`
### 5.1 可直接迁移的部分
- 关键词拆词算法
- 文件字段识别逻辑
- 结果分组与导出字段结构
- 淘宝/京东外部接口字段映射
- AI 分析 prompt 结构
### 5.2 需要重写的部分
- 整个页面 UI
- 所有弹窗/Tab/表格展示层
- 所有旧式 DOM 操作
- 所有旧 Express 路由
- 所有旧鉴权逻辑
- 云模板上传和列表管理
## 6. 推荐的新项目落地结构
建议新增如下结构:
### 6.1 页面
- `app/(chat)/product-planning/page.tsx`
### 6.2 组件
- `components/product-planning/ProductPlanningPage.tsx`
- `components/product-planning/KeywordAnalyzerPanel.tsx`
- `components/product-planning/ReviewQaPanel.tsx`
- `components/product-planning/CompetitorPanel.tsx`
- `components/product-planning/KeywordInsightPanel.tsx`
- `components/product-planning/IdSalesPanel.tsx`
- `components/product-planning/TemplateLibraryPanel.tsx`
### 6.3 业务逻辑
- `lib/product-planning/types.ts`
- `lib/product-planning/schemas.ts`
- `lib/product-planning/keyword-segmentation.ts`
- `lib/product-planning/data-processing.ts`
- `lib/product-planning/formatters.ts`
- `lib/product-planning/taobao.ts`
- `lib/product-planning/jd.ts`
- `lib/product-planning/analysis.ts`
### 6.4 API
- `app/api/product-planning/taobao/item/route.ts`
- `app/api/product-planning/taobao/item-pro/route.ts`
- `app/api/product-planning/taobao/reviews/route.ts`
- `app/api/product-planning/taobao/qa/route.ts`
- `app/api/product-planning/taobao/month-sale/route.ts`
- `app/api/product-planning/jd/item/route.ts`
- `app/api/product-planning/jd/desc/route.ts`
- `app/api/product-planning/keyword-insight/route.ts`
- `app/api/product-planning/analyze/route.ts`
- `app/api/product-planning/templates/route.ts`
- `app/api/product-planning/templates/[id]/route.ts`
### 6.5 测试
- `tests/routes/product-planning-apis.test.ts`
- `tests/e2e/product-planning-page.test.ts`
## 7. 模块级迁移建议
### 7.1 关键词拆词
迁移优先级:`最高`
原因:
- 这是页面主能力
- 旧逻辑大多是纯前端算法,最容易拆成 TS 模块复用
建议做法:
- 将 `word-segmentation.js` 和 `data-processing.js` 拆成纯函数
- 去除对 `window`、DOM、全局变量的依赖
- 输入统一改为:
- 上传文件
- 模板文件
- 手动分词结果
- 输出统一改为 TS 类型对象,再交给 React 表格组件渲染
### 7.2 评价分析 / 问大家分析
迁移优先级:`最高`
建议做法:
- 前端不再从 DOM 里读表格
- 改为:
- 直接通过 API 获取结构化数据
- 或上传 Excel/CSV 后解析为结构化数组
- AI 分析通过新的 `app/api/product-planning/analyze/route.ts`
分析接口建议入参:
- `type: "review" | "qa" | "image"`
- `payload: object`
- `promptMode?: string`
### 7.3 竞品分析
迁移优先级:`高`
建议做法:
- 第一版只支持淘宝
- 第二版补京东
- 图片分析不要沿用旧 `/api/ai/analyze-image`
- 改成统一接入当前项目的 AI 能力
建议前端能力:
- 批量输入商品链接/ID
- 返回基础信息、主图、SKU、销量、店铺、属性
- 导出对比表
- 单图/多图 AI 解析
### 7.4 关键词洞察
迁移优先级:`中高`
风险点:
- 旧实现里有直接请求外部接口的痕迹
- 当前项目必须改成服务端代理,不能在前端暴露密钥
建议:
- 单独做 `keyword-insight` API 代理
- 前端只接当前项目自己的接口
### 7.5 ID 销量查询
迁移优先级:`中高`
建议:
- 走 `item_get` + `item_month_sale` 组合查询
- 提供批量输入和导出
- 第一版最多支持 50 个,避免前端超长耗时
### 7.6 云模板管理
迁移优先级:`中`
这是旧模块里和当前系统差异最大的部分。
旧实现:
- 依赖旧 `/api/cloud-storage/*`
- 依赖旧管理员角色接口
- 使用旧 S3/MinIO 元数据方案
新实现建议:
- 直接复用当前项目 `lib/storage/service.ts`
- 模板元数据放数据库表或 `SystemSettings`
- 权限通过 `NextAuth` 判断管理员
不建议继续保留旧接口模型。
## 8. 推荐实施顺序
### 阶段一:先做“可用版”
目标:先把核心业务搬进当前项目并可实际使用。
范围:
1. 新建 `商品策划工具` 页面
2. 完成关键词拆词
3. 完成评价/问大家分析
4. 完成淘宝商品基础代理接口
5. 完成 AI 分析接口
6. 完成基础导出
这一步做完后,用户已经可以进入新页面并完成主流程。
### 阶段二:补齐分析工作台
范围:
1. 竞品分析
2. 关键词洞察
3. ID 销量查询
4. 京东代理接口
5. 导出表格优化
### 阶段三:补齐管理能力与体验
范围:
1. 模板库上传/下载/删除
2. 管理员权限控制
3. 历史记录/最近使用
4. 页面细节打磨
5. E2E 回归测试
## 9. 依赖与环境变量建议
建议新增或确认以下环境变量:
- `TAOBAO_API_KEY`
- `TAOBAO_API_SECRET`
- `TAOBAO_SALE_API_TOKEN`
- `PRODUCT_PLANNING_QA_TOKEN`
- `JD_API_KEY` 与 `JD_API_SECRET`(如果后续与淘宝分离)
建议新增依赖:
- `xlsx`
- `exceljs`
原因:
- 当前项目虽然已有 `papaparse`,但对旧模块的 Excel 上传和复杂导出不够
## 10. 风险与阻塞项
### 高风险
1. 外部商品数据接口稳定性
2. 旧页面某些隐藏联动逻辑散落在多个 JS/内联脚本中
3. 云模板功能和旧鉴权体系耦合较深
### 中风险
1. 旧拆词算法体量大,迁移后需要回归验证
2. 竞品分析涉及淘宝与京东双通道,字段差异大
3. AI 分析结果格式需要重新统一
### 低风险
1. 页面入口新增
2. 新建内部 API 命名空间
3. 使用现有 UI 组件重构工作台
## 11. 我的推荐方案
如果以“稳、快、能上线”为目标,推荐这样执行:
### 方案 A:分阶段迁移,先核心后扩展
这是最推荐的方案。
第一批上线:
- 新页面
- 关键词拆词
- 评价/问大家分析
- 淘宝基础代理
- AI 结果总结
第二批上线:
- 竞品分析
- 关键词洞察
- ID 销量查询
第三批上线:
- 模板库管理
- 管理员运营能力
优点:
- 风险最低
- 最容易快速产出可用版本
- 不会被云模板和旧权限逻辑拖慢
### 方案 B:整页一次性全量迁移
不推荐。
原因:
- 旧模块分散、脚本多、联动多
- 一次性重做很容易周期失控
- 测试面太大
## 12. 建议的验收标准
第一阶段验收标准建议定义为:
1. 用户能从当前项目进入“商品策划工具”新页面
2. 可以上传 Excel/TXT 并完成关键词拆词
3. 可以输入商品链接/ID 获取评价和问大家数据
4. 可以对评价/问大家执行 AI 分析
5. 可以导出核心分析结果
6. 全流程使用当前项目的登录态,不依赖旧 token
7. 所有外部 API 密钥都只在服务端使用
## 13. 最终建议
这个迁移项目值得做,而且可以做好。
推荐执行方式:
- 不做旧页面直拷
- 以当前项目为主,重建一个新的 `商品策划工具` 工作台页面
- 优先迁移“关键词拆词 + 评价/问大家分析 + 淘宝代理 + AI 分析”
- 将云模板、竞品、销量、关键词洞察拆成后续阶段补齐
这样做,既能尽量保留旧功能价值,也不会把当前项目拉回到旧系统的技术包袱里。

File diff suppressed because it is too large Load Diff

View File

@ -130,20 +130,22 @@ export function resolveAgentPsdImageBaseFrame(params: {
contentBounds: params.contentBounds, contentBounds: params.contentBounds,
}); });
const baseFrame = resolveBaseFittedSize({ const baseFrame = resolveBaseFittedSize({
containerWidth: contentBounds.width, containerWidth,
containerHeight: contentBounds.height, containerHeight,
imageWidth, imageWidth,
imageHeight, imageHeight,
replaceMode, replaceMode,
}); });
const anchorCenterX = contentBounds.left + contentBounds.width / 2;
const anchorCenterY = contentBounds.top + contentBounds.height / 2;
return { return {
width: baseFrame.width, width: baseFrame.width,
height: baseFrame.height, height: baseFrame.height,
left: contentBounds.left + baseFrame.left, left: anchorCenterX - baseFrame.width / 2,
top: contentBounds.top + baseFrame.top, top: anchorCenterY - baseFrame.height / 2,
centerX: contentBounds.left + baseFrame.left + baseFrame.width / 2, centerX: anchorCenterX,
centerY: contentBounds.top + baseFrame.top + baseFrame.height / 2, centerY: anchorCenterY,
}; };
} }

View File

@ -6,7 +6,10 @@
*/ */
import { generateText } from "ai"; import { generateText } from "ai";
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat"; import {
getCompatibleTextGenerationTemperature,
shouldForceInlineVisionInputs,
} from "@/lib/ai/model-compat";
import sharp from "sharp"; import sharp from "sharp";
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
// 使用动态导入避免循环依赖问题 // 使用动态导入避免循环依赖问题
@ -509,6 +512,31 @@ const ANALYZER_QWEN_FALLBACK_MAX_CANDIDATES = getEnvInt(
1, 1,
4, 4,
); );
function createDeadlineAt(timeoutMs?: number | null): number | null {
if (!Number.isFinite(timeoutMs) || Number(timeoutMs) <= 0) {
return null;
}
return Date.now() + Number(timeoutMs);
}
function resolveRemainingTimeoutMs(params: {
deadlineAt?: number | null;
fallbackMs: number;
reserveMs?: number;
minimumMs: number;
}): number | null {
const { deadlineAt, fallbackMs, minimumMs } = params;
const reserveMs = params.reserveMs ?? 0;
if (!deadlineAt) return fallbackMs;
const remainingMs = deadlineAt - Date.now() - reserveMs;
if (remainingMs < minimumMs) {
return null;
}
return Math.min(fallbackMs, remainingMs);
}
const ZHIPU_VISION_MODEL_CANDIDATES = [ const ZHIPU_VISION_MODEL_CANDIDATES = [
"glm-4.6v-flash", "glm-4.6v-flash",
"glm-4.6v", "glm-4.6v",
@ -760,6 +788,7 @@ async function summarizeWithHardcodedQwen3VlFallback(params: {
context?: string; context?: string;
imageUrls?: string[]; imageUrls?: string[];
visionImages?: Array<{ type: "image"; image: string }>; visionImages?: Array<{ type: "image"; image: string }>;
timeoutMs?: number;
}): Promise<string | null> { }): Promise<string | null> {
const imageCandidates = await getQwen3VlFallbackImageCandidates({ const imageCandidates = await getQwen3VlFallbackImageCandidates({
imageUrls: params.imageUrls, imageUrls: params.imageUrls,
@ -779,6 +808,12 @@ async function summarizeWithHardcodedQwen3VlFallback(params: {
try { try {
const { callQwen3Vl } = await import("./qwen3-vl"); const { callQwen3Vl } = await import("./qwen3-vl");
console.warn("[Smart Analyzer] Visual summary fallback to hardcoded qwen3-vl"); console.warn("[Smart Analyzer] Visual summary fallback to hardcoded qwen3-vl");
const timeoutMs =
resolveRemainingTimeoutMs({
deadlineAt: createDeadlineAt(params.timeoutMs),
fallbackMs: ANALYZER_QWEN_FALLBACK_TIMEOUT_MS,
minimumMs: 3_000,
}) ?? ANALYZER_QWEN_FALLBACK_TIMEOUT_MS;
for (const imageInput of imageCandidates) { for (const imageInput of imageCandidates) {
const inputMode = imageInput.startsWith("data:image/") ? "inline" : "remote"; const inputMode = imageInput.startsWith("data:image/") ? "inline" : "remote";
try { try {
@ -787,7 +822,7 @@ async function summarizeWithHardcodedQwen3VlFallback(params: {
prompt, prompt,
temperature: 0.1, temperature: 0.1,
maxTokens: 140, maxTokens: 140,
timeoutMs: ANALYZER_QWEN_FALLBACK_TIMEOUT_MS, timeoutMs,
retries: ANALYZER_QWEN_FALLBACK_RETRIES, retries: ANALYZER_QWEN_FALLBACK_RETRIES,
}); });
const normalized = normalizeVisualSummaryText(text); const normalized = normalizeVisualSummaryText(text);
@ -1357,9 +1392,11 @@ export async function summarizeImageForExecutionReport(params: {
context?: string; context?: string;
preferredModelId?: string; preferredModelId?: string;
assetIds?: string[]; assetIds?: string[];
timeoutMs?: number;
}): Promise<string | null> { }): Promise<string | null> {
const { text, imageUrls, context, preferredModelId, assetIds } = params; const { text, imageUrls, context, preferredModelId, assetIds, timeoutMs } = params;
const normalizedInputUrls = uniqueNonEmptyStrings(imageUrls).slice(0, ANALYZER_MAX_IMAGE_COUNT); const normalizedInputUrls = uniqueNonEmptyStrings(imageUrls).slice(0, ANALYZER_MAX_IMAGE_COUNT);
const deadlineAt = createDeadlineAt(timeoutMs);
if (normalizedInputUrls.length > 1) { if (normalizedInputUrls.length > 1) {
const mergedLines: string[] = []; const mergedLines: string[] = [];
for (const [index, imageUrl] of normalizedInputUrls.entries()) { for (const [index, imageUrl] of normalizedInputUrls.entries()) {
@ -1369,6 +1406,11 @@ export async function summarizeImageForExecutionReport(params: {
context, context,
preferredModelId, preferredModelId,
assetIds: assetIds?.[index] ? [assetIds[index] as string] : [], assetIds: assetIds?.[index] ? [assetIds[index] as string] : [],
timeoutMs: resolveRemainingTimeoutMs({
deadlineAt,
fallbackMs: timeoutMs ?? ANALYZER_TIMEOUT_MS,
minimumMs: 4_000,
}) ?? undefined,
}); });
if (singleSummary?.trim()) { if (singleSummary?.trim()) {
mergedLines.push(`图${index + 1}:${stripVisualSummaryImageLabel(singleSummary)}`); mergedLines.push(`图${index + 1}:${stripVisualSummaryImageLabel(singleSummary)}`);
@ -1459,6 +1501,11 @@ export async function summarizeImageForExecutionReport(params: {
context, context,
imageUrls, imageUrls,
visionImages, visionImages,
timeoutMs: resolveRemainingTimeoutMs({
deadlineAt,
fallbackMs: timeoutMs ?? ANALYZER_QWEN_FALLBACK_TIMEOUT_MS,
minimumMs: 3_000,
}) ?? undefined,
}); });
if (forcedSummary) return await finalizeVisualSummary(forcedSummary); if (forcedSummary) return await finalizeVisualSummary(forcedSummary);
} }
@ -1496,16 +1543,30 @@ export async function summarizeImageForExecutionReport(params: {
if (!model) continue; if (!model) continue;
const preferRemote = shouldPreferRemoteVisionUrls(candidate); const preferRemote = shouldPreferRemoteVisionUrls(candidate);
const modeAttempts = Array.from(new Set([preferRemote, !preferRemote])); const forceInlineInputs = shouldForceInlineVisionInputs(candidate);
const modeAttempts = forceInlineInputs
? [false]
: Array.from(new Set([preferRemote, !preferRemote]));
for (const preferRemoteUrls of modeAttempts) { for (const preferRemoteUrls of modeAttempts) {
const fallbackVisionImages = await buildAnalyzerVisionImagesByMode({ const fallbackVisionImages = preferRemoteUrls
imageUrls, ? await buildAnalyzerVisionImagesByMode({
preferRemoteUrls, imageUrls: normalizedInputUrls,
}); preferRemoteUrls: true,
})
: visionImages;
if (fallbackVisionImages.length === 0) continue; if (fallbackVisionImages.length === 0) continue;
try { try {
const attemptTimeoutMs = resolveRemainingTimeoutMs({
deadlineAt,
fallbackMs: ANALYZER_TIMEOUT_MS,
reserveMs: 3_500,
minimumMs: 4_000,
});
if (!attemptTimeoutMs) {
break;
}
const compatibleTemperature = getCompatibleTextGenerationTemperature(candidate, 0.1); const compatibleTemperature = getCompatibleTextGenerationTemperature(candidate, 0.1);
const { text: summaryText } = await generateText({ const { text: summaryText } = await generateText({
model, model,
@ -1525,7 +1586,7 @@ export async function summarizeImageForExecutionReport(params: {
? { temperature: compatibleTemperature } ? { temperature: compatibleTemperature }
: {}), : {}),
maxTokens: 120, maxTokens: 120,
abortSignal: AbortSignal.timeout(ANALYZER_TIMEOUT_MS), abortSignal: AbortSignal.timeout(attemptTimeoutMs),
} as any); } as any);
const normalized = normalizeVisualSummaryText(summaryText); const normalized = normalizeVisualSummaryText(summaryText);
@ -1558,6 +1619,11 @@ export async function summarizeImageForExecutionReport(params: {
context, context,
imageUrls, imageUrls,
visionImages, visionImages,
timeoutMs: resolveRemainingTimeoutMs({
deadlineAt,
fallbackMs: timeoutMs ?? ANALYZER_QWEN_FALLBACK_TIMEOUT_MS,
minimumMs: 3_000,
}) ?? undefined,
}); });
if (qwenFallbackSummary) return await finalizeVisualSummary(qwenFallbackSummary); if (qwenFallbackSummary) return await finalizeVisualSummary(qwenFallbackSummary);

View File

@ -34,11 +34,13 @@ export async function requestLongTaskResult(params: {
payload: Record<string, any>; payload: Record<string, any>;
pollingParams?: Record<string, string | number | null | undefined>; pollingParams?: Record<string, string | number | null | undefined>;
timeoutMs?: number; timeoutMs?: number;
headers?: HeadersInit;
}) { }) {
const response = await fetch(params.endpoint, { const response = await fetch(params.endpoint, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(params.headers || {}),
}, },
body: JSON.stringify(params.payload), body: JSON.stringify(params.payload),
}); });
@ -73,6 +75,7 @@ export async function requestLongTaskResult(params: {
const statusResponse = await fetch(`${params.endpoint}?${pollingParams.toString()}`, { const statusResponse = await fetch(`${params.endpoint}?${pollingParams.toString()}`, {
method: "GET", method: "GET",
cache: "no-store", cache: "no-store",
headers: params.headers,
}); });
const statusPayload = await safeParseJson(statusResponse); const statusPayload = await safeParseJson(statusResponse);

View File

@ -605,7 +605,7 @@ export const canvasProject = pgTable("CanvasProject", {
title: varchar("title", { length: 256 }).notNull().default("未命名画布"), title: varchar("title", { length: 256 }).notNull().default("未命名画布"),
description: text("description"), description: text("description"),
thumbnail: text("thumbnail"), thumbnail: text("thumbnail"),
workspaceType: varchar("workspaceType", { enum: ["canvas", "agent"] }) workspaceType: varchar("workspaceType", { enum: ["canvas", "agent", "video"] })
.notNull() .notNull()
.default("canvas"), .default("canvas"),
// 存储完整画布状态(节点、边、视口等) // 存储完整画布状态(节点、边、视口等)

View File

@ -0,0 +1,678 @@
import type { CanvasNode, NodeType, Position } from "@/types/canvas";
export type VideoWorkflowSectionId =
| "input"
| "analysis"
| "storyboard"
| "generation"
| "character"
| "tool"
| "output";
export type VideoWorkflowTemplateType =
| "camera-movement"
| "canvas-node"
| "character-description"
| "create-character"
| "create-scene"
| "custom-agent"
| "doodle-canvas"
| "extract-characters-scenes"
| "gen-image"
| "gen-music"
| "gen-video"
| "generate-character-image"
| "generate-character-video"
| "generate-scene-image"
| "generate-scene-video"
| "image-compare"
| "inpaint-crop"
| "inpaint-stitch"
| "input-image"
| "jimeng-super-resolution"
| "local-save"
| "motion-control"
| "novel-input"
| "preview"
| "professional-camera"
| "rh-app"
| "rh-comfy"
| "scene-description"
| "storyboard-chart-node"
| "storyboard-node"
| "table-editor-node"
| "text-node"
| "video-analyze"
| "video-input";
export interface VideoWorkflowQuickConnectOption {
type: VideoWorkflowTemplateType;
label: string;
}
export interface VideoWorkflowNodeDefinition {
type: VideoWorkflowTemplateType;
label: string;
description: string;
group: VideoWorkflowSectionId;
size: {
width: number;
height: number;
};
canvasNodeType: NodeType;
accentColor: string;
quickConnectOutputs: VideoWorkflowQuickConnectOption[];
}
export interface VideoWorkflowSectionDefinition {
id: VideoWorkflowSectionId;
label: string;
description: string;
}
export interface VideoWorkflowStarterTemplate {
id: string;
title: string;
description: string;
nodes: VideoWorkflowTemplateType[];
}
const QUICK_CONNECT_CONFIG: Partial<
Record<VideoWorkflowTemplateType, VideoWorkflowQuickConnectOption[]>
> = {
"text-node": [
{ type: "gen-image", label: "绘画" },
{ type: "gen-video", label: "视频" },
{ type: "custom-agent", label: "Agent" },
],
"input-image": [
{ type: "gen-image", label: "绘画" },
{ type: "gen-video", label: "视频" },
{ type: "storyboard-node", label: "分镜视表" },
{ type: "inpaint-crop", label: "裁剪局部重绘" },
],
"novel-input": [
{ type: "custom-agent", label: "Agent" },
{ type: "text-node", label: "文本" },
{ type: "rh-app", label: "RH 应用" },
],
"video-input": [
{ type: "preview", label: "预览" },
{ type: "rh-app", label: "RH 应用" },
],
"video-analyze": [
{ type: "storyboard-node", label: "分镜视表" },
{ type: "gen-image", label: "绘画" },
],
"table-editor-node": [{ type: "storyboard-node", label: "分镜视表" }],
"storyboard-node": [
{ type: "gen-image", label: "绘画" },
{ type: "gen-video", label: "视频" },
],
"storyboard-chart-node": [{ type: "preview", label: "预览" }],
"inpaint-stitch": [
{ type: "preview", label: "预览" },
{ type: "local-save", label: "保存" },
],
};
const SECTION_DEFINITIONS: VideoWorkflowSectionDefinition[] = [
{ id: "input", label: "输入", description: "脚本、图片、视频和小说素材入口" },
{ id: "analysis", label: "分析", description: "拆解素材、抽取镜头和角色语义" },
{ id: "storyboard", label: "分镜", description: "组织镜头表、图表和镜头语言" },
{ id: "generation", label: "生成", description: "图片、视频、音乐和增强节点" },
{ id: "character", label: "角色场景", description: "角色与场景资产沉淀和派生" },
{ id: "tool", label: "工具", description: "代理、画板、局部编辑和外部应用" },
{ id: "output", label: "输出", description: "预览、对比和保存结果" },
];
const createDefinition = (
type: VideoWorkflowTemplateType,
label: string,
description: string,
group: VideoWorkflowSectionId,
size: { width: number; height: number },
canvasNodeType: NodeType,
accentColor: string
): VideoWorkflowNodeDefinition => ({
type,
label,
description,
group,
size,
canvasNodeType,
accentColor,
quickConnectOutputs: QUICK_CONNECT_CONFIG[type] || [],
});
export const VIDEO_WORKFLOW_NODE_DEFINITIONS: Record<
VideoWorkflowTemplateType,
VideoWorkflowNodeDefinition
> = {
"camera-movement": createDefinition(
"camera-movement",
"镜头运动",
"规划推拉摇移、运镜节奏和镜头情绪。",
"storyboard",
{ width: 340, height: 480 },
"workflow",
"#fde68a"
),
"canvas-node": createDefinition(
"canvas-node",
"画板节点",
"把多个素材汇总到一个创意画板里继续编排。",
"tool",
{ width: 600, height: 500 },
"workflow",
"#e2e8f0"
),
"character-description": createDefinition(
"character-description",
"角色描述",
"沉淀角色外观、气质和动作特征。",
"character",
{ width: 400, height: 400 },
"workflow",
"#dbeafe"
),
"create-character": createDefinition(
"create-character",
"创建角色",
"将角色设定固化成可复用的角色资产。",
"character",
{ width: 350, height: 300 },
"workflow",
"#dbeafe"
),
"create-scene": createDefinition(
"create-scene",
"创建场景",
"将场景描述固化成可复用的世界观素材。",
"character",
{ width: 350, height: 300 },
"workflow",
"#dbeafe"
),
"custom-agent": createDefinition(
"custom-agent",
"自定义代理",
"接入一段更复杂的代理链路处理创作任务。",
"tool",
{ width: 500, height: 800 },
"workflow",
"#e2e8f0"
),
"doodle-canvas": createDefinition(
"doodle-canvas",
"涂鸦画板",
"用草图、路径或遮罩表达额外视觉意图。",
"tool",
{ width: 500, height: 300 },
"workflow",
"#e2e8f0"
),
"extract-characters-scenes": createDefinition(
"extract-characters-scenes",
"提取角色场景",
"从长文本里拆出角色、地点和关系线索。",
"analysis",
{ width: 400, height: 500 },
"workflow",
"#bbf7d0"
),
"gen-image": createDefinition(
"gen-image",
"生成图片",
"根据上游脚本、分镜或素材生成关键帧。",
"generation",
{ width: 360, height: 340 },
"workflow",
"#fecaca"
),
"gen-music": createDefinition(
"gen-music",
"生成音乐",
"为视频节奏匹配音乐、氛围和情绪。",
"generation",
{ width: 350, height: 700 },
"workflow",
"#fecaca"
),
"gen-video": createDefinition(
"gen-video",
"生成视频",
"根据文本、图像或分镜节点生成视频镜头。",
"generation",
{ width: 360, height: 340 },
"workflow",
"#fecaca"
),
"generate-character-image": createDefinition(
"generate-character-image",
"生成角色图片",
"围绕角色设定输出稳定的角色图像。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"generate-character-video": createDefinition(
"generate-character-video",
"生成角色视频",
"围绕角色设定输出角色镜头或动作片段。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"generate-scene-image": createDefinition(
"generate-scene-image",
"生成场景图片",
"围绕场景设定生成环境和氛围关键帧。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"generate-scene-video": createDefinition(
"generate-scene-video",
"生成场景视频",
"围绕场景设定生成世界观或转场镜头。",
"character",
{ width: 400, height: 450 },
"workflow",
"#dbeafe"
),
"image-compare": createDefinition(
"image-compare",
"图片对比",
"并排比较不同版本画面与细节变化。",
"output",
{ width: 400, height: 300 },
"workflow",
"#bae6fd"
),
"inpaint-crop": createDefinition(
"inpaint-crop",
"裁剪局部重绘",
"选定区域后做局部改图、补图或定点修改。",
"tool",
{ width: 340, height: 380 },
"workflow",
"#e2e8f0"
),
"inpaint-stitch": createDefinition(
"inpaint-stitch",
"无缝拼回",
"把局部重绘结果回贴到原始画面里。",
"tool",
{ width: 340, height: 380 },
"workflow",
"#e2e8f0"
),
"input-image": createDefinition(
"input-image",
"图片输入",
"上传参考图、角色图、首帧或关键帧素材。",
"input",
{ width: 260, height: 260 },
"image",
"#bfdbfe"
),
"jimeng-super-resolution": createDefinition(
"jimeng-super-resolution",
"智能超清",
"对结果做分辨率增强和画质修复。",
"generation",
{ width: 320, height: 280 },
"workflow",
"#fecaca"
),
"local-save": createDefinition(
"local-save",
"本地保存",
"将流程输出整理并保存到本地项目目录。",
"output",
{ width: 320, height: 380 },
"workflow",
"#bae6fd"
),
"motion-control": createDefinition(
"motion-control",
"动作迁移",
"把动作参考迁移到主体或镜头中。",
"generation",
{ width: 320, height: 580 },
"workflow",
"#fecaca"
),
"novel-input": createDefinition(
"novel-input",
"小说输入",
"粘贴长篇设定、故事梗概或分章节剧情。",
"input",
{ width: 400, height: 500 },
"text",
"#bfdbfe"
),
"preview": createDefinition(
"preview",
"预览节点",
"检查上游视频、图片或分镜输出效果。",
"output",
{ width: 440, height: 310 },
"workflow",
"#bae6fd"
),
"professional-camera": createDefinition(
"professional-camera",
"专业镜头",
"补充焦段、景别和镜头语言规则。",
"storyboard",
{ width: 320, height: 420 },
"workflow",
"#fde68a"
),
"rh-app": createDefinition(
"rh-app",
"RH 应用",
"跳转外部 RH 应用执行专门流程。",
"tool",
{ width: 340, height: 420 },
"workflow",
"#e2e8f0"
),
"rh-comfy": createDefinition(
"rh-comfy",
"RH Comfy",
"挂接 Comfy 风格的外部工作流节点。",
"tool",
{ width: 340, height: 420 },
"workflow",
"#e2e8f0"
),
"scene-description": createDefinition(
"scene-description",
"场景描述",
"沉淀空间氛围、材质和布光信息。",
"character",
{ width: 400, height: 400 },
"workflow",
"#dbeafe"
),
"storyboard-chart-node": createDefinition(
"storyboard-chart-node",
"分镜图表",
"把分镜节点转成更适合检查的图表视图。",
"storyboard",
{ width: 540, height: 480 },
"workflow",
"#fde68a"
),
"storyboard-node": createDefinition(
"storyboard-node",
"分镜节点",
"组织镜头表、时长、画面意图和镜头顺序。",
"storyboard",
{ width: 600, height: 500 },
"workflow",
"#fde68a"
),
"table-editor-node": createDefinition(
"table-editor-node",
"表格编辑器",
"先用结构化表格整理分镜字段再输出。",
"storyboard",
{ width: 520, height: 420 },
"workflow",
"#fde68a"
),
"text-node": createDefinition(
"text-node",
"文本节点",
"输入脚本、镜头、旁白和视频需求。",
"input",
{ width: 360, height: 280 },
"text",
"#bfdbfe"
),
"video-analyze": createDefinition(
"video-analyze",
"视频分析",
"解析视频节奏、镜头结构和可复用画面信息。",
"analysis",
{ width: 400, height: 500 },
"workflow",
"#bbf7d0"
),
"video-input": createDefinition(
"video-input",
"视频输入",
"导入素材视频、参考视频或上游成片。",
"input",
{ width: 360, height: 420 },
"video",
"#bfdbfe"
),
};
export const VIDEO_WORKFLOW_LIBRARY_SECTIONS = SECTION_DEFINITIONS;
export const VIDEO_WORKFLOW_STARTER_TEMPLATES: VideoWorkflowStarterTemplate[] = [
{
id: "script-to-video",
title: "脚本成片",
description: "从脚本文案进入,经过分镜和视频生成,最后预览并保存。",
nodes: ["text-node", "storyboard-node", "gen-video", "preview", "local-save"],
},
{
id: "image-to-video",
title: "图片转视频",
description: "从参考图启动视频生成,适合首帧、角色图和海报成片。",
nodes: ["input-image", "gen-video", "preview", "local-save"],
},
{
id: "video-analysis-remix",
title: "视频拆解再创作",
description: "先解析已有视频,再回到分镜和生图/生视频链路。",
nodes: ["video-input", "video-analyze", "storyboard-node", "gen-video", "preview"],
},
{
id: "novel-character-pipeline",
title: "小说角色拆解",
description: "从长文本提取角色与场景,再衍生角色视频。",
nodes: [
"novel-input",
"extract-characters-scenes",
"create-character",
"generate-character-video",
"preview",
],
},
{
id: "inpaint-repair",
title: "局部重绘回贴",
description: "对参考图局部修改后拼回,再预览和保存。",
nodes: ["input-image", "inpaint-crop", "inpaint-stitch", "preview", "local-save"],
},
];
export function getVideoWorkflowNodeDefinition(
type: VideoWorkflowTemplateType | string | null | undefined
) {
if (!type) return null;
return VIDEO_WORKFLOW_NODE_DEFINITIONS[type as VideoWorkflowTemplateType] || null;
}
export function getVideoWorkflowSectionNodes(sectionId: VideoWorkflowSectionId) {
return Object.values(VIDEO_WORKFLOW_NODE_DEFINITIONS).filter(
(definition) => definition.group === sectionId
);
}
export function getVideoWorkflowQuickConnectOutputs(type: VideoWorkflowTemplateType) {
return VIDEO_WORKFLOW_NODE_DEFINITIONS[type]?.quickConnectOutputs || [];
}
export function inferVideoWorkflowTemplateType(
node: CanvasNode | null | undefined
): VideoWorkflowTemplateType | null {
if (!node) return null;
const rawTemplateType =
typeof (node.data as { templateType?: string; metadata?: Record<string, unknown> })?.templateType ===
"string"
? String((node.data as { templateType?: string }).templateType).trim()
: typeof (node.data as { metadata?: Record<string, unknown> })?.metadata?.templateType ===
"string"
? String((node.data as { metadata?: Record<string, unknown> }).metadata?.templateType).trim()
: "";
if (rawTemplateType && rawTemplateType in VIDEO_WORKFLOW_NODE_DEFINITIONS) {
return rawTemplateType as VideoWorkflowTemplateType;
}
if (node.type === "text") {
return "text-node";
}
if (node.type === "image") {
return "input-image";
}
if (node.type === "video") {
return "video-input";
}
return null;
}
export function buildVideoWorkflowCanvasNode(
type: VideoWorkflowTemplateType,
position: Position
): Omit<CanvasNode, "id" | "createdAt" | "updatedAt"> {
const definition = getVideoWorkflowNodeDefinition(type);
if (!definition) {
throw new Error(`Unknown video workflow node type: ${type}`);
}
const metadata = {
templateType: definition.type,
label: definition.label,
description: definition.description,
};
if (definition.canvasNodeType === "text") {
const isNovelInput = type === "novel-input";
return {
type: "text",
position,
size: definition.size,
data: {
content: isNovelInput
? "在这里粘贴长篇剧情、人物关系、世界观设定或章节梗概"
: "输入脚本、镜头、画面意图或视频需求",
fontSize: isNovelInput ? 16 : 15,
lineHeight: 1.5,
borderWidth: 1,
borderColor: "#d4d4d8",
borderRadius: 20,
padding: 18,
backgroundColor: "#ffffff",
metadata,
},
connections: [],
};
}
if (definition.canvasNodeType === "image") {
return {
type: "image",
position,
size: definition.size,
data: {
src: "",
status: "pending",
title: definition.label,
prompt: definition.description,
note: "上传参考图、角色图、海报或关键帧",
metadata,
},
connections: [],
};
}
if (definition.canvasNodeType === "video") {
return {
type: "video",
position,
size: definition.size,
data: {
src: "",
status: "pending",
prompt: "上传视频素材、参考视频,或作为上游输出接收节点",
metadata,
},
connections: [],
};
}
return {
type: "workflow",
position,
size: definition.size,
data: {
templateType: definition.type,
title: definition.label,
description: definition.description,
summary:
definition.quickConnectOutputs.length > 0
? `下一步可接:${definition.quickConnectOutputs
.map((item) => item.label)
.join(" / ")}`
: "当前节点主要承担中间处理或输出检查。",
status: "pending",
group: definition.group,
accentColor: definition.accentColor,
metadata,
},
connections: [],
};
}
export function buildVideoWorkflowStarterTemplateGraph(
templateId: string,
origin: Position
) {
const template =
VIDEO_WORKFLOW_STARTER_TEMPLATES.find((item) => item.id === templateId) ||
VIDEO_WORKFLOW_STARTER_TEMPLATES[0];
const laneHeight = 540;
let cursorX = origin.x;
const nodes = template.nodes.map((type) => {
const definition = getVideoWorkflowNodeDefinition(type);
if (!definition) {
throw new Error(`Unknown video workflow starter node type: ${type}`);
}
const position = {
x: cursorX,
y: origin.y + Math.max(0, (laneHeight - definition.size.height) / 2),
};
cursorX += definition.size.width + 180;
return {
type,
node: buildVideoWorkflowCanvasNode(type, position),
};
});
const edges = nodes.slice(1).map((node, index) => ({
sourceIndex: index,
targetIndex: index + 1,
type: "default" as const,
}));
return {
template,
nodes,
edges,
};
}

View File

@ -0,0 +1,656 @@
import type { VideoGenerationMode } from "@/types/ai";
import type {
CanvasEdge,
CanvasNode,
ImageNodeData,
TextNodeData,
VideoNodeData,
WorkflowNodeData,
} from "@/types/canvas";
export type WorkflowMediaKind = "image" | "video";
export interface WorkflowMediaReference {
nodeId: string;
kind: WorkflowMediaKind;
url: string;
title?: string;
prompt?: string;
source: "node" | "workflow-result" | "workflow-preview";
}
export interface WorkflowStoryboardShot {
index: number;
sourceNodeId: string;
title: string;
description: string;
prompt: string;
}
export interface WorkflowCharacterRecord {
sourceNodeId: string;
name: string;
description: string;
prompt: string;
look?: string;
wardrobe?: string;
signatureAction?: string;
traits: string[];
}
export interface WorkflowSceneRecord {
sourceNodeId: string;
name: string;
description: string;
prompt: string;
mood?: string;
lighting?: string;
props: string[];
}
export interface WorkflowAssetRecord {
sourceNodeId: string;
kind: "character" | "scene";
name: string;
description: string;
prompt: string;
}
export interface WorkflowStructuredData {
characters: WorkflowCharacterRecord[];
scenes: WorkflowSceneRecord[];
characterAssets: WorkflowAssetRecord[];
sceneAssets: WorkflowAssetRecord[];
relationships: string[];
recommendedNextSteps: string[];
agentPlan: string[];
deliverables: string[];
recommendedNodes: string[];
}
export interface WorkflowGraphContext {
nodeMap: Map<string, CanvasNode>;
incoming: Map<string, string[]>;
outgoing: Map<string, string[]>;
}
function appendEdge(map: Map<string, string[]>, key: string, value: string) {
const current = map.get(key) || [];
current.push(value);
map.set(key, current);
}
function uniqueStrings(values: string[]) {
return Array.from(
new Set(values.map((item) => String(item || "").trim()).filter(Boolean))
);
}
function normalizeHttpUrl(value: string | null | undefined) {
const normalized = String(value || "").trim();
return /^https?:\/\//i.test(normalized) ? normalized : "";
}
function asRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function extractStructuredPromptTexts(items: unknown) {
if (!Array.isArray(items)) return [];
return items
.flatMap((item) => {
const raw = asRecord(item);
const traits = Array.isArray(raw.traits)
? raw.traits.map((trait) => String(trait || "").trim()).filter(Boolean)
: [];
const props = Array.isArray(raw.props)
? raw.props.map((prop) => String(prop || "").trim()).filter(Boolean)
: [];
return [
raw.name,
raw.title,
raw.description,
raw.prompt,
raw.look,
raw.wardrobe,
raw.signatureAction,
raw.mood,
raw.lighting,
traits.join("、"),
props.join("、"),
];
})
.map((value) => String(value || "").trim())
.filter(Boolean);
}
function extractWorkflowPromptCandidates(node: CanvasNode) {
if (node.type === "text") {
const data = node.data as TextNodeData;
return [data.content];
}
if (node.type === "image") {
const data = node.data as ImageNodeData;
return [data.prompt, data.title, data.note];
}
if (node.type === "video") {
const data = node.data as VideoNodeData;
return [data.prompt];
}
if (node.type === "workflow") {
const data = node.data as WorkflowNodeData;
const metadata = (data.metadata || {}) as Record<string, unknown>;
const storyboardShots = Array.isArray(metadata.storyboardShots)
? metadata.storyboardShots
: [];
const entityTexts = [
...extractStructuredPromptTexts(metadata.characters),
...extractStructuredPromptTexts(metadata.scenes),
...extractStructuredPromptTexts(metadata.characterAssets),
...extractStructuredPromptTexts(metadata.sceneAssets),
...(Array.isArray(metadata.agentPlan)
? metadata.agentPlan.map((item) => String(item || "").trim()).filter(Boolean)
: []),
...(Array.isArray(metadata.recommendedNextSteps)
? metadata.recommendedNextSteps
.map((item) => String(item || "").trim())
.filter(Boolean)
: []),
];
const storyboardTexts = storyboardShots
.map((item) => {
const raw = (item || {}) as Record<string, unknown>;
return [raw.title, raw.description].map((value) => String(value || "").trim()).filter(Boolean).join(" - ");
})
.filter(Boolean);
return [
String(metadata.sourcePrompt || ""),
data.summary || "",
data.description || "",
data.title || "",
...storyboardTexts,
...entityTexts,
];
}
return [];
}
function extractWorkflowMedia(node: CanvasNode): WorkflowMediaReference[] {
if (node.type === "image") {
const data = node.data as ImageNodeData;
const url = normalizeHttpUrl(data.src);
return url
? [
{
nodeId: node.id,
kind: "image",
url,
title: data.title,
prompt: data.prompt,
source: "node",
},
]
: [];
}
if (node.type === "video") {
const data = node.data as VideoNodeData;
const url = normalizeHttpUrl(data.src);
const posterUrl = normalizeHttpUrl(
data.coverImage ||
data.thumbnail ||
String(data.metadata?.coverUrl || data.metadata?.posterUrl || data.metadata?.lastFrameUrl || "")
);
const items: WorkflowMediaReference[] = [];
if (url) {
items.push({
nodeId: node.id,
kind: "video",
url,
prompt: data.prompt,
source: "node",
});
}
if (posterUrl) {
items.push({
nodeId: node.id,
kind: "image",
url: posterUrl,
title: "视频封面",
prompt: data.prompt,
source: "node",
});
}
return items;
}
if (node.type !== "workflow") {
return [];
}
const data = node.data as WorkflowNodeData;
const metadata = (data.metadata || {}) as Record<string, unknown>;
const resultUrl = normalizeHttpUrl(String(metadata.resultMediaUrl || ""));
const previewUrl = normalizeHttpUrl(String(metadata.previewMediaUrl || ""));
const resultPosterUrl = normalizeHttpUrl(String(metadata.resultPosterUrl || ""));
const resultKind =
String(metadata.resultMediaKind || "").trim() === "video" ? "video" : "image";
const previewKind =
String(metadata.previewMediaKind || "").trim() === "video" ? "video" : "image";
const title = String(metadata.resultMediaTitle || data.title || "").trim() || undefined;
const prompt = String(metadata.sourcePrompt || data.summary || data.description || "").trim() || undefined;
const items: WorkflowMediaReference[] = [];
if (resultUrl) {
items.push({
nodeId: node.id,
kind: resultKind,
url: resultUrl,
title,
prompt,
source: "workflow-result",
});
}
if (resultPosterUrl) {
items.push({
nodeId: node.id,
kind: "image",
url: resultPosterUrl,
title:
String(metadata.resultPosterTitle || `${data.title || "视频结果"}-封面`).trim() ||
undefined,
prompt,
source: "workflow-preview",
});
}
if (previewUrl && previewUrl !== resultUrl) {
items.push({
nodeId: node.id,
kind: previewKind,
url: previewUrl,
title:
String(metadata.previewMediaTitle || data.title || "").trim() || undefined,
prompt,
source: "workflow-preview",
});
}
return items;
}
function normalizeTextList(value: unknown, limit = 8) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, limit)
)
);
}
function normalizeCharacterRecord(
value: unknown,
sourceNodeId: string
): WorkflowCharacterRecord | null {
const raw = asRecord(value);
const name = String(raw.name || raw.title || "").trim();
const description = String(raw.description || raw.summary || "").trim();
const prompt = uniqueStrings([
String(raw.prompt || "").trim(),
name,
description,
String(raw.look || "").trim(),
String(raw.wardrobe || "").trim(),
String(raw.signatureAction || "").trim(),
normalizeTextList(raw.traits, 6).join("、"),
]).join("\n");
if (!name && !description && !prompt) {
return null;
}
return {
sourceNodeId,
name: name || "未命名角色",
description,
prompt,
look: String(raw.look || "").trim() || undefined,
wardrobe: String(raw.wardrobe || "").trim() || undefined,
signatureAction: String(raw.signatureAction || "").trim() || undefined,
traits: normalizeTextList(raw.traits, 6),
};
}
function normalizeSceneRecord(
value: unknown,
sourceNodeId: string
): WorkflowSceneRecord | null {
const raw = asRecord(value);
const name = String(raw.name || raw.title || "").trim();
const description = String(raw.description || raw.summary || "").trim();
const prompt = uniqueStrings([
String(raw.prompt || "").trim(),
name,
description,
String(raw.mood || "").trim(),
String(raw.lighting || "").trim(),
normalizeTextList(raw.props, 6).join("、"),
]).join("\n");
if (!name && !description && !prompt) {
return null;
}
return {
sourceNodeId,
name: name || "未命名场景",
description,
prompt,
mood: String(raw.mood || "").trim() || undefined,
lighting: String(raw.lighting || "").trim() || undefined,
props: normalizeTextList(raw.props, 6),
};
}
function normalizeAssetRecord(
value: unknown,
sourceNodeId: string,
kind: "character" | "scene"
): WorkflowAssetRecord | null {
const raw = asRecord(value);
const name = String(raw.name || raw.title || "").trim();
const description = String(raw.description || raw.summary || "").trim();
const prompt = uniqueStrings([
String(raw.prompt || "").trim(),
name,
description,
]).join("\n");
if (!name && !description && !prompt) {
return null;
}
return {
sourceNodeId,
kind,
name: name || (kind === "character" ? "角色资产" : "场景资产"),
description,
prompt,
};
}
function normalizeStoryboardShot(
raw: unknown,
index: number,
sourceNodeId: string
): WorkflowStoryboardShot | null {
const item = (raw || {}) as Record<string, unknown>;
const title = String(item.title || `镜头 ${index + 1}`).trim();
const description = String(item.description || "").trim();
const prompt = uniqueStrings([title, description]).join("\n");
if (!title && !description) {
return null;
}
return {
index,
sourceNodeId,
title: title || `镜头 ${index + 1}`,
description,
prompt,
};
}
function extractWorkflowStoryboardShots(node: CanvasNode): WorkflowStoryboardShot[] {
if (node.type !== "workflow") {
return [];
}
const data = node.data as WorkflowNodeData;
const metadata = (data.metadata || {}) as Record<string, unknown>;
const storyboardShots = Array.isArray(metadata.storyboardShots)
? metadata.storyboardShots
: [];
return storyboardShots
.map((item, index) => normalizeStoryboardShot(item, index, node.id))
.filter((item): item is WorkflowStoryboardShot => Boolean(item));
}
export function createWorkflowGraphContext(
nodes: CanvasNode[],
edges: CanvasEdge[]
): WorkflowGraphContext {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const incoming = new Map<string, string[]>();
const outgoing = new Map<string, string[]>();
edges.forEach((edge) => {
appendEdge(incoming, edge.target, edge.source);
appendEdge(outgoing, edge.source, edge.target);
});
return {
nodeMap,
incoming,
outgoing,
};
}
export function getDirectUpstreamNodes(nodeId: string, graph: WorkflowGraphContext) {
return (graph.incoming.get(nodeId) || [])
.map((id) => graph.nodeMap.get(id) || null)
.filter((node): node is CanvasNode => Boolean(node));
}
export function getDirectDownstreamNodes(nodeId: string, graph: WorkflowGraphContext) {
return (graph.outgoing.get(nodeId) || [])
.map((id) => graph.nodeMap.get(id) || null)
.filter((node): node is CanvasNode => Boolean(node));
}
export function getRecursiveUpstreamNodes(nodeId: string, graph: WorkflowGraphContext) {
const queue = [...(graph.incoming.get(nodeId) || [])];
const visited = new Set<string>();
const ordered: CanvasNode[] = [];
while (queue.length > 0) {
const currentId = queue.shift()!;
if (visited.has(currentId)) continue;
visited.add(currentId);
const node = graph.nodeMap.get(currentId);
if (!node) continue;
ordered.push(node);
queue.push(...(graph.incoming.get(currentId) || []));
}
return ordered;
}
export function collectWorkflowPrompts(nodeId: string, graph: WorkflowGraphContext) {
const upstreamNodes = getRecursiveUpstreamNodes(nodeId, graph);
return uniqueStrings(
upstreamNodes.flatMap((node) =>
extractWorkflowPromptCandidates(node).filter(
(value): value is string => typeof value === "string"
)
)
);
}
export function buildWorkflowPrompt(nodeId: string, graph: WorkflowGraphContext) {
const prompts = collectWorkflowPrompts(nodeId, graph);
if (prompts.length === 0) return "";
return prompts.slice(0, 4).join("\n");
}
export function collectWorkflowMedia(nodeId: string, graph: WorkflowGraphContext) {
const upstreamNodes = getRecursiveUpstreamNodes(nodeId, graph);
const seen = new Set<string>();
const media: WorkflowMediaReference[] = [];
upstreamNodes.forEach((node) => {
extractWorkflowMedia(node).forEach((item) => {
const key = `${item.kind}:${item.url}`;
if (seen.has(key)) return;
seen.add(key);
media.push(item);
});
});
return media;
}
export function collectWorkflowStoryboardShots(
nodeId: string,
graph: WorkflowGraphContext
) {
const currentNode = graph.nodeMap.get(nodeId);
if (currentNode) {
const currentShots = extractWorkflowStoryboardShots(currentNode);
if (currentShots.length > 0) {
return currentShots;
}
}
const upstreamNodes = getRecursiveUpstreamNodes(nodeId, graph);
for (const node of upstreamNodes) {
const shots = extractWorkflowStoryboardShots(node);
if (shots.length > 0) {
return shots;
}
}
return [];
}
export function collectWorkflowStructuredData(
nodeId: string,
graph: WorkflowGraphContext
): WorkflowStructuredData {
const orderedNodes = [
graph.nodeMap.get(nodeId) || null,
...getRecursiveUpstreamNodes(nodeId, graph),
].filter((node): node is CanvasNode => Boolean(node));
const characters: WorkflowCharacterRecord[] = [];
const scenes: WorkflowSceneRecord[] = [];
const characterAssets: WorkflowAssetRecord[] = [];
const sceneAssets: WorkflowAssetRecord[] = [];
const relationships: string[] = [];
const recommendedNextSteps: string[] = [];
const agentPlan: string[] = [];
const deliverables: string[] = [];
const recommendedNodes: string[] = [];
const seen = new Set<string>();
orderedNodes.forEach((node) => {
if (node.type !== "workflow") return;
const data = node.data as WorkflowNodeData;
const metadata = (data.metadata || {}) as Record<string, unknown>;
const pushUnique = <T extends { name?: string; description?: string; prompt?: string }>(
bucket: T[],
item: T | null,
kind: string
) => {
if (!item) return;
const key = `${kind}:${String(item.name || "").trim().toLowerCase()}:${String(
item.description || item.prompt || ""
)
.trim()
.toLowerCase()}`;
if (seen.has(key)) return;
seen.add(key);
bucket.push(item);
};
(Array.isArray(metadata.characters) ? metadata.characters : []).forEach((item) => {
pushUnique(characters, normalizeCharacterRecord(item, node.id), "character");
});
(Array.isArray(metadata.scenes) ? metadata.scenes : []).forEach((item) => {
pushUnique(scenes, normalizeSceneRecord(item, node.id), "scene");
});
(Array.isArray(metadata.characterAssets) ? metadata.characterAssets : []).forEach((item) => {
pushUnique(
characterAssets,
normalizeAssetRecord(item, node.id, "character"),
"character-asset"
);
});
(Array.isArray(metadata.sceneAssets) ? metadata.sceneAssets : []).forEach((item) => {
pushUnique(sceneAssets, normalizeAssetRecord(item, node.id, "scene"), "scene-asset");
});
normalizeTextList(metadata.relationships, 8).forEach((item) => {
if (!relationships.includes(item)) relationships.push(item);
});
normalizeTextList(metadata.recommendedNextSteps, 8).forEach((item) => {
if (!recommendedNextSteps.includes(item)) recommendedNextSteps.push(item);
});
normalizeTextList(metadata.agentPlan, 8).forEach((item) => {
if (!agentPlan.includes(item)) agentPlan.push(item);
});
normalizeTextList(metadata.deliverables, 8).forEach((item) => {
if (!deliverables.includes(item)) deliverables.push(item);
});
normalizeTextList(metadata.recommendedNodes, 8).forEach((item) => {
if (!recommendedNodes.includes(item)) recommendedNodes.push(item);
});
});
return {
characters,
scenes,
characterAssets,
sceneAssets,
relationships,
recommendedNextSteps,
agentPlan,
deliverables,
recommendedNodes,
};
}
export function getBestWorkflowPreviewMedia(
nodeId: string,
graph: WorkflowGraphContext
): WorkflowMediaReference | null {
return collectWorkflowMedia(nodeId, graph)[0] || null;
}
export function inferVideoGenerationModeFromMedia(
media: WorkflowMediaReference[]
): VideoGenerationMode {
const imageRefs = media.filter((item) => item.kind === "image");
if (imageRefs.length >= 3) return "reference-images";
if (imageRefs.length >= 2) return "first-last-frame";
if (imageRefs.length === 1) return "first-frame";
return "text-to-video";
}
export function summarizeWorkflowInputs(nodeId: string, graph: WorkflowGraphContext) {
const media = collectWorkflowMedia(nodeId, graph);
const prompts = collectWorkflowPrompts(nodeId, graph);
return {
promptCount: prompts.length,
imageCount: media.filter((item) => item.kind === "image").length,
videoCount: media.filter((item) => item.kind === "video").length,
};
}

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,11 @@
// 画布服务层 // 画布服务层
import type { CanvasProject, CanvasState, CanvasNode } from '@/types/canvas'; import type {
CanvasProject,
CanvasState,
CanvasNode,
CanvasWorkspaceType,
} from '@/types/canvas';
import type { VideoGenerationMode, VideoRatio, VideoResolution } from '@/types/ai';
import { requestLongTaskResult } from '@/lib/client/long-task'; import { requestLongTaskResult } from '@/lib/client/long-task';
import type { import type {
EnhanceProKreaModel, EnhanceProKreaModel,
@ -34,6 +40,11 @@ export interface CanvasTextEditPayloadItem {
action?: 'replace' | 'delete'; action?: 'replace' | 'delete';
} }
export type WorkflowAnalysisMode =
| "video-analysis"
| "character-scene-extraction"
| "agent-plan";
export interface EnhanceProImageResult { export interface EnhanceProImageResult {
originalUrl: string; originalUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -69,9 +80,15 @@ export interface EnhanceProRequestParams {
// 画布服务 // 画布服务
export const canvasService = { export const canvasService = {
// 获取画布列表 // 获取画布列表
async getProjects(): Promise<ApiResponse<CanvasProject[]>> { async getProjects(
workspaceType: CanvasWorkspaceType = 'canvas'
): Promise<ApiResponse<CanvasProject[]>> {
try { try {
const response = await fetch('/api/canvas'); const query =
workspaceType && workspaceType !== 'canvas'
? `?workspaceType=${encodeURIComponent(workspaceType)}`
: '';
const response = await fetch(`/api/canvas${query}`);
const data = await response.json(); const data = await response.json();
return data; return data;
} catch (error) { } catch (error) {
@ -91,12 +108,15 @@ export const canvasService = {
}, },
// 创建画布 // 创建画布
async createProject(project: Partial<CanvasProject>): Promise<ApiResponse<CanvasProject>> { async createProject(
project: Partial<CanvasProject>,
workspaceType: CanvasWorkspaceType = 'canvas'
): Promise<ApiResponse<CanvasProject>> {
try { try {
const response = await fetch('/api/canvas', { const response = await fetch('/api/canvas', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(project), body: JSON.stringify({ ...project, workspaceType }),
}); });
const data = await response.json(); const data = await response.json();
return data; return data;
@ -106,12 +126,18 @@ export const canvasService = {
}, },
// 更新画布 // 更新画布
async updateProject(id: string, updates: Partial<CanvasProject>): Promise<ApiResponse<CanvasProject>> { async updateProject(
id: string,
updates: Partial<CanvasProject>,
workspaceType?: CanvasWorkspaceType
): Promise<ApiResponse<CanvasProject>> {
try { try {
const response = await fetch(`/api/canvas/${id}`, { const response = await fetch(`/api/canvas/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates), body: JSON.stringify(
workspaceType ? { ...updates, workspaceType } : updates
),
}); });
const data = await response.json(); const data = await response.json();
return data; return data;
@ -134,9 +160,15 @@ export const canvasService = {
}, },
// 保存画布状态 // 保存画布状态
async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> { async saveCanvasState(
id: string,
canvasData: CanvasState,
workspaceType?: CanvasWorkspaceType
): Promise<ApiResponse> {
try { try {
const requestBody = JSON.stringify({ canvasData }); const requestBody = JSON.stringify(
workspaceType ? { canvasData, workspaceType } : { canvasData }
);
const doSave = async () => const doSave = async () =>
fetch(`/api/canvas/${id}`, { fetch(`/api/canvas/${id}`, {
method: 'PUT', method: 'PUT',
@ -394,12 +426,36 @@ export const imageGenerationService = {
}, },
// 图片扩展 - 使用图片URL // 图片扩展 - 使用图片URL
async extendImage(imageUrl: string, direction?: string): Promise<ApiResponse<{ url: string }>> { async extendImage(
imageUrl: string,
options?:
| string
| {
prompt?: string;
xScale?: number;
yScale?: number;
}
): Promise<ApiResponse<{ url: string }>> {
try { try {
const normalizedOptions =
typeof options === 'string'
? {
prompt: options.trim() ? `图像拓展处理:${options.trim()}` : undefined,
}
: options || {};
const response = await fetch('/api/extend', { const response = await fetch('/api/extend', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_url: imageUrl, direction }), // extend API 使用 image_url 参数名 body: JSON.stringify({
image_url: imageUrl,
prompt: normalizedOptions.prompt || '图像拓展处理',
...(typeof normalizedOptions.xScale === 'number'
? { xScale: normalizedOptions.xScale }
: {}),
...(typeof normalizedOptions.yScale === 'number'
? { yScale: normalizedOptions.yScale }
: {}),
}),
}); });
const data = await response.json(); const data = await response.json();
if (data.success && data.original_url) { if (data.success && data.original_url) {
@ -520,15 +576,42 @@ export const imageGenerationService = {
horizontalAngle: number; horizontalAngle: number;
verticalAngle: number; verticalAngle: number;
zoom: number; zoom: number;
}): Promise<ApiResponse<{ url: string }>> { }): Promise<
ApiResponse<{
url: string;
thumbnailUrl?: string;
prompt?: string;
angles?: {
horizontal_angle?: number;
vertical_angle?: number;
zoom?: number;
};
}>
> {
try { try {
const response = await fetch('/api/angle-edit', { const response = await fetch('/api/angle-edit', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params), body: JSON.stringify({
image_url: params.imageUrl,
horizontal_angle: params.horizontalAngle,
vertical_angle: params.verticalAngle,
zoom: params.zoom,
}),
}); });
const data = await response.json(); const data = await response.json();
return data; if (data.success && data.image_url) {
return {
success: true,
data: {
url: data.image_url,
thumbnailUrl: data.thumbnail_url,
prompt: data.prompt,
angles: data.angles,
},
};
}
return { success: false, error: data.error || '角度编辑失败' };
} catch (error) { } catch (error) {
return { success: false, error: '角度编辑失败' }; return { success: false, error: '角度编辑失败' };
} }
@ -594,10 +677,26 @@ export const videoGenerationService = {
// 生成视频 // 生成视频
async generateVideo(params: { async generateVideo(params: {
prompt: string; prompt: string;
model: string; mode?: VideoGenerationMode;
referenceImage?: string; images?: string[];
resolution?: VideoResolution;
ratio?: VideoRatio;
duration?: number; duration?: number;
}): Promise<ApiResponse<{ url: string }>> { watermark?: boolean;
cameraFixed?: boolean;
returnLastFrame?: boolean;
preferredModelRef?: string;
}): Promise<
ApiResponse<{
url: string;
metadata?: Record<string, any> | null;
modelDisplayName?: string;
modelId?: string;
modelRef?: string;
mode?: VideoGenerationMode;
images?: string[];
}>
> {
try { try {
const response = await fetch('/api/canvas/generate/video', { const response = await fetch('/api/canvas/generate/video', {
method: 'POST', method: 'POST',
@ -612,6 +711,44 @@ export const videoGenerationService = {
}, },
}; };
export const workflowAnalysisService = {
async analyze(params: {
mode: WorkflowAnalysisMode;
prompt?: string;
imageUrls?: string[];
videoUrls?: string[];
nodeTitle?: string;
}): Promise<
ApiResponse<{
summary?: string;
highlights?: string[];
shotIdeas?: Array<{ title: string; description: string }>;
visualMotifs?: string[];
recommendedNextSteps?: string[];
characters?: Array<Record<string, any>>;
scenes?: Array<Record<string, any>>;
relationships?: string[];
agentPlan?: string[];
deliverables?: string[];
recommendedNodes?: string[];
model?: string | null;
raw?: string;
}>
> {
try {
const response = await fetch('/api/canvas/workflow/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '工作流分析失败' };
}
},
};
export type Model3DTaskStatus = export type Model3DTaskStatus =
| 'queued' | 'queued'
| 'running' | 'running'

View File

@ -15,6 +15,20 @@ import type {
// 生成唯一 ID // 生成唯一 ID
const generateId = () => crypto.randomUUID(); const generateId = () => crypto.randomUUID();
const rebuildNodeConnections = (nodes: CanvasNode[], edges: CanvasEdge[]) => {
const connectionMap = new Map<string, Set<string>>();
nodes.forEach((node) => connectionMap.set(node.id, new Set<string>()));
edges.forEach((edge) => {
connectionMap.get(edge.source)?.add(edge.target);
connectionMap.get(edge.target)?.add(edge.source);
});
nodes.forEach((node) => {
node.connections = Array.from(connectionMap.get(node.id) || []);
});
};
// 默认视口 // 默认视口
const defaultViewport: Viewport = { const defaultViewport: Viewport = {
x: 0, x: 0,
@ -329,6 +343,7 @@ export const useCanvasStore = create<CanvasStore>()(
} }
state.nodes = state.nodes.filter(n => n.id !== id); state.nodes = state.nodes.filter(n => n.id !== id);
state.edges = state.edges.filter(e => e.source !== id && e.target !== id); state.edges = state.edges.filter(e => e.source !== id && e.target !== id);
rebuildNodeConnections(state.nodes, state.edges);
state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id); state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id);
}); });
get().pushHistory(); get().pushHistory();
@ -358,6 +373,7 @@ export const useCanvasStore = create<CanvasStore>()(
state.edges = state.edges.filter( state.edges = state.edges.filter(
e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target) e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target)
); );
rebuildNodeConnections(state.nodes, state.edges);
state.selectedNodeIds = []; state.selectedNodeIds = [];
}); });
get().pushHistory(); get().pushHistory();
@ -393,6 +409,7 @@ export const useCanvasStore = create<CanvasStore>()(
addEdge: (edgeData) => { addEdge: (edgeData) => {
const id = generateId(); const id = generateId();
const edge: CanvasEdge = { ...edgeData, id }; const edge: CanvasEdge = { ...edgeData, id };
let added = false;
set((state) => { set((state) => {
// 检查是否已存在相同的边 // 检查是否已存在相同的边
@ -401,15 +418,30 @@ export const useCanvasStore = create<CanvasStore>()(
); );
if (!exists) { if (!exists) {
state.edges.push(edge); state.edges.push(edge);
rebuildNodeConnections(state.nodes, state.edges);
added = true;
} }
}); });
if (added) {
get().pushHistory();
}
return id; return id;
}, },
deleteEdge: (id) => set((state) => { deleteEdge: (id) => {
state.edges = state.edges.filter(e => e.id !== id); let deleted = false;
}), set((state) => {
const nextEdges = state.edges.filter(e => e.id !== id);
deleted = nextEdges.length !== state.edges.length;
state.edges = nextEdges;
rebuildNodeConnections(state.nodes, state.edges);
});
if (deleted) {
get().pushHistory();
}
},
// 视口操作 // 视口操作
setViewport: (viewport) => set((state) => { setViewport: (viewport) => set((state) => {
@ -503,6 +535,7 @@ export const useCanvasStore = create<CanvasStore>()(
loadCanvas: (canvasState) => set((state) => { loadCanvas: (canvasState) => set((state) => {
if (canvasState.nodes) state.nodes = canvasState.nodes; if (canvasState.nodes) state.nodes = canvasState.nodes;
if (canvasState.edges) state.edges = canvasState.edges; if (canvasState.edges) state.edges = canvasState.edges;
rebuildNodeConnections(state.nodes, state.edges);
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds; if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
if (canvasState.viewport) state.viewport = canvasState.viewport; if (canvasState.viewport) state.viewport = canvasState.viewport;
if (canvasState.tool) state.tool = canvasState.tool; if (canvasState.tool) state.tool = canvasState.tool;

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,7 @@
import type { AgentWorkflowPersistedState } from "@/types/agent"; import type { AgentWorkflowPersistedState } from "@/types/agent";
export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video';
// 画布节点类型定义 // 画布节点类型定义
export type NodeType = export type NodeType =
@ -7,6 +9,7 @@ export type NodeType =
| 'image' | 'image'
| 'video' | 'video'
| 'text' | 'text'
| 'workflow'
| 'scene-fusion' | 'scene-fusion'
| 'model3d' | 'model3d'
| 'world3d'; | 'world3d';
@ -99,6 +102,19 @@ export interface TextNodeData {
boxShadow?: string; boxShadow?: string;
backdropBlur?: number; backdropBlur?: number;
textTransform?: "none" | "uppercase" | "lowercase" | "capitalize"; textTransform?: "none" | "uppercase" | "lowercase" | "capitalize";
metadata?: Record<string, any>;
}
// 工作流节点数据
export interface WorkflowNodeData {
templateType: string;
title: string;
description?: string;
summary?: string;
status: NodeStatus;
group?: string;
accentColor?: string;
metadata?: Record<string, any>;
} }
// 场景裂变节点数据 // 场景裂变节点数据
@ -165,6 +181,7 @@ export interface CanvasNode {
| ImageNodeData | ImageNodeData
| VideoNodeData | VideoNodeData
| TextNodeData | TextNodeData
| WorkflowNodeData
| SceneFusionNodeData | SceneFusionNodeData
| Model3DNodeData | Model3DNodeData
| World3DNodeData; | World3DNodeData;
@ -200,6 +217,7 @@ export interface CanvasProject {
title: string; title: string;
description?: string; description?: string;
thumbnail?: string; thumbnail?: string;
workspaceType?: CanvasWorkspaceType;
canvasData: CanvasState; canvasData: CanvasState;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;