Harden public attack surface
1
.gitignore
vendored
@ -63,6 +63,7 @@ migration-*.txt
|
||||
|
||||
# 本地抓包/竞品参考产物,不能提交
|
||||
artifacts/liblib-*/
|
||||
analysis/qiaodou-extract/
|
||||
|
||||
# 模型文件
|
||||
*.model
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
# 桥豆麻衣酱 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
|
||||
@ -1,181 +0,0 @@
|
||||
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}`);
|
||||
@ -1,116 +0,0 @@
|
||||
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();
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
@ -1 +0,0 @@
|
||||
(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)}}})();
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@ -1,263 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@ -1,598 +0,0 @@
|
||||
# 桥豆麻衣酱 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 这些后端,反而是后面的工作。
|
||||
@ -1,10 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
data: null,
|
||||
error: "未授权,请先登录",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// 转发到 Florence-2 后端(本地服务)
|
||||
@ -31,4 +44,3 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import sharp from "sharp";
|
||||
@ -33,6 +34,40 @@ interface PickObjectResult {
|
||||
|
||||
const MAX_VISION_SIDE = 500;
|
||||
const PICK_OBJECT_FETCH_TIMEOUT_MS = 15000;
|
||||
const MAX_PICK_OBJECT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function isBlockedImageHost(hostname: string) {
|
||||
const host = hostname.toLowerCase();
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "::1" ||
|
||||
host.endsWith(".local") ||
|
||||
host.endsWith(".internal")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = host.split(".").map((part) => Number(part));
|
||||
if (parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
||||
const [a, b] = parts;
|
||||
return (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
);
|
||||
}
|
||||
|
||||
return host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
|
||||
}
|
||||
|
||||
function ensureImageSizeAllowed(byteLength: number) {
|
||||
if (byteLength > MAX_PICK_OBJECT_IMAGE_BYTES) {
|
||||
throw new Error("图片过大,请上传 10MB 以内的图片");
|
||||
}
|
||||
}
|
||||
|
||||
async function downscaleImageForVision(buffer: Buffer) {
|
||||
const source = sharp(buffer, { failOn: "none" });
|
||||
@ -85,6 +120,11 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
if (!raw) throw new Error("缺少图片数据");
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
const url = new URL(raw);
|
||||
if (!["http:", "https:"].includes(url.protocol) || isBlockedImageHost(url.hostname)) {
|
||||
throw new Error("图片地址不允许访问");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PICK_OBJECT_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
@ -95,7 +135,12 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`拉取图片失败: HTTP ${response.status}`);
|
||||
}
|
||||
const contentLength = Number(response.headers.get("content-length") || "0");
|
||||
if (contentLength) {
|
||||
ensureImageSizeAllowed(contentLength);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
ensureImageSizeAllowed(arrayBuffer.byteLength);
|
||||
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@ -108,12 +153,22 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
}
|
||||
|
||||
const base64Body = base64Match[2];
|
||||
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
||||
const buffer = Buffer.from(base64Body, "base64");
|
||||
ensureImageSizeAllowed(buffer.byteLength);
|
||||
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
||||
}
|
||||
|
||||
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: PickObjectRequest = await request.json();
|
||||
const { imageData, clickX, clickY } = body;
|
||||
|
||||
@ -133,6 +188,13 @@ export async function POST(request: Request) {
|
||||
error: '缺少点击坐标',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (clickX < 0 || clickX > 1 || clickY < 0 || clickY > 1) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '点击坐标超出范围',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedImage = await normalizeImageInput(imageData);
|
||||
|
||||
|
||||
@ -45,7 +45,6 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
|
||||
import {
|
||||
getGenerationCreditCostFromImageGenConfig,
|
||||
getImageCreditCostFromConfig,
|
||||
@ -505,9 +504,9 @@ type VideoAnalysisState = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[];
|
||||
const capturedCounts = (capturedCanvasModules as any).counts || {};
|
||||
const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[];
|
||||
const capturedTools: CapturedTool[] = [];
|
||||
const capturedCounts: { byType?: Record<string, number> } = {};
|
||||
const capturedWorkflows: WorkflowPreset[] = [];
|
||||
|
||||
const TOOL_TYPE_META: Record<
|
||||
string,
|
||||
|
||||
@ -15,6 +15,8 @@ const nextConfig: NextConfig = {
|
||||
proxyClientMaxBodySize: "500mb", // 代理/中间件请求体限制
|
||||
},
|
||||
images: {
|
||||
dangerouslyAllowLocalIP: false,
|
||||
contentDispositionType: "attachment",
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "avatar.vercel.sh",
|
||||
@ -61,6 +63,7 @@ const nextConfig: NextConfig = {
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains; preload" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
{ key: "Content-Security-Policy", value: csp },
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
|
||||
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
|
||||
"build": "tsx lib/db/migrate && next build",
|
||||
"postbuild": "find .next/static -type f -name '*.map' -delete 2>/dev/null || true",
|
||||
"build:skip-prerender": "tsx lib/db/migrate && next build --no-lint || (echo 'Build with errors, creating fallback...' && mkdir -p .next && echo '{}' > .next/prerender-manifest.json && echo 'Build fallback created')",
|
||||
"start": "next start",
|
||||
"lint": "npx ultracite@latest check",
|
||||
|
||||