free-short-video 5.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +32 -0
- package/LICENSE +21 -0
- package/README.md +531 -0
- package/bin/cli.js +191 -0
- package/core/__init__.py +35 -0
- package/core/api/__init__.py +7 -0
- package/core/api/agnes_chat.py +294 -0
- package/core/api/agnes_image.py +256 -0
- package/core/api/agnes_models.py +84 -0
- package/core/api/agnes_video.py +535 -0
- package/core/api/error_collector.py +215 -0
- package/core/api/rate_limiter.py +142 -0
- package/core/artifacts.py +574 -0
- package/core/audio/__init__.py +9 -0
- package/core/audio/subtitle.py +1103 -0
- package/core/audio/tts.py +194 -0
- package/core/audio/voices.py +330 -0
- package/core/compositor/__init__.py +6 -0
- package/core/compositor/concatenator.py +653 -0
- package/core/compositor/processor.py +124 -0
- package/core/compositor/watermark.py +221 -0
- package/core/config.py +474 -0
- package/core/image_generator.py +8 -0
- package/core/path_security.py +58 -0
- package/core/pipeline.py +8 -0
- package/core/pipelines/__init__.py +466 -0
- package/core/pipelines/anchor_video.py +406 -0
- package/core/pipelines/creative_video.py +1968 -0
- package/core/pipelines/manuscript_video.py +540 -0
- package/core/pipelines/multi_scene.py +380 -0
- package/core/pipelines/poetry_video.py +504 -0
- package/core/pipelines/simple_video.py +143 -0
- package/core/screenwriter.py +1860 -0
- package/core/task_manager.py +211 -0
- package/core/video_generator.py +8 -0
- package/models/__init__.py +75 -0
- package/models/task.py +548 -0
- package/package.json +36 -0
- package/requirements.txt +13 -0
- package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
- package/resource/fonts/STHeitiMedium.ttc +0 -0
- package/server.py +2026 -0
- package/static/favicon.ico +0 -0
- package/static/icon.png +0 -0
- package/static/index.html +5277 -0
- package/utils/__init__.py +0 -0
- package/utils/image.py +36 -0
- package/utils/video.py +28 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* free-short-video — npm launcher
|
|
6
|
+
*
|
|
7
|
+
* Installs/runs the full Agnes Video Generator (Python/FastAPI) service on the
|
|
8
|
+
* user's machine with zero manual setup beyond a Python 3.10+ interpreter:
|
|
9
|
+
* 1. detect python3 >= 3.10
|
|
10
|
+
* 2. create a venv inside the package
|
|
11
|
+
* 3. pip install -r requirements.txt (pulls moviepy + imageio-ffmpeg)
|
|
12
|
+
* 4. expose imageio-ffmpeg's static ffmpeg binary on PATH (no system ffmpeg needed)
|
|
13
|
+
* 5. spawn `python server.py` with PORT/HOST + optional AGNES_API_KEY
|
|
14
|
+
* 6. auto-open the browser, forward Ctrl+C for graceful shutdown
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { spawn, execSync } = require('child_process');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
23
|
+
|
|
24
|
+
function printHelp() {
|
|
25
|
+
console.log(`free-short-video — free AI short-video generator
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
npx free-short-video [options]
|
|
29
|
+
fsv [options]
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--port <n> Listen port (default 8765)
|
|
33
|
+
--host <h> Bind host (default 127.0.0.1; use 0.0.0.0 for LAN)
|
|
34
|
+
--no-open Do not auto-open the browser
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
|
|
37
|
+
Environment:
|
|
38
|
+
AGNES_API_KEY Your Agnes API key (can also be set later in the Web UI)
|
|
39
|
+
|
|
40
|
+
First run creates a local venv and installs Python dependencies automatically.
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── parse args ────────────────────────────────────────────────────────────
|
|
45
|
+
let port = 8765;
|
|
46
|
+
let host = '127.0.0.1';
|
|
47
|
+
let openBrowser = true;
|
|
48
|
+
|
|
49
|
+
const argv = process.argv.slice(2);
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i];
|
|
52
|
+
if (a === '--port') {
|
|
53
|
+
const v = parseInt(argv[++i], 10);
|
|
54
|
+
if (!Number.isNaN(v)) port = v;
|
|
55
|
+
} else if (a === '--host') {
|
|
56
|
+
host = argv[++i] || host;
|
|
57
|
+
} else if (a === '--no-open') {
|
|
58
|
+
openBrowser = false;
|
|
59
|
+
} else if (a === '-h' || a === '--help') {
|
|
60
|
+
printHelp();
|
|
61
|
+
process.exit(0);
|
|
62
|
+
} else {
|
|
63
|
+
console.error(`Unknown option: ${a}\n`);
|
|
64
|
+
printHelp();
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
70
|
+
function run(cmd, opts) {
|
|
71
|
+
return execSync(cmd, Object.assign({ stdio: 'inherit', cwd: PKG_ROOT }, opts));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function findPython() {
|
|
75
|
+
for (const cmd of ['python3', 'python']) {
|
|
76
|
+
try {
|
|
77
|
+
const out = execSync(`"${cmd}" --version`, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
78
|
+
.toString()
|
|
79
|
+
.trim();
|
|
80
|
+
const m = out.match(/Python (\d+)\.(\d+)/);
|
|
81
|
+
if (m && (parseInt(m[1], 10) > 3 || (parseInt(m[1], 10) === 3 && parseInt(m[2], 10) >= 10))) {
|
|
82
|
+
return cmd;
|
|
83
|
+
}
|
|
84
|
+
} catch (_) {
|
|
85
|
+
/* not found / wrong version */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function venvBin(name) {
|
|
92
|
+
return process.platform === 'win32'
|
|
93
|
+
? path.join(PKG_ROOT, '.venv', 'Scripts', `${name}.exe`)
|
|
94
|
+
: path.join(PKG_ROOT, '.venv', 'bin', name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── 1. python check ───────────────────────────────────────────────────────
|
|
98
|
+
console.log('================================================');
|
|
99
|
+
console.log(' free-short-video');
|
|
100
|
+
console.log('================================================');
|
|
101
|
+
console.log('');
|
|
102
|
+
|
|
103
|
+
const py = findPython();
|
|
104
|
+
if (!py) {
|
|
105
|
+
console.error('❌ Python 3.10+ is required but was not found.');
|
|
106
|
+
console.error(' macOS: brew install python3');
|
|
107
|
+
console.error(' Ubuntu: sudo apt install python3 python3-venv');
|
|
108
|
+
console.error(' Windows: https://www.python.org/downloads/');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
console.log(`✓ Using ${py}`);
|
|
112
|
+
|
|
113
|
+
const venvPython = venvBin('python');
|
|
114
|
+
const venvPip = venvBin('pip');
|
|
115
|
+
|
|
116
|
+
// ── 2. venv + deps ─────────────────────────────────────────────────────────
|
|
117
|
+
if (!fs.existsSync(venvPython)) {
|
|
118
|
+
console.log('[1/3] Creating virtual environment...');
|
|
119
|
+
run(`"${py}" -m venv "${path.join(PKG_ROOT, '.venv')}"`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log('[2/3] Installing Python dependencies (first run only)...');
|
|
123
|
+
run(`"${venvPip}" install -q -r "${path.join(PKG_ROOT, 'requirements.txt')}"`, {
|
|
124
|
+
env: process.env,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── 3. ffmpeg via imageio-ffmpeg (no system ffmpeg required) ──────────────
|
|
128
|
+
let ffmpegDir = null;
|
|
129
|
+
try {
|
|
130
|
+
const exe = execSync(`"${venvPython}" -c "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())"`, {
|
|
131
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
132
|
+
})
|
|
133
|
+
.toString()
|
|
134
|
+
.trim();
|
|
135
|
+
if (exe) ffmpegDir = path.dirname(exe);
|
|
136
|
+
} catch (_) {
|
|
137
|
+
/* imageio-ffmpeg missing is non-fatal; system ffmpeg may still work */
|
|
138
|
+
}
|
|
139
|
+
if (ffmpegDir) {
|
|
140
|
+
console.log('✓ Using bundled ffmpeg (imageio-ffmpeg)');
|
|
141
|
+
} else {
|
|
142
|
+
console.log('⚠ No bundled ffmpeg found; relying on system ffmpeg in PATH');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 4. launch server ──────────────────────────────────────────────────────
|
|
146
|
+
const env = Object.assign({}, process.env, {
|
|
147
|
+
HOST: host,
|
|
148
|
+
PORT: String(port),
|
|
149
|
+
});
|
|
150
|
+
if (ffmpegDir) {
|
|
151
|
+
env.PATH = ffmpegDir + path.delimiter + (env.PATH || '');
|
|
152
|
+
}
|
|
153
|
+
if (process.env.AGNES_API_KEY) {
|
|
154
|
+
env.AGNES_API_KEY = process.env.AGNES_API_KEY;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log('[3/3] Starting service...');
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(` Web UI will be at http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`);
|
|
160
|
+
console.log(' Press Ctrl+C to stop.');
|
|
161
|
+
console.log('');
|
|
162
|
+
|
|
163
|
+
const child = spawn(venvPython, ['server.py'], {
|
|
164
|
+
cwd: PKG_ROOT,
|
|
165
|
+
env,
|
|
166
|
+
stdio: 'inherit',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
child.on('exit', (code) => {
|
|
170
|
+
process.exit(code === null ? 0 : code);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const shutdown = (sig) => {
|
|
174
|
+
if (child.exitCode === null) child.kill(sig);
|
|
175
|
+
};
|
|
176
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
177
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
178
|
+
|
|
179
|
+
// ── auto-open browser ─────────────────────────────────────────────────────
|
|
180
|
+
if (openBrowser) {
|
|
181
|
+
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
try {
|
|
184
|
+
if (process.platform === 'darwin') execSync(`open "${url}"`);
|
|
185
|
+
else if (process.platform === 'win32') execSync(`start "" "${url}"`);
|
|
186
|
+
else execSync(`xdg-open "${url}"`);
|
|
187
|
+
} catch (_) {
|
|
188
|
+
/* browser open is best-effort */
|
|
189
|
+
}
|
|
190
|
+
}, 1500);
|
|
191
|
+
}
|
package/core/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""core — Agnes Video Generator v2.0 核心模块
|
|
2
|
+
|
|
3
|
+
导出所有子包的核心类和工具函数。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from core.api import AgnesImageAPI, AgnesVideoAPI, AgnesChatAPI
|
|
7
|
+
from core.audio import EdgeTTSEngine, SilentTTSEngine, SubtitleGenerator
|
|
8
|
+
from core.compositor import VideoConcatenator, VideoProcessor
|
|
9
|
+
from core.pipelines import (
|
|
10
|
+
BasePipeline,
|
|
11
|
+
PipelineShutdown,
|
|
12
|
+
SimpleVideoPipeline,
|
|
13
|
+
CreativeVideoPipeline,
|
|
14
|
+
ManuscriptVideoPipeline,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# API 层
|
|
19
|
+
"AgnesImageAPI",
|
|
20
|
+
"AgnesVideoAPI",
|
|
21
|
+
"AgnesChatAPI",
|
|
22
|
+
# 音频层
|
|
23
|
+
"EdgeTTSEngine",
|
|
24
|
+
"SilentTTSEngine",
|
|
25
|
+
"SubtitleGenerator",
|
|
26
|
+
# 拼接层
|
|
27
|
+
"VideoConcatenator",
|
|
28
|
+
"VideoProcessor",
|
|
29
|
+
# 流水线层
|
|
30
|
+
"BasePipeline",
|
|
31
|
+
"PipelineShutdown",
|
|
32
|
+
"SimpleVideoPipeline",
|
|
33
|
+
"CreativeVideoPipeline",
|
|
34
|
+
"ManuscriptVideoPipeline",
|
|
35
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""core.api — Agnes AI API 调用层"""
|
|
2
|
+
|
|
3
|
+
from core.api.agnes_image import AgnesImageAPI, ImageOutput
|
|
4
|
+
from core.api.agnes_video import AgnesVideoAPI, VideoOutput
|
|
5
|
+
from core.api.agnes_chat import AgnesChatAPI
|
|
6
|
+
|
|
7
|
+
__all__ = ["AgnesImageAPI", "ImageOutput", "AgnesVideoAPI", "VideoOutput", "AgnesChatAPI"]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""core.api.agnes_chat — Agnes Chat API 封装(从 core/screenwriter.py 提取)
|
|
2
|
+
|
|
3
|
+
P5: 健壮 JSON 解析(strip_code_fence + 正则提取 + 降级重试)
|
|
4
|
+
P11: chat/chat_multimodal 统一重试(5xx/超时/连接错 3 次指数退避,4xx 不重试)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import mimetypes
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from core.api.error_collector import collect_error, collect_error_from_exception
|
|
19
|
+
from core.api.rate_limiter import get_rate_limiter
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
BASE_URL = "https://apihub.agnes-ai.com/v1"
|
|
24
|
+
|
|
25
|
+
# 重试配置
|
|
26
|
+
_MAX_RETRIES = 3
|
|
27
|
+
_RETRY_BASE_DELAY = 15 # 秒,指数退避基数
|
|
28
|
+
|
|
29
|
+
# 正则:匹配首个 {…} 块(支持嵌套大括号)
|
|
30
|
+
_JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def strip_code_fence(text: str) -> str:
|
|
34
|
+
"""去除 LLM 响应中的代码围栏标记。
|
|
35
|
+
|
|
36
|
+
处理常见变体:```json ... ```、``` ... ```、前后有多余空白/换行。
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
text: LLM 原始响应文本。
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
去除围栏后的文本。
|
|
43
|
+
"""
|
|
44
|
+
text = text.strip()
|
|
45
|
+
# 去除首行 ``` 或 ```json 等
|
|
46
|
+
if text.startswith("```"):
|
|
47
|
+
first_newline = text.index("\n") if "\n" in text else len(text)
|
|
48
|
+
text = text[first_newline + 1:]
|
|
49
|
+
# 去除尾部 ```
|
|
50
|
+
if text.endswith("```"):
|
|
51
|
+
text = text[:-3]
|
|
52
|
+
return text.strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AgnesChatAPI:
|
|
56
|
+
"""Agnes LLM Chat API 封装(text + multimodal)。"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, api_key: str, model: str = "agnes-2.0-flash"):
|
|
59
|
+
self.api_key = api_key
|
|
60
|
+
self.model = model
|
|
61
|
+
self.headers = {
|
|
62
|
+
"Authorization": f"Bearer {api_key}",
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _image_to_b64_uri(self, path: str) -> str:
|
|
67
|
+
with open(path, "rb") as f:
|
|
68
|
+
b64 = base64.b64encode(f.read()).decode("utf-8")
|
|
69
|
+
mime = mimetypes.guess_type(path)[0] or "image/png"
|
|
70
|
+
return f"data:{mime};base64,{b64}"
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _should_retry(resp: requests.Response) -> bool:
|
|
74
|
+
"""判断 HTTP 响应是否应重试(5xx 和 429 重试,4xx 不重试)。"""
|
|
75
|
+
return resp.status_code >= 500 or resp.status_code == 429
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _extract_prompt_from_payload(payload: dict) -> str:
|
|
79
|
+
"""从 Chat payload 中提取 user prompt 文本(用于错误收集)。"""
|
|
80
|
+
messages = payload.get("messages", [])
|
|
81
|
+
for msg in reversed(messages):
|
|
82
|
+
content = msg.get("content", "")
|
|
83
|
+
if isinstance(content, list):
|
|
84
|
+
# 多模态:提取 text 部分
|
|
85
|
+
texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
|
|
86
|
+
if texts:
|
|
87
|
+
return texts[0]
|
|
88
|
+
elif isinstance(content, str) and content.strip():
|
|
89
|
+
return content
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
def _request_with_retry(self, payload: dict, timeout: int = 120) -> dict:
|
|
93
|
+
"""带重试的 API 请求。
|
|
94
|
+
|
|
95
|
+
对 5xx/429/超时/连接错误进行最多 3 次指数退避重试。
|
|
96
|
+
4xx 错误(非 429)直接抛出不重试。
|
|
97
|
+
每次请求前通过全局限速器控制调用频率。
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
payload: 请求 JSON body。
|
|
101
|
+
timeout: 请求超时秒数。
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
解析后的响应 JSON dict。
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
requests.HTTPError: 4xx 客户端错误或重试耗尽。
|
|
108
|
+
"""
|
|
109
|
+
last_exc = None
|
|
110
|
+
for attempt in range(_MAX_RETRIES):
|
|
111
|
+
try:
|
|
112
|
+
get_rate_limiter().acquire()
|
|
113
|
+
resp = requests.post(
|
|
114
|
+
f"{BASE_URL}/chat/completions",
|
|
115
|
+
headers=self.headers,
|
|
116
|
+
json=payload,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
)
|
|
119
|
+
if self._should_retry(resp) and attempt < _MAX_RETRIES - 1:
|
|
120
|
+
delay = _RETRY_BASE_DELAY * (attempt + 1)
|
|
121
|
+
logger.warning(
|
|
122
|
+
f"[AgnesChat] Server error {resp.status_code}, "
|
|
123
|
+
f"retry {attempt + 1}/{_MAX_RETRIES} in {delay}s..."
|
|
124
|
+
)
|
|
125
|
+
collect_error(
|
|
126
|
+
"chat", "chat",
|
|
127
|
+
prompt=self._extract_prompt_from_payload(payload),
|
|
128
|
+
error_type=f"HTTP{resp.status_code}",
|
|
129
|
+
error_message=f"HTTP {resp.status_code}: server error",
|
|
130
|
+
status_code=resp.status_code,
|
|
131
|
+
response_body=resp.text,
|
|
132
|
+
retry_count=attempt + 1,
|
|
133
|
+
)
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
continue
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
return resp.json()
|
|
138
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
|
139
|
+
last_exc = e
|
|
140
|
+
# 每次失败都记录(包括中间重试)
|
|
141
|
+
collect_error_from_exception(
|
|
142
|
+
"chat", "chat",
|
|
143
|
+
exc=e, prompt=self._extract_prompt_from_payload(payload),
|
|
144
|
+
retry_count=attempt + 1,
|
|
145
|
+
)
|
|
146
|
+
if attempt < _MAX_RETRIES - 1:
|
|
147
|
+
delay = _RETRY_BASE_DELAY * (attempt + 1)
|
|
148
|
+
logger.warning(
|
|
149
|
+
f"[AgnesChat] {type(e).__name__}, "
|
|
150
|
+
f"retry {attempt + 1}/{_MAX_RETRIES} in {delay}s..."
|
|
151
|
+
)
|
|
152
|
+
time.sleep(delay)
|
|
153
|
+
continue
|
|
154
|
+
raise
|
|
155
|
+
# 重试耗尽
|
|
156
|
+
if last_exc:
|
|
157
|
+
collect_error_from_exception(
|
|
158
|
+
"chat", "chat",
|
|
159
|
+
exc=last_exc, prompt=self._extract_prompt_from_payload(payload),
|
|
160
|
+
retry_count=_MAX_RETRIES,
|
|
161
|
+
)
|
|
162
|
+
raise last_exc
|
|
163
|
+
# 不可重试的 HTTP 错误(4xx 非 429)
|
|
164
|
+
try:
|
|
165
|
+
resp.raise_for_status() # type: ignore[possibly-undefined]
|
|
166
|
+
except requests.HTTPError as e:
|
|
167
|
+
collect_error(
|
|
168
|
+
"chat", "chat",
|
|
169
|
+
prompt=self._extract_prompt_from_payload(payload),
|
|
170
|
+
error_type=type(e).__name__,
|
|
171
|
+
error_message=str(e),
|
|
172
|
+
status_code=resp.status_code, # type: ignore[possibly-undefined]
|
|
173
|
+
response_body=resp.text[:5000], # type: ignore[possibly-undefined]
|
|
174
|
+
retry_count=_MAX_RETRIES,
|
|
175
|
+
)
|
|
176
|
+
raise
|
|
177
|
+
return resp.json() # type: ignore[possibly-undefined]
|
|
178
|
+
|
|
179
|
+
def chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> str:
|
|
180
|
+
"""纯文本 Chat 调用(含重试)。"""
|
|
181
|
+
logger.info(f"[AgnesChat] Calling chat ({self.model}), prompt: {len(user_prompt)} chars...")
|
|
182
|
+
data = self._request_with_retry(
|
|
183
|
+
{
|
|
184
|
+
"model": self.model,
|
|
185
|
+
"messages": [
|
|
186
|
+
{"role": "system", "content": system_prompt},
|
|
187
|
+
{"role": "user", "content": user_prompt},
|
|
188
|
+
],
|
|
189
|
+
"temperature": 0.7,
|
|
190
|
+
"max_tokens": max_tokens,
|
|
191
|
+
},
|
|
192
|
+
timeout=120,
|
|
193
|
+
)
|
|
194
|
+
return data["choices"][0]["message"]["content"]
|
|
195
|
+
|
|
196
|
+
def chat_json(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> dict:
|
|
197
|
+
"""Chat 调用并解析 JSON 响应(健壮版)。
|
|
198
|
+
|
|
199
|
+
处理流程:
|
|
200
|
+
1. 调用 chat 获取文本
|
|
201
|
+
2. strip_code_fence 去除围栏
|
|
202
|
+
3. 尝试直接 json.loads
|
|
203
|
+
4. 失败则用正则提取首个 {…} 块
|
|
204
|
+
5. 仍失败则重试一次 chat 调用
|
|
205
|
+
6. 最终失败抛出 ValueError
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
system_prompt: System 提示词。
|
|
209
|
+
user_prompt: User 提示词。
|
|
210
|
+
max_tokens: 最大生成 token 数。
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
解析后的 JSON dict。
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ValueError: JSON 解析最终失败。
|
|
217
|
+
"""
|
|
218
|
+
for retry in range(2):
|
|
219
|
+
content = self.chat(system_prompt, user_prompt, max_tokens=max_tokens)
|
|
220
|
+
# Step 1: 去围栏
|
|
221
|
+
cleaned = strip_code_fence(content)
|
|
222
|
+
# Step 2: 直接解析
|
|
223
|
+
try:
|
|
224
|
+
return json.loads(cleaned)
|
|
225
|
+
except (json.JSONDecodeError, ValueError):
|
|
226
|
+
pass
|
|
227
|
+
# Step 3: 正则提取首个 {…} 块
|
|
228
|
+
match = _JSON_BLOCK_RE.search(cleaned)
|
|
229
|
+
if match:
|
|
230
|
+
try:
|
|
231
|
+
return json.loads(match.group())
|
|
232
|
+
except (json.JSONDecodeError, ValueError):
|
|
233
|
+
pass
|
|
234
|
+
# Step 4: 首轮失败则重试一次 chat 调用
|
|
235
|
+
if retry == 0:
|
|
236
|
+
logger.warning("[AgnesChat] JSON parse failed, retrying chat call...")
|
|
237
|
+
continue
|
|
238
|
+
# 最终失败
|
|
239
|
+
preview = content[:200]
|
|
240
|
+
error_msg = (
|
|
241
|
+
f"[AgnesChat] Failed to parse JSON after 2 attempts. "
|
|
242
|
+
f"Response preview: {preview}..."
|
|
243
|
+
)
|
|
244
|
+
collect_error(
|
|
245
|
+
"chat", "chat_json",
|
|
246
|
+
prompt=user_prompt, system_prompt=system_prompt,
|
|
247
|
+
error_type="JSONParseError",
|
|
248
|
+
error_message=error_msg,
|
|
249
|
+
response_body=content[:5000],
|
|
250
|
+
retry_count=2,
|
|
251
|
+
)
|
|
252
|
+
raise ValueError(error_msg)
|
|
253
|
+
# 不应到达此处,但保险起见
|
|
254
|
+
raise ValueError("[AgnesChat] Unexpected flow in chat_json")
|
|
255
|
+
|
|
256
|
+
def chat_multimodal(
|
|
257
|
+
self,
|
|
258
|
+
system_prompt: str,
|
|
259
|
+
text_prompt: str,
|
|
260
|
+
image_paths: List[str],
|
|
261
|
+
max_tokens: int = 4096,
|
|
262
|
+
) -> str:
|
|
263
|
+
"""多模态 Chat 调用(文本 + 图片,含重试)。"""
|
|
264
|
+
messages = [{"role": "system", "content": system_prompt}]
|
|
265
|
+
|
|
266
|
+
user_content = [{"type": "text", "text": text_prompt}]
|
|
267
|
+
for img_path in image_paths:
|
|
268
|
+
if img_path.startswith(("http://", "https://")):
|
|
269
|
+
user_content.append({
|
|
270
|
+
"type": "image_url",
|
|
271
|
+
"image_url": {"url": img_path},
|
|
272
|
+
})
|
|
273
|
+
elif os.path.exists(img_path):
|
|
274
|
+
b64_uri = self._image_to_b64_uri(img_path)
|
|
275
|
+
user_content.append({
|
|
276
|
+
"type": "image_url",
|
|
277
|
+
"image_url": {"url": b64_uri},
|
|
278
|
+
})
|
|
279
|
+
messages.append({"role": "user", "content": user_content})
|
|
280
|
+
|
|
281
|
+
logger.info(
|
|
282
|
+
f"[AgnesChat] Calling multimodal ({self.model}), "
|
|
283
|
+
f"{len(image_paths)} image(s), prompt: {len(text_prompt)} chars..."
|
|
284
|
+
)
|
|
285
|
+
data = self._request_with_retry(
|
|
286
|
+
{
|
|
287
|
+
"model": self.model,
|
|
288
|
+
"messages": messages,
|
|
289
|
+
"temperature": 0.7,
|
|
290
|
+
"max_tokens": max_tokens,
|
|
291
|
+
},
|
|
292
|
+
timeout=300,
|
|
293
|
+
)
|
|
294
|
+
return data["choices"][0]["message"]["content"]
|