feat: AI 场景路由、ASR 服务及前后端全链路同步

- 新增 AI 场景路由控制器和管理接口
- 新增 ASR 语音识别服务及前后端集成
- 同步 AI Runtime 客户端到 Web/小程序/Life-Script
- 完善 AI 配置测试修复和管理后台路由配置
- 新增数据库迁移脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 13:25:21 +08:00
parent d77090aa5e
commit 89fc42819d
72 changed files with 4584 additions and 383 deletions
+218 -137
View File
@@ -1,239 +1,320 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
life-script 部署脚本
功能:项目构建、文件传输、原子切换、历史版本管理、回滚支持
使用系统自带的ssh/scp命令,无需额外依赖
Life-Script deployment script.
Builds the local project, uploads dist files to the server, switches the live
symlink atomically, keeps recent releases, and supports rollback.
"""
import io
import os
import sys
import subprocess
import shlex
import shutil
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
# 服务器配置
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True)
SERVER_IP = "101.200.208.45"
USERNAME = "root"
# 部署配置
APP_NAME = "life-script"
DEPLOY_BASE = "/data/www/course-web-deploy"
RELEASES_DIR = f"{DEPLOY_BASE}/releases"
LINK_PATH = "/data/www/course-of-life"
MAX_RELEASES = 5
# 本地路径
SCRIPT_DIR = Path(__file__).parent.absolute()
DIST_DIR = SCRIPT_DIR / "dist"
SSH_BASE = [
"ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"StrictHostKeyChecking=no",
f"{USERNAME}@{SERVER_IP}",
]
SCP_BASE = [
"scp",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"StrictHostKeyChecking=no",
]
class Colors:
"""终端颜色"""
GREEN = '\033[32m'
RED = '\033[31m'
YELLOW = '\033[33m'
RESET = '\033[0m'
GREEN = "\033[32m"
RED = "\033[31m"
YELLOW = "\033[33m"
RESET = "\033[0m"
def log_info(msg):
"""打印信息日志"""
print(f"{Colors.GREEN}[INFO]{Colors.RESET} {msg}")
print(f"{Colors.GREEN}[INFO]{Colors.RESET} {msg}", flush=True)
def log_error(msg):
"""打印错误日志"""
print(f"{Colors.RED}[ERROR]{Colors.RESET} {msg}")
print(f"{Colors.RED}[ERROR]{Colors.RESET} {msg}", flush=True)
def log_warn(msg):
"""打印警告日志"""
print(f"{Colors.YELLOW}[WARN]{Colors.RESET} {msg}")
print(f"{Colors.YELLOW}[WARN]{Colors.RESET} {msg}", flush=True)
def run_command(cmd, cwd=None, shell=True, capture=True):
"""执行本地命令"""
def _reader_thread(stream, sink, prefix=""):
for line in iter(stream.readline, ""):
sink.append(line)
print(f"{prefix}{line}", end="", flush=True)
stream.close()
def run_stream(args, cwd=None, timeout=None):
"""Run a command and stream stdout/stderr in real time."""
stdout_lines = []
stderr_lines = []
try:
if capture:
result = subprocess.run(
cmd,
cwd=cwd,
shell=shell,
capture_output=True,
text=True
)
return result.returncode == 0, result.stdout, result.stderr
else:
result = subprocess.run(cmd, cwd=cwd, shell=shell)
return result.returncode == 0, "", ""
except Exception as e:
return False, "", str(e)
process = subprocess.Popen(
args,
cwd=cwd,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
stdout_thread = threading.Thread(target=_reader_thread, args=(process.stdout, stdout_lines))
stderr_thread = threading.Thread(target=_reader_thread, args=(process.stderr, stderr_lines))
stdout_thread.start()
stderr_thread.start()
try:
returncode = process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill()
returncode = process.wait()
log_error(f"命令执行超时 ({timeout}s): {' '.join(map(str, args))}")
return False, "".join(stdout_lines), "".join(stderr_lines) or "命令执行超时"
finally:
stdout_thread.join(timeout=2)
stderr_thread.join(timeout=2)
return returncode == 0, "".join(stdout_lines), "".join(stderr_lines)
except Exception as exc:
return False, "".join(stdout_lines), str(exc)
def exec_ssh_cmd(cmd, timeout=30):
"""通过SSH执行远程命令"""
ssh_cmd = f'ssh -o ConnectTimeout=10 -o BatchMode=yes {USERNAME}@{SERVER_IP} "{cmd}"'
def run_capture(args, cwd=None, timeout=None):
"""Run a command and capture output for decision making."""
try:
result = subprocess.run(
ssh_cmd,
shell=True,
args,
cwd=cwd,
shell=False,
capture_output=True,
text=True,
timeout=timeout
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
log_error(f"SSH命令超时: {cmd}")
return False, "", "命令执行超时"
except Exception as e:
return False, "", str(e)
return False, "", f"命令执行超时 ({timeout}s): {' '.join(map(str, args))}"
except Exception as exc:
return False, "", str(exc)
def run_command(cmd, cwd=None, capture=True, timeout=None):
"""Run a local shell command."""
args = cmd if isinstance(cmd, list) else ["cmd", "/c", cmd] if os.name == "nt" else ["bash", "-lc", cmd]
if capture:
return run_capture(args, cwd=cwd, timeout=timeout)
return run_stream(args, cwd=cwd, timeout=timeout)
def ssh_args(cmd):
return SSH_BASE + ["bash", "-lc", shlex.quote(cmd)]
def exec_ssh_cmd(cmd, timeout=120, capture=False):
"""Run a remote command through SSH. Streams output by default."""
args = ssh_args(cmd)
if capture:
ok, stdout, stderr = run_capture(args, cwd=SCRIPT_DIR, timeout=timeout)
else:
ok, stdout, stderr = run_stream(args, cwd=SCRIPT_DIR, timeout=timeout)
if not ok:
log_error(f"SSH命令失败: {cmd}")
if stderr:
log_error(stderr.strip())
return ok, stdout.strip(), stderr.strip()
def scp_upload(local_path, remote_path, recursive=False):
"""通过SCP上传文件或目录"""
r_flag = "-r" if recursive else ""
scp_cmd = f'scp -o ConnectTimeout=10 -o BatchMode=yes {r_flag} "{local_path}" {USERNAME}@{SERVER_IP}:{remote_path}'
try:
result = subprocess.run(
scp_cmd,
shell=True,
capture_output=True,
text=True,
timeout=120 # 上传文件给更长时间
)
if result.returncode != 0:
log_error(f"SCP上传失败: {result.stderr}")
return False
return True
except subprocess.TimeoutExpired:
log_error("SCP上传超时")
return False
except Exception as e:
log_error(f"SCP上传异常: {e}")
return False
"""Upload a file or directory through SCP with streamed logs."""
args = SCP_BASE.copy()
if recursive:
args.append("-r")
args.extend([str(local_path), f"{USERNAME}@{SERVER_IP}:{remote_path}"])
ok, _, stderr = run_stream(args, cwd=SCRIPT_DIR, timeout=300)
if not ok:
log_error(f"SCP上传失败: {stderr.strip()}")
return ok
def check_env():
"""检查本地环境"""
log_info("检查本地环境...")
# 检查npm
success, _, _ = run_command("npm --version")
success, _, err = run_command("npm --version", timeout=30)
if not success:
log_error("未找到npm命令,请先安装Node.js")
log_error("未找到 npm 命令,请先安装 Node.js")
if err:
log_error(err)
sys.exit(1)
log_info("环境检查通过")
def build_project():
"""构建项目"""
log_info("开始构建项目...")
# 切换到项目目录
os.chdir(SCRIPT_DIR)
# 清理旧构建
if DIST_DIR.exists():
shutil.rmtree(DIST_DIR)
log_info("已清理旧的dist目录")
# 执行构建(不捕获输出,直接显示)
log_info("已清理旧的 dist 目录")
log_info("执行: npm run build")
success, _, _ = run_command("npm run build", capture=False)
success, _, err = run_command("npm run build", cwd=SCRIPT_DIR, capture=False, timeout=600)
if not success:
log_error("项目构建失败")
if err:
log_error(err)
sys.exit(1)
log_info("项目构建成功")
# 检查dist目录
if not DIST_DIR.exists():
log_error("dist目录不存在")
log_error("dist 目录不存在")
sys.exit(1)
log_info("项目构建成功")
def deploy():
"""部署到服务器"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
release_path = f"{RELEASES_DIR}/{timestamp}"
log_info(f"准备部署版本: {timestamp}")
# 1. 创建远程目录
log_info("创建远程目录...")
success, _, err = exec_ssh_cmd(f"mkdir -p {release_path}")
success, _, err = exec_ssh_cmd(f"mkdir -p {shlex.quote(release_path)}", timeout=60)
if not success:
log_error(f"创建远程目录失败: {err}")
sys.exit(1)
# 2. 上传文件
log_info("上传文件到服务器...")
for item in DIST_DIR.iterdir():
log_info(f" 上传: {item.name}")
if item.is_file():
if not scp_upload(item, f"{release_path}/"):
log_error("文件上传失败")
sys.exit(1)
ok = scp_upload(item, f"{release_path}/")
else:
if not scp_upload(item, f"{release_path}/", recursive=True):
log_error("目录上传失败")
sys.exit(1)
ok = scp_upload(item, f"{release_path}/", recursive=True)
if not ok:
log_error("文件上传失败")
sys.exit(1)
log_info("文件上传成功")
# 3. 设置权限
log_info("设置文件权限...")
exec_ssh_cmd(f"chmod -R 755 {release_path}")
# 4. 原子切换软链接
success, _, err = exec_ssh_cmd(f"chmod -R 755 {shlex.quote(release_path)}", timeout=120)
if not success:
log_error(f"设置文件权限失败: {err}")
sys.exit(1)
log_info("切换服务版本...")
# 检查目标路径是否为普通目录
exec_ssh_cmd(f"if [ -d '{LINK_PATH}' ] && [ ! -L '{LINK_PATH}' ]; then mv '{LINK_PATH}' '{LINK_PATH}_backup_$(date +%s)'; fi")
# 创建软链接
success, _, err = exec_ssh_cmd(f"ln -snf '{release_path}' '{LINK_PATH}'")
switch_script = f"""
set -e
release_path={shlex.quote(release_path)}
link_path={shlex.quote(LINK_PATH)}
if [ -d "$link_path" ] && [ ! -L "$link_path" ]; then
backup_path="${{link_path}}_backup_$(date +%s)"
echo "当前路径是普通目录,备份到: $backup_path"
mv "$link_path" "$backup_path"
fi
ln -sfn "$release_path" "$link_path"
echo "当前版本指向: $(readlink "$link_path")"
"""
success, _, err = exec_ssh_cmd(switch_script, timeout=120)
if not success:
log_error(f"切换版本失败: {err}")
sys.exit(1)
log_info(f"部署完成当前版本指向: {release_path}")
# 5. 清理旧版本
log_info(f"部署完成当前版本指向: {release_path}")
clean_old_releases()
def clean_old_releases():
"""清理旧版本,只保留最近的N个"""
log_info(f"清理旧版本(保留最近{MAX_RELEASES}个)...")
clean_cmd = f"cd {RELEASES_DIR} && ls -t | tail -n +{MAX_RELEASES + 1} | xargs -I {{}} rm -rf {{}}"
exec_ssh_cmd(clean_cmd)
log_info(f"清理旧版本保留最近 {MAX_RELEASES} 个)...")
clean_script = f"""
set -e
mkdir -p {shlex.quote(RELEASES_DIR)}
cd {shlex.quote(RELEASES_DIR)}
old_releases=$(ls -1dt */ 2>/dev/null | tail -n +{MAX_RELEASES + 1} || true)
if [ -n "$old_releases" ]; then
echo "$old_releases" | xargs -r rm -rf
echo "$old_releases" | sed 's/^/已删除旧版本: /'
else
echo "没有需要清理的旧版本"
fi
"""
success, _, err = exec_ssh_cmd(clean_script, timeout=120)
if not success:
log_warn(f"清理旧版本失败: {err}")
def rollback():
"""回滚到上一个版本"""
log_info("开始回滚操作...")
# 获取当前指向的版本
success, current_link, _ = exec_ssh_cmd(f"readlink {LINK_PATH}")
log_info(f"当前版本: {current_link}")
# 获取上一个版本目录
success, prev_version, _ = exec_ssh_cmd(
f"ls -dt {RELEASES_DIR}/* | grep -v '{current_link}' | head -n 1"
)
if not prev_version:
log_error("没有找到可回滚的历史版本")
success, current_link, err = exec_ssh_cmd(f"readlink {shlex.quote(LINK_PATH)}", timeout=60, capture=True)
if not success:
log_error(f"读取当前版本失败: {err}")
sys.exit(1)
log_info(f"当前版本: {current_link}")
find_prev_script = f"""
set -e
current_link={shlex.quote(current_link)}
ls -dt {shlex.quote(RELEASES_DIR)}/* 2>/dev/null | grep -v "$current_link" | head -n 1
"""
success, prev_version, err = exec_ssh_cmd(find_prev_script, timeout=60, capture=True)
if not success or not prev_version:
log_error("没有找到可回滚的历史版本")
if err:
log_error(err)
sys.exit(1)
log_info(f"回滚目标版本: {prev_version}")
exec_ssh_cmd(f"ln -snf {prev_version} {LINK_PATH}")
log_info("回滚成功!")
success, _, err = exec_ssh_cmd(
f"ln -sfn {shlex.quote(prev_version)} {shlex.quote(LINK_PATH)} && readlink {shlex.quote(LINK_PATH)}",
timeout=60,
)
if not success:
log_error(f"回滚失败: {err}")
sys.exit(1)
log_info("回滚成功")
def print_usage():
"""打印使用说明"""
print("""
print(
"""
用法: python deploy.py [命令]
命令:
@@ -243,13 +324,13 @@ def print_usage():
示例:
python deploy.py # 部署
python deploy.py rollback # 回滚
""")
"""
)
def main():
"""主函数"""
command = sys.argv[1] if len(sys.argv) > 1 else "deploy"
if command == "rollback":
rollback()
elif command == "deploy":
+35 -68
View File
@@ -1,78 +1,45 @@
/**
* AI 服务模块
* 封装 OpenRouter API 调用
*/
import { streamAiScene } from './aiRuntime';
const API_KEY = "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";
const BASE_URL = "https://openrouter.ai/api/v1/chat/completions";
/**
* 调用 AI API
* @param {string} prompt - 用户提示
* @param {string} systemMsg - 系统消息
* @returns {Promise<string>} AI 响应内容
*/
const fetchAI = async (prompt, systemMsg) => {
try {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: "deepseek/deepseek-chat-v3-0324:free",
messages: [
{ role: "system", content: systemMsg },
{ role: "user", content: prompt }
]
})
});
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
console.error('AI API Error:', error);
return "(AI 暂时陷入了沉思,请稍后再试)";
}
const runScene = async (sceneCode, inputs, callbacks = {}) => {
const result = await streamAiScene({
sceneCode,
inputs,
onStart: callbacks.onStart,
onDelta: callbacks.onDelta,
onDone: callbacks.onDone,
onError: callbacks.onError
});
return result.output;
};
/**
* 分析生命事件
* @param {Object} event - 事件对象 { title, time, content }
* @returns {Promise<string>} AI 分析反馈
*/
export const analyzeLifeEvent = async (event) => {
const system = "你是一位温柔的生命引路人,擅长从平凡事件中发掘成长的力量。请分析用户记录的事件,提供情感价值、成长总结和疗愈鼓励。保持字数在150字左右。";
const prompt = `事件标题:${event.title}\n时间:${event.time}\n内容:${event.content}`;
return fetchAI(prompt, system);
export const analyzeLifeEvent = async (event, callbacks = {}) => {
return runScene('life_healing', {
mode: 'life_event_analysis',
prompt: `请分析这段人生事件,并给出温和、具体、可执行的反馈。\n标题:${event.title || ''}\n时间:${event.time || ''}\n内容:${event.content || ''}`,
title: event.title,
time: event.time,
content: event.content
}, callbacks);
};
/**
* 生成爽文剧本
* @param {Object} params - 参数对象 { theme, style, length, character }
* @param {Array} events - 生命事件数组
* @returns {Promise<string>} 生成的剧本内容
*/
export const generateEpicScript = async (params, events = []) => {
const system = `你是一位金牌爽文编剧。根据用户的角色设定和过往经历,生成一段符合用户设定、充满爽感的未来人生剧本。剧本必须包含起承转合,使用【标题】标记段落。`;
const charInfo = `姓名:${params.character.nickname}, 性格:${params.character.mbti}, 兴趣:${params.character.hobbies?.join(',') || ''}, 星座:${params.character.zodiac}`;
const eventSummary = events.map(e => e.title).join(', ');
const prompt = `角色信息:${charInfo}\n过往经历关键词:${eventSummary}\n用户指定主题:${params.theme}\n指定风格:${params.style}\n篇幅要求:${params.length}\n\n请以此创作一段热血、精彩的人生剧本。`;
return fetchAI(prompt, system);
export const generateEpicScript = async (params, events = [], callbacks = {}) => {
return runScene('script_generate', {
prompt: params.theme,
theme: params.theme,
style: params.style,
length: params.length,
events,
character: params.character,
useSocialInsights: params.useSocialInsights
}, callbacks);
};
/**
* 生成实现路径
* @param {string} script - 剧本内容
* @returns {Promise<string>} 生成的路径内容
*/
export const generatePath = async (script) => {
const system = "你是一位人生规划导师。请将用户生成的剧本拆解为现实中可操作的路径。使用【阶段名称】加上具体建议。务必客观、可执行。";
return fetchAI(script, system);
export const generatePath = async (script, callbacks = {}) => {
return runScene('life_healing', {
mode: 'path_generate',
prompt: `请把下面的人生剧本拆解成现实中可执行的路径,按阶段输出。\n\n${script || ''}`,
script
}, callbacks);
};
export default {
+81
View File
@@ -0,0 +1,81 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
const parseSseFrame = (frame) => {
const event = { type: 'message', data: '' };
frame.split(/\r?\n/).forEach((line) => {
if (line.startsWith('event:')) event.type = line.slice(6).trim();
if (line.startsWith('data:')) event.data += line.slice(5).trim();
});
if (!event.data) return null;
try {
return JSON.parse(event.data);
} catch {
return { type: event.type, content: event.data };
}
};
export const streamAiScene = async ({
sceneCode,
inputs = {},
onStart,
onDelta,
onDone,
onError
}) => {
const token = localStorage.getItem('access_token');
const response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ sceneCode, inputs })
});
if (!response.ok || !response.body) {
const message = `AI流式请求失败(${response.status})`;
onError?.(message);
throw new Error(message);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let output = '';
const consumeText = (text) => {
buffer += text;
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || '';
frames.forEach((frame) => {
const event = parseSseFrame(frame);
if (!event) return;
if (event.type === 'start') {
onStart?.(event);
} else if (event.type === 'delta') {
const delta = event.content || '';
output += delta;
onDelta?.(delta, output, event);
} else if (event.type === 'done') {
onDone?.(event, output);
} else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败';
onError?.(message, event);
throw new Error(message);
}
});
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
consumeText(decoder.decode(value, { stream: true }));
}
consumeText(decoder.decode());
if (buffer.trim()) consumeText('\n\n');
return { output };
};
export default {
streamAiScene
};
+8 -2
View File
@@ -16,6 +16,7 @@ const PathView = ({ onGoToScript }) => {
const { getSelectedScript, selectedPath, setPath, loadPath, deletePath, selectedScriptId } = useStore();
const [isLoading, setIsLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [streamPath, setStreamPath] = useState('');
const selectedScript = getSelectedScript();
@@ -37,8 +38,12 @@ const PathView = ({ onGoToScript }) => {
setIsLoading(true);
try {
const path = await generatePath(selectedScript.content);
setStreamPath('');
const path = await generatePath(selectedScript.content, {
onDelta: (_delta, output) => setStreamPath(output)
});
await setPath(path, selectedScriptId);
setStreamPath('');
} catch (error) {
console.error('Failed to generate path:', error);
} finally {
@@ -77,7 +82,8 @@ const PathView = ({ onGoToScript }) => {
});
};
const pathSteps = parsePathSteps(selectedPath);
const visiblePath = streamPath || selectedPath;
const pathSteps = parsePathSteps(visiblePath);
// 无剧本时显示提示
if (!selectedScript) {
+35 -3
View File
@@ -4,6 +4,7 @@ import { GlassCard, GlassButton, GlassInput, GlassSelect } from '../components/u
import Modal from '../components/Modal';
import useStore from '../store/useStore';
import { scriptStyles, scriptLengths } from '../utils/constants';
import { generateEpicScript } from '../services/ai';
/**
* ScriptView 组件
@@ -37,6 +38,7 @@ const ScriptView = ({ onOpenProfile }) => {
const [style, setStyle] = useState(scriptStyles[0].value);
const [length, setLength] = useState(scriptLengths[0].value);
const [isLoading, setIsLoading] = useState(false);
const [streamContent, setStreamContent] = useState('');
// 编辑模态框状态
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@@ -62,16 +64,23 @@ const ScriptView = ({ onOpenProfile }) => {
setIsLoading(true);
try {
// 直接调用后端创建接口,由后端调用AI生成
setStreamContent('');
const content = await generateEpicScript(
{ theme, style, length, character: registrationData },
lifeEvents,
{ onDelta: (_delta, output) => setStreamContent(output) }
);
await addScript({
theme,
style,
length,
content,
character: registrationData,
events: lifeEvents
});
setTheme('');
setStreamContent('');
} catch (error) {
console.error('Failed to generate script:', error);
} finally {
@@ -114,16 +123,24 @@ const ScriptView = ({ onOpenProfile }) => {
setIsLoading(true);
try {
setStreamContent('');
const content = await generateEpicScript(
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
lifeEvents,
{ onDelta: (_delta, output) => setStreamContent(output) }
);
await updateScript({
id: editingScript.id,
theme: editForm.theme,
style: editForm.style,
length: editForm.length,
content,
character: registrationData,
events: lifeEvents,
regenerateContent: true // 标记需要重新生成AI内容
regenerateContent: false
});
closeEditModal();
setStreamContent('');
} catch (error) {
console.error('Failed to update script:', error);
} finally {
@@ -289,7 +306,22 @@ const ScriptView = ({ onOpenProfile }) => {
{/* 右侧剧本展示区 */}
<div className="lg:col-span-8">
<div className="h-full">
{selectedScript ? (
{isLoading && streamContent ? (
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
<div className="prose prose-invert max-w-none">
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
<div>
<h4 className="text-2xl font-serif text-orange-200">{theme}</h4>
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">正在生成</p>
</div>
<BookOpen className="text-white/20" />
</div>
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm">
{streamContent}
</div>
</div>
</GlassCard>
) : selectedScript ? (
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in relative group" padding="lg">
{/* 编辑按钮 */}
<button
+23 -2
View File
@@ -3,6 +3,7 @@ import { Plus, Wind, Sparkles, Pencil, Trash2 } from 'lucide-react';
import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components/ui';
import Modal from '../components/Modal';
import useStore from '../store/useStore';
import { analyzeLifeEvent } from '../services/ai';
/**
* 格式化 AI 反馈内容的组件
@@ -91,6 +92,7 @@ const TimelineView = () => {
// 模态框状态
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [streamFeedback, setStreamFeedback] = useState('');
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
const [editingEventId, setEditingEventId] = useState(null);
@@ -111,6 +113,7 @@ const TimelineView = () => {
const openAddModal = () => {
setEditingEventId(null);
setEventForm({ title: '', time: '', content: '' });
setStreamFeedback('');
setIsModalOpen(true);
};
@@ -125,6 +128,7 @@ const TimelineView = () => {
time: event.time || '',
content: event.content || ''
});
setStreamFeedback(event.aiFeedback || '');
setIsModalOpen(true);
};
@@ -135,6 +139,7 @@ const TimelineView = () => {
setIsModalOpen(false);
setEditingEventId(null);
setEventForm({ title: '', time: '', content: '' });
setStreamFeedback('');
};
/**
@@ -149,16 +154,23 @@ const TimelineView = () => {
setIsLoading(true);
try {
setStreamFeedback('');
const aiFeedback = await analyzeLifeEvent(eventForm, {
onDelta: (_delta, output) => setStreamFeedback(output)
});
if (editingEventId) {
// 编辑模式:调用更新接口
await updateLifeEvent({
id: editingEventId,
...eventForm
...eventForm,
aiFeedback
});
} else {
// 新增模式:调用添加接口
await addLifeEvent({
...eventForm
...eventForm,
aiFeedback
});
}
@@ -311,6 +323,15 @@ const TimelineView = () => {
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
rows={5}
/>
{streamFeedback && (
<div className="ai-glow-card p-4 rounded-2xl bg-orange-200/[0.02] border border-orange-200/5">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-3 h-3 text-orange-200" />
<span className="text-[9px] uppercase tracking-[0.2em] text-orange-200/60 font-bold">实时疗愈反馈</span>
</div>
<p className="text-xs italic text-white/50 leading-loose whitespace-pre-wrap">{streamFeedback}</p>
</div>
)}
<GlassButton
variant="primary"
onClick={handleSubmit}