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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user