feat: AI 打字机流式输出、小程序脚本主页布局及灵感卡片优化
- life-script: 新增 aiRuntime 打字机流式服务,PathView/ScriptView/TimelineView 接入打字机效果 - mini-program: ScriptView 重构为打字机输出 + 卡片式灵感列表,主页布局优化 - web: aiRuntime 服务新增流式输出支持 - chat store: 消息状态管理和打字机流式渲染支持 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
const createRequestId = () => `life-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const parseSseFrame = (frame) => {
|
const parseSseFrame = (frame) => {
|
||||||
const event = { type: 'message', data: '' };
|
const event = { type: 'message', data: '' };
|
||||||
frame.split(/\r?\n/).forEach((line) => {
|
frame.split(/\r?\n/).forEach((line) => {
|
||||||
@@ -14,6 +18,46 @@ const parseSseFrame = (frame) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const authHeaders = () => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryRuntimeResult = async (requestId) => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/ai/runtime/result?requestId=${encodeURIComponent(requestId)}`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (response.ok && payload?.code === 200 && payload.data) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
throw new Error(payload?.message || 'AI 结果还在生成中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoverRuntimeOutput = async (requestId, { onDelta, onDone }) => {
|
||||||
|
const maxAttempts = 150;
|
||||||
|
for (let index = 0; index < maxAttempts; index++) {
|
||||||
|
try {
|
||||||
|
const log = await queryRuntimeResult(requestId);
|
||||||
|
const output = String(log.outputText || '').trim();
|
||||||
|
if (output) {
|
||||||
|
onDelta?.(output, output, { type: 'delta', metadata: { recovered: true, requestId } });
|
||||||
|
onDone?.({ type: 'done', metadata: { recovered: true, requestId, source: 'call-log' } }, output);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
if (log.status === 'failed') {
|
||||||
|
throw new Error(log.errorMessage || 'AI 生成失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (index === maxAttempts - 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
throw new Error('AI 生成结果暂时没有返回');
|
||||||
|
};
|
||||||
|
|
||||||
export const streamAiScene = async ({
|
export const streamAiScene = async ({
|
||||||
sceneCode,
|
sceneCode,
|
||||||
inputs = {},
|
inputs = {},
|
||||||
@@ -22,26 +66,79 @@ export const streamAiScene = async ({
|
|||||||
onDone,
|
onDone,
|
||||||
onError
|
onError
|
||||||
}) => {
|
}) => {
|
||||||
const token = localStorage.getItem('access_token');
|
const requestId = String(inputs.requestId || createRequestId());
|
||||||
const response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
|
const requestInputs = { ...inputs, requestId };
|
||||||
method: 'POST',
|
const controller = new AbortController();
|
||||||
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 buffer = '';
|
||||||
let output = '';
|
let output = '';
|
||||||
|
let closed = false;
|
||||||
|
let recovered = false;
|
||||||
|
let recoveryTimer;
|
||||||
|
let recoveryPromise;
|
||||||
|
|
||||||
|
const clearRecoveryTimer = () => {
|
||||||
|
if (recoveryTimer) {
|
||||||
|
clearTimeout(recoveryTimer);
|
||||||
|
recoveryTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoverOnce = () => {
|
||||||
|
if (!recoveryPromise) {
|
||||||
|
recoveryPromise = recoverRuntimeOutput(requestId, { onDelta, onDone });
|
||||||
|
}
|
||||||
|
return recoveryPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeFromRecoveredOutput = async () => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
const recoveredOutput = await recoverOnce();
|
||||||
|
if (closed) return;
|
||||||
|
output = recoveredOutput;
|
||||||
|
recovered = true;
|
||||||
|
closed = true;
|
||||||
|
clearRecoveryTimer();
|
||||||
|
controller.abort();
|
||||||
|
} catch {
|
||||||
|
if (!closed) recoveryPromise = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recoveryTimer = setTimeout(() => {
|
||||||
|
completeFromRecoveredOutput();
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
const finishRecovered = (event, message) => {
|
||||||
|
if (!output.trim()) return false;
|
||||||
|
recovered = true;
|
||||||
|
closed = true;
|
||||||
|
clearRecoveryTimer();
|
||||||
|
onDone?.({
|
||||||
|
type: 'done',
|
||||||
|
metadata: {
|
||||||
|
...(event?.metadata || {}),
|
||||||
|
recovered: true,
|
||||||
|
warningCode: event?.code,
|
||||||
|
warningMessage: message || event?.message
|
||||||
|
}
|
||||||
|
}, output);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoverOrThrow = async (message, event) => {
|
||||||
|
if (finishRecovered(event, message)) return;
|
||||||
|
try {
|
||||||
|
output = await recoverOnce();
|
||||||
|
recovered = true;
|
||||||
|
closed = true;
|
||||||
|
clearRecoveryTimer();
|
||||||
|
} catch (error) {
|
||||||
|
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回';
|
||||||
|
onError?.(finalMessage, event);
|
||||||
|
throw new Error(finalMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const consumeText = (text) => {
|
const consumeText = (text) => {
|
||||||
buffer += text;
|
buffer += text;
|
||||||
@@ -57,22 +154,61 @@ export const streamAiScene = async ({
|
|||||||
output += delta;
|
output += delta;
|
||||||
onDelta?.(delta, output, event);
|
onDelta?.(delta, output, event);
|
||||||
} else if (event.type === 'done') {
|
} else if (event.type === 'done') {
|
||||||
|
closed = true;
|
||||||
|
clearRecoveryTimer();
|
||||||
onDone?.(event, output);
|
onDone?.(event, output);
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
const message = event.message || event.code || 'AI流式请求失败';
|
throw Object.assign(new Error(event.message || event.code || 'AI 流式请求失败'), { event });
|
||||||
onError?.(message, event);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
while (true) {
|
let response;
|
||||||
const { value, done } = await reader.read();
|
try {
|
||||||
if (done) break;
|
response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
|
||||||
consumeText(decoder.decode(value, { stream: true }));
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sceneCode, requestId, inputs: requestInputs }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!closed) await recoverOrThrow(error?.message);
|
||||||
|
return { output };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
await recoverOrThrow(`AI 流式请求失败(${response.status})`);
|
||||||
|
return { output };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
consumeText(decoder.decode(value, { stream: true }));
|
||||||
|
if (closed || recovered) break;
|
||||||
|
}
|
||||||
|
if (!closed && !recovered) {
|
||||||
|
consumeText(decoder.decode());
|
||||||
|
if (buffer.trim()) consumeText('\n\n');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!closed) {
|
||||||
|
await recoverOrThrow(error?.message, error?.event);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearRecoveryTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!output.trim() && !closed) {
|
||||||
|
await recoverOrThrow('AI 生成结果暂时没有返回');
|
||||||
}
|
}
|
||||||
consumeText(decoder.decode());
|
|
||||||
if (buffer.trim()) consumeText('\n\n');
|
|
||||||
return { output };
|
return { output };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { GlassCard, GlassButton } from '../components/ui';
|
|||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import useStore from '../store/useStore';
|
import useStore from '../store/useStore';
|
||||||
import { generatePath } from '../services/ai';
|
import { generatePath } from '../services/ai';
|
||||||
|
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PathView 组件
|
* PathView 组件
|
||||||
@@ -17,6 +18,7 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [streamPath, setStreamPath] = useState('');
|
const [streamPath, setStreamPath] = useState('');
|
||||||
|
const pathWriter = useTypewriterStream({ interval: 18, step: 1 });
|
||||||
|
|
||||||
const selectedScript = getSelectedScript();
|
const selectedScript = getSelectedScript();
|
||||||
|
|
||||||
@@ -36,15 +38,22 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
if (!selectedScript) return;
|
if (!selectedScript) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
pathWriter.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStreamPath('');
|
setStreamPath('');
|
||||||
const path = await generatePath(selectedScript.content, {
|
const path = await generatePath(selectedScript.content, {
|
||||||
onDelta: (_delta, output) => setStreamPath(output)
|
onDelta: (_delta, output) => {
|
||||||
|
setStreamPath(output);
|
||||||
|
pathWriter.push(output);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
pathWriter.finish(path);
|
||||||
|
await pathWriter.waitForDone();
|
||||||
await setPath(path, selectedScriptId);
|
await setPath(path, selectedScriptId);
|
||||||
setStreamPath('');
|
setStreamPath('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
pathWriter.fail('路径生成失败,请稍后重试');
|
||||||
console.error('Failed to generate path:', error);
|
console.error('Failed to generate path:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -82,7 +91,7 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const visiblePath = streamPath || selectedPath;
|
const visiblePath = isLoading ? pathWriter.visibleText : selectedPath;
|
||||||
const pathSteps = parsePathSteps(visiblePath);
|
const pathSteps = parsePathSteps(visiblePath);
|
||||||
|
|
||||||
// 无剧本时显示提示
|
// 无剧本时显示提示
|
||||||
@@ -164,7 +173,9 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="py-20 text-center text-white/20 italic font-serif">
|
<div className="py-20 text-center text-white/20 italic font-serif">
|
||||||
等待开启人生导航...
|
{isLoading
|
||||||
|
? (pathWriter.isWaiting ? '正在分析剧本,拆解路径...' : '正在逐字生成路径...')
|
||||||
|
: '等待开启人生导航...'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Modal from '../components/Modal';
|
|||||||
import useStore from '../store/useStore';
|
import useStore from '../store/useStore';
|
||||||
import { scriptStyles, scriptLengths } from '../utils/constants';
|
import { scriptStyles, scriptLengths } from '../utils/constants';
|
||||||
import { generateEpicScript } from '../services/ai';
|
import { generateEpicScript } from '../services/ai';
|
||||||
|
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScriptView 组件
|
* ScriptView 组件
|
||||||
@@ -39,6 +40,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
const [length, setLength] = useState(scriptLengths[0].value);
|
const [length, setLength] = useState(scriptLengths[0].value);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [streamContent, setStreamContent] = useState('');
|
const [streamContent, setStreamContent] = useState('');
|
||||||
|
const scriptWriter = useTypewriterStream({ interval: 18, step: 1 });
|
||||||
|
|
||||||
// 编辑模态框状态
|
// 编辑模态框状态
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
@@ -62,14 +64,22 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
scriptWriter.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStreamContent('');
|
setStreamContent('');
|
||||||
const content = await generateEpicScript(
|
const content = await generateEpicScript(
|
||||||
{ theme, style, length, character: registrationData },
|
{ theme, style, length, character: registrationData },
|
||||||
lifeEvents,
|
lifeEvents,
|
||||||
{ onDelta: (_delta, output) => setStreamContent(output) }
|
{
|
||||||
|
onDelta: (_delta, output) => {
|
||||||
|
setStreamContent(output);
|
||||||
|
scriptWriter.push(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
scriptWriter.finish(content);
|
||||||
|
await scriptWriter.waitForDone();
|
||||||
await addScript({
|
await addScript({
|
||||||
theme,
|
theme,
|
||||||
style,
|
style,
|
||||||
@@ -82,6 +92,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
setTheme('');
|
setTheme('');
|
||||||
setStreamContent('');
|
setStreamContent('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
scriptWriter.fail('生成失败,请稍后重试');
|
||||||
console.error('Failed to generate script:', error);
|
console.error('Failed to generate script:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -121,14 +132,22 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
scriptWriter.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStreamContent('');
|
setStreamContent('');
|
||||||
const content = await generateEpicScript(
|
const content = await generateEpicScript(
|
||||||
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
|
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
|
||||||
lifeEvents,
|
lifeEvents,
|
||||||
{ onDelta: (_delta, output) => setStreamContent(output) }
|
{
|
||||||
|
onDelta: (_delta, output) => {
|
||||||
|
setStreamContent(output);
|
||||||
|
scriptWriter.push(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
scriptWriter.finish(content);
|
||||||
|
await scriptWriter.waitForDone();
|
||||||
await updateScript({
|
await updateScript({
|
||||||
id: editingScript.id,
|
id: editingScript.id,
|
||||||
theme: editForm.theme,
|
theme: editForm.theme,
|
||||||
@@ -142,6 +161,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
closeEditModal();
|
closeEditModal();
|
||||||
setStreamContent('');
|
setStreamContent('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
scriptWriter.fail('生成失败,请稍后重试');
|
||||||
console.error('Failed to update script:', error);
|
console.error('Failed to update script:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -306,18 +326,21 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
{/* 右侧剧本展示区 */}
|
{/* 右侧剧本展示区 */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
{isLoading && streamContent ? (
|
{isLoading ? (
|
||||||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
|
<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="prose prose-invert max-w-none">
|
||||||
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-2xl font-serif text-orange-200">{theme}</h4>
|
<h4 className="text-2xl font-serif text-orange-200">{theme || editForm.theme}</h4>
|
||||||
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">正在生成</p>
|
<p className="text-[10px] text-white/40 mt-1 tracking-widest">
|
||||||
|
{scriptWriter.isWaiting ? '正在理解你的创作目标' : scriptWriter.isDraining ? '正在收束最后一句' : '正在逐字生成剧本'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<BookOpen className="text-white/20" />
|
<Loader2 className="text-orange-200/60 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm">
|
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm">
|
||||||
{streamContent}
|
{scriptWriter.visibleText || '故事正在生成,请稍候...'}
|
||||||
|
{(scriptWriter.isStreaming || scriptWriter.isDraining) && <span className="text-orange-200 animate-pulse">|</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components
|
|||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import useStore from '../store/useStore';
|
import useStore from '../store/useStore';
|
||||||
import { analyzeLifeEvent } from '../services/ai';
|
import { analyzeLifeEvent } from '../services/ai';
|
||||||
|
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化 AI 反馈内容的组件
|
* 格式化 AI 反馈内容的组件
|
||||||
@@ -93,6 +94,7 @@ const TimelineView = () => {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [streamFeedback, setStreamFeedback] = useState('');
|
const [streamFeedback, setStreamFeedback] = useState('');
|
||||||
|
const feedbackWriter = useTypewriterStream({ interval: 18, step: 1 });
|
||||||
|
|
||||||
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
||||||
const [editingEventId, setEditingEventId] = useState(null);
|
const [editingEventId, setEditingEventId] = useState(null);
|
||||||
@@ -114,6 +116,7 @@ const TimelineView = () => {
|
|||||||
setEditingEventId(null);
|
setEditingEventId(null);
|
||||||
setEventForm({ title: '', time: '', content: '' });
|
setEventForm({ title: '', time: '', content: '' });
|
||||||
setStreamFeedback('');
|
setStreamFeedback('');
|
||||||
|
feedbackWriter.reset();
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ const TimelineView = () => {
|
|||||||
content: event.content || ''
|
content: event.content || ''
|
||||||
});
|
});
|
||||||
setStreamFeedback(event.aiFeedback || '');
|
setStreamFeedback(event.aiFeedback || '');
|
||||||
|
feedbackWriter.reset();
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,6 +144,7 @@ const TimelineView = () => {
|
|||||||
setEditingEventId(null);
|
setEditingEventId(null);
|
||||||
setEventForm({ title: '', time: '', content: '' });
|
setEventForm({ title: '', time: '', content: '' });
|
||||||
setStreamFeedback('');
|
setStreamFeedback('');
|
||||||
|
feedbackWriter.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,12 +157,18 @@ const TimelineView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
feedbackWriter.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStreamFeedback('');
|
setStreamFeedback('');
|
||||||
const aiFeedback = await analyzeLifeEvent(eventForm, {
|
const aiFeedback = await analyzeLifeEvent(eventForm, {
|
||||||
onDelta: (_delta, output) => setStreamFeedback(output)
|
onDelta: (_delta, output) => {
|
||||||
|
setStreamFeedback(output);
|
||||||
|
feedbackWriter.push(output);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
feedbackWriter.finish(aiFeedback);
|
||||||
|
await feedbackWriter.waitForDone();
|
||||||
|
|
||||||
if (editingEventId) {
|
if (editingEventId) {
|
||||||
// 编辑模式:调用更新接口
|
// 编辑模式:调用更新接口
|
||||||
@@ -177,6 +188,7 @@ const TimelineView = () => {
|
|||||||
// 重置表单并关闭模态框
|
// 重置表单并关闭模态框
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
feedbackWriter.fail('AI 疗愈反馈生成失败,请稍后重试');
|
||||||
console.error('Failed to save event:', error);
|
console.error('Failed to save event:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -323,13 +335,17 @@ const TimelineView = () => {
|
|||||||
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
||||||
rows={5}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
{streamFeedback && (
|
{(streamFeedback || isLoading) && (
|
||||||
<div className="ai-glow-card p-4 rounded-2xl bg-orange-200/[0.02] border border-orange-200/5">
|
<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">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Sparkles className="w-3 h-3 text-orange-200" />
|
<Sparkles className="w-3 h-3 text-orange-200" />
|
||||||
<span className="text-[9px] uppercase tracking-[0.2em] text-orange-200/60 font-bold">实时疗愈反馈</span>
|
<span className="text-[9px] tracking-[0.2em] text-orange-200/60 font-bold">
|
||||||
|
{feedbackWriter.isWaiting ? '正在理解这段经历' : feedbackWriter.isDraining ? '正在收束反馈' : '实时疗愈反馈'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs italic text-white/50 leading-loose whitespace-pre-wrap">{streamFeedback}</p>
|
{feedbackWriter.visibleText
|
||||||
|
? <FeedbackContent content={feedbackWriter.visibleText} />
|
||||||
|
: <p className="text-xs italic text-white/40 leading-loose">AI 正在梳理你的生命轨迹,请稍候...</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ const hydrateSafeArea = () => {
|
|||||||
statusBarHeight.value = windowInfo.statusBarHeight || 20
|
statusBarHeight.value = windowInfo.statusBarHeight || 20
|
||||||
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
|
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
|
||||||
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
||||||
|
uni.setStorageSync('windowWidth', windowInfo.windowWidth || 375)
|
||||||
uni.setStorageSync('statusBarHeight', statusBarHeight.value)
|
uni.setStorageSync('statusBarHeight', statusBarHeight.value)
|
||||||
uni.setStorageSync('safeAreaTop', safeAreaTop.value)
|
uni.setStorageSync('safeAreaTop', safeAreaTop.value)
|
||||||
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
|
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
|
||||||
|
if (uni.getMenuButtonBoundingClientRect) {
|
||||||
|
uni.setStorageSync('menuButtonRect', uni.getMenuButtonBoundingClientRect())
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusBarHeight.value = 20
|
statusBarHeight.value = 20
|
||||||
safeAreaTop.value = 20
|
safeAreaTop.value = 20
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<view class="detail-page">
|
<view class="detail-page">
|
||||||
<view class="space-bg"></view>
|
<view class="space-bg"></view>
|
||||||
|
|
||||||
<view class="floating-nav">
|
<view class="floating-nav" :style="floatingTopStyle">
|
||||||
<view class="circle-btn" @click="goBack">
|
<view class="circle-btn" @click="goBack">
|
||||||
<view class="back-icon"></view>
|
<view class="back-icon"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -118,10 +118,12 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import Markdown from '../../components/Markdown.vue'
|
import Markdown from '../../components/Markdown.vue'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const pagePath = '/pages/life-event/detail'
|
const pagePath = '/pages/life-event/detail'
|
||||||
|
const { floatingTopStyle } = useMenuButtonSafeArea()
|
||||||
const safeAreaBottom = ref(0)
|
const safeAreaBottom = ref(0)
|
||||||
const eventId = ref('')
|
const eventId = ref('')
|
||||||
const cachedEvent = ref(null)
|
const cachedEvent = ref(null)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="record-page">
|
<view class="record-page">
|
||||||
<view class="space-bg"></view>
|
<view class="space-bg"></view>
|
||||||
<view class="safe-top" :style="{ height: safeAreaTop + 10 + 'px' }"></view>
|
<view class="safe-top" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
|
|
||||||
<view class="header">
|
<view class="header">
|
||||||
<view class="back-hit" @click="goBack"><view class="back-icon"></view></view>
|
<view class="back-hit" @click="goBack"><view class="back-icon"></view></view>
|
||||||
@@ -125,9 +125,9 @@
|
|||||||
:placeholder="contentPlaceholder"
|
:placeholder="contentPlaceholder"
|
||||||
placeholder-class="placeholder"
|
placeholder-class="placeholder"
|
||||||
/>
|
/>
|
||||||
<view class="ai-btn" @click="assistWrite">
|
<view class="ai-btn" :class="{ active: assisting }" @click="assistWrite">
|
||||||
<text class="sparkle">✦</text>
|
<text class="sparkle">✦</text>
|
||||||
<text>AI 帮我写</text>
|
<text>{{ assistButtonText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -165,14 +165,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
|
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const pagePath = '/pages/life-event/form'
|
const pagePath = '/pages/life-event/form'
|
||||||
const safeAreaTop = ref(20)
|
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const assisting = ref(false)
|
||||||
|
const assistWriter = useTypewriterStream({ interval: 24, step: 1 })
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -208,6 +212,17 @@ const seasonRange = [years, seasons.map(item => item.label)]
|
|||||||
const tags = ref(['成长', '学习', '工作', '旅行', '感情', '家庭', '友情', '挑战', '突破', '收获', '感动', '迷茫'])
|
const tags = ref(['成长', '学习', '工作', '旅行', '感情', '家庭', '友情', '挑战', '突破', '收获', '感动', '迷茫'])
|
||||||
const contentPlaceholder = '请详细记录这段经历的背景、发生的事情、你的感受和收获...'
|
const contentPlaceholder = '请详细记录这段经历的背景、发生的事情、你的感受和收获...'
|
||||||
|
|
||||||
|
const assistButtonText = computed(() => {
|
||||||
|
if (assistWriter.isWaiting.value) return 'AI 正在理解'
|
||||||
|
if (assistWriter.isStreaming.value || assistWriter.isDraining.value) return 'AI 正在写入'
|
||||||
|
return 'AI 帮我写'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => assistWriter.visibleText.value, (text) => {
|
||||||
|
if (!text || !(assisting.value || assistWriter.isStreaming.value || assistWriter.isDraining.value)) return
|
||||||
|
form.content = text
|
||||||
|
})
|
||||||
|
|
||||||
const monthPickerValue = computed(() => {
|
const monthPickerValue = computed(() => {
|
||||||
const [year, month] = (form.eventDateText || form.time).split('-')
|
const [year, month] = (form.eventDateText || form.time).split('-')
|
||||||
return [
|
return [
|
||||||
@@ -225,8 +240,6 @@ const seasonPickerValue = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const info = uni.getWindowInfo()
|
|
||||||
safeAreaTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const id = pages[pages.length - 1]?.options?.id || ''
|
const id = pages[pages.length - 1]?.options?.id || ''
|
||||||
if (id) loadEvent(id)
|
if (id) loadEvent(id)
|
||||||
@@ -336,16 +349,24 @@ const loadEvent = async (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assistWrite = async () => {
|
const assistWrite = async () => {
|
||||||
|
if (assisting.value) return
|
||||||
|
assisting.value = true
|
||||||
|
assistWriter.reset()
|
||||||
const result = await store.assistEventWriting({ ...form }, {
|
const result = await store.assistEventWriting({ ...form }, {
|
||||||
onDelta: (_delta, output) => {
|
onDelta: (_delta, output) => {
|
||||||
form.content = output
|
assistWriter.push(output)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
assistWriter.fail(result.error || 'AI 帮写失败')
|
||||||
|
assisting.value = false
|
||||||
uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' })
|
uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.data?.content) form.content = result.data.content
|
if (result.data?.content) assistWriter.finish(result.data.content)
|
||||||
|
else assistWriter.finish(form.content)
|
||||||
|
await assistWriter.waitForDone()
|
||||||
|
assisting.value = false
|
||||||
if (Array.isArray(result.data?.tags)) {
|
if (Array.isArray(result.data?.tags)) {
|
||||||
result.data.tags.forEach(tag => {
|
result.data.tags.forEach(tag => {
|
||||||
if (!tags.value.includes(tag)) tags.value.push(tag)
|
if (!tags.value.includes(tag)) tags.value.push(tag)
|
||||||
@@ -394,6 +415,10 @@ const buildAiFeedback = () => {
|
|||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
assistWriter.dispose()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -800,10 +825,33 @@ const goBack = () => {
|
|||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-btn.active {
|
||||||
|
border-color: rgba(255, 216, 107, 0.5);
|
||||||
|
color: #ffe7a3;
|
||||||
|
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
.sparkle {
|
.sparkle {
|
||||||
color: #d783ff;
|
color: #d783ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-btn.active .sparkle {
|
||||||
|
color: #ffd86b;
|
||||||
|
animation: sparklePulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparklePulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.custom-tag {
|
.custom-tag {
|
||||||
height: 50rpx;
|
height: 50rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const handleLogin = async () => {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.hasProfile) {
|
if (result.hasProfile) {
|
||||||
uni.redirectTo({ url: '/pages/main/index' })
|
uni.redirectTo({ url: '/pages/main/index?tab=script' })
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({ url: '/pages/onboarding/index' })
|
uni.redirectTo({ url: '/pages/onboarding/index' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="path-view">
|
<view class="path-view" :style="{ paddingTop: capsuleTopReservePx + 'px' }">
|
||||||
<text class="page-title">实现路径</text>
|
<text class="page-title">实现路径</text>
|
||||||
|
|
||||||
<view v-if="!selectedScript" class="empty-state glass-card">
|
<view v-if="!selectedScript" class="empty-state glass-card">
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<view v-else-if="!currentPath" class="empty-state glass-card">
|
<view v-else-if="!currentPath" class="empty-state glass-card">
|
||||||
<text class="empty-icon">🎯</text>
|
<text class="empty-icon">🎯</text>
|
||||||
<text class="empty-text">等待开启人生导航...</text>
|
<text class="empty-text">{{ pathEmptyText }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="path-content">
|
<view v-else class="path-content">
|
||||||
@@ -41,14 +41,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, watch } from 'vue'
|
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
|
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
import * as lifePathService from '../../services/lifePath.js'
|
import * as lifePathService from '../../services/lifePath.js'
|
||||||
import { streamAiScene } from '../../services/aiRuntime.js'
|
import { streamAiScene } from '../../services/aiRuntime.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
|
|
||||||
const pathData = ref(null)
|
const pathData = ref(null)
|
||||||
|
const pathGenerating = ref(false)
|
||||||
|
const pathWriter = useTypewriterStream({ interval: 24, step: 1 })
|
||||||
|
|
||||||
const selectedScript = computed(() => {
|
const selectedScript = computed(() => {
|
||||||
return store.scripts.find(s => s.isSelected)
|
return store.scripts.find(s => s.isSelected)
|
||||||
@@ -60,6 +65,34 @@ const currentPath = computed(() => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pathEmptyText = computed(() => {
|
||||||
|
if (pathWriter.isWaiting.value) return '正在分析剧本,拆解可执行路径...'
|
||||||
|
if (pathWriter.isStreaming.value || pathWriter.isDraining.value) return '正在生成路径节点...'
|
||||||
|
return '等待开启人生导航...'
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildStreamPath = (scriptId, title, output) => ({
|
||||||
|
id: `stream-${scriptId}`,
|
||||||
|
scriptId,
|
||||||
|
title,
|
||||||
|
description: output,
|
||||||
|
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
|
||||||
|
phase: `阶段${index + 1}`,
|
||||||
|
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
|
||||||
|
desc: line,
|
||||||
|
content: line,
|
||||||
|
done: index === 0
|
||||||
|
})),
|
||||||
|
progress: 8,
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => pathWriter.visibleText.value, (text) => {
|
||||||
|
if (!(pathGenerating.value || pathWriter.isStreaming.value || pathWriter.isDraining.value) || !selectedScript.value?.id || !text) return
|
||||||
|
const title = selectedScript.value.title ? `${selectedScript.value.title} · 实现路径` : '我的实现路径'
|
||||||
|
pathData.value = buildStreamPath(selectedScript.value.id, title, text)
|
||||||
|
})
|
||||||
|
|
||||||
const loadPath = async (scriptId) => {
|
const loadPath = async (scriptId) => {
|
||||||
if (!scriptId) return
|
if (!scriptId) return
|
||||||
try {
|
try {
|
||||||
@@ -76,6 +109,8 @@ const createPlaceholderPath = async (scriptId) => {
|
|||||||
const script = selectedScript.value || {}
|
const script = selectedScript.value || {}
|
||||||
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
|
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
|
||||||
let generatedText = ''
|
let generatedText = ''
|
||||||
|
pathGenerating.value = true
|
||||||
|
pathWriter.reset()
|
||||||
try {
|
try {
|
||||||
const streamRes = await streamAiScene({
|
const streamRes = await streamAiScene({
|
||||||
sceneCode: 'life_healing',
|
sceneCode: 'life_healing',
|
||||||
@@ -86,26 +121,23 @@ const createPlaceholderPath = async (scriptId) => {
|
|||||||
},
|
},
|
||||||
onDelta: (_delta, output) => {
|
onDelta: (_delta, output) => {
|
||||||
generatedText = output
|
generatedText = output
|
||||||
pathData.value = {
|
pathWriter.push(output)
|
||||||
id: `stream-${scriptId}`,
|
},
|
||||||
scriptId,
|
onDone: (_event, output) => {
|
||||||
title,
|
pathWriter.finish(output)
|
||||||
description: output,
|
},
|
||||||
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
|
onError: (message) => {
|
||||||
phase: `阶段${index + 1}`,
|
pathWriter.fail(message || '路径生成失败')
|
||||||
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
|
|
||||||
desc: line,
|
|
||||||
content: line,
|
|
||||||
done: index === 0
|
|
||||||
})),
|
|
||||||
progress: 8,
|
|
||||||
status: 'active'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
generatedText = streamRes.output || generatedText
|
generatedText = streamRes.output || generatedText
|
||||||
|
pathWriter.finish(generatedText)
|
||||||
|
await pathWriter.waitForDone()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
pathWriter.fail(error?.message || '路径生成失败')
|
||||||
generatedText = ''
|
generatedText = ''
|
||||||
|
} finally {
|
||||||
|
pathGenerating.value = false
|
||||||
}
|
}
|
||||||
const steps = [
|
const steps = [
|
||||||
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
|
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
|
||||||
@@ -128,7 +160,7 @@ const createPlaceholderPath = async (scriptId) => {
|
|||||||
const res = await lifePathService.createPath({
|
const res = await lifePathService.createPath({
|
||||||
scriptId,
|
scriptId,
|
||||||
title,
|
title,
|
||||||
description: script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
|
description: generatedText || script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
|
||||||
steps,
|
steps,
|
||||||
progress: 8,
|
progress: 8,
|
||||||
status: 'active'
|
status: 'active'
|
||||||
@@ -164,6 +196,10 @@ onMounted(() => {
|
|||||||
loadPath(selectedScript.value.id)
|
loadPath(selectedScript.value.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
pathWriter.dispose()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="detail-page">
|
<view class="detail-page">
|
||||||
<view class="space-bg"></view>
|
<view class="space-bg"></view>
|
||||||
<view class="status-space" :style="{ height: statusBarHeight + 'px' }"></view>
|
<view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
|
|
||||||
<view class="topbar">
|
<view class="topbar" :style="topbarStyle">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<text class="top-title">人生剧本 ✦</text>
|
<text class="top-title">人生剧本 ✦</text>
|
||||||
<button class="save-btn kos-pill" @click="selectCurrent">映射</button>
|
<button class="save-btn kos-pill" @click="selectCurrent">映射</button>
|
||||||
@@ -67,14 +67,15 @@ import { useAppStore } from '../../stores/app.js'
|
|||||||
import Markdown from '../../components/Markdown.vue'
|
import Markdown from '../../components/Markdown.vue'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const statusBarHeight = ref(20)
|
|
||||||
const activeTab = ref('content')
|
const activeTab = ref('content')
|
||||||
const scriptId = ref('')
|
const scriptId = ref('')
|
||||||
const script = ref(null)
|
const script = ref(null)
|
||||||
const pagePath = '/pages/main/ScriptDetailView'
|
const pagePath = '/pages/main/ScriptDetailView'
|
||||||
const ttsPlayer = useTtsPlayer({ pagePath })
|
const ttsPlayer = useTtsPlayer({ pagePath })
|
||||||
|
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
|
|
||||||
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
|
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
|
||||||
const lengthText = computed(() => {
|
const lengthText = computed(() => {
|
||||||
@@ -146,7 +147,6 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
statusBarHeight.value = uni.getStorageSync('statusBarHeight') || 20
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
scriptId.value = pages[pages.length - 1]?.options?.id || ''
|
scriptId.value = pages[pages.length - 1]?.options?.id || ''
|
||||||
await loadScript()
|
await loadScript()
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="script-view">
|
<view class="script-view">
|
||||||
|
<view class="cosmic-background">
|
||||||
|
<view class="cosmic-stars layer-one"></view>
|
||||||
|
<view class="cosmic-stars layer-two"></view>
|
||||||
|
<view class="cosmic-planet planet-main"></view>
|
||||||
|
<view class="cosmic-planet planet-soft"></view>
|
||||||
|
<view class="meteor meteor-one"></view>
|
||||||
|
<view class="meteor meteor-two"></view>
|
||||||
|
<view class="meteor meteor-three"></view>
|
||||||
|
</view>
|
||||||
<view v-if="viewState === 'home'" class="wish-home">
|
<view v-if="viewState === 'home'" class="wish-home">
|
||||||
<view class="home-head">
|
<view class="home-head">
|
||||||
<view class="history-button" @click="openScriptLibrary">
|
<view class="history-button" @click="openScriptLibrary">
|
||||||
@@ -21,28 +30,26 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="hero-copy">
|
<view class="hero-copy">
|
||||||
<text class="hero-title">今天有什么</text>
|
<text class="hero-title">今天有什么<text class="hero-highlight">心愿</text>想实现</text>
|
||||||
<text class="hero-title"><text class="hero-highlight">心愿</text>想实现</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="orb-wrap">
|
<view class="inspiration-section">
|
||||||
<view
|
<view class="section-line">
|
||||||
class="mic-orb"
|
<text class="section-title">灵感一下</text>
|
||||||
:class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }"
|
<text class="refresh" @click="shuffleInspirations">换一换</text>
|
||||||
@touchstart.prevent="startVoicePress"
|
</view>
|
||||||
@touchend.prevent="endVoicePress"
|
<view class="recommend-grid">
|
||||||
@touchcancel.prevent="cancelVoicePress"
|
<view
|
||||||
>
|
v-for="item in recommendations"
|
||||||
<view class="mic-symbol">
|
:key="item.text"
|
||||||
<view class="mic-head"></view>
|
class="recommend-card"
|
||||||
<view class="mic-stem"></view>
|
@click="useRecommendation(item.text)"
|
||||||
<view class="mic-base"></view>
|
>
|
||||||
|
<text class="recommend-text">{{ item.text }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text class="voice-copy">{{ voiceCopy }}</text>
|
|
||||||
|
|
||||||
<view class="wish-input-wrap">
|
<view class="wish-input-wrap">
|
||||||
<input
|
<input
|
||||||
class="wish-input"
|
class="wish-input"
|
||||||
@@ -68,44 +75,79 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="inspiration-section">
|
<view class="orb-wrap">
|
||||||
<view class="section-line">
|
<view
|
||||||
<text class="section-title">灵感一下</text>
|
class="mic-orb"
|
||||||
<text class="refresh" @click="shuffleInspirations">换一换</text>
|
:class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }"
|
||||||
</view>
|
@touchstart.prevent="startVoicePress"
|
||||||
<view class="recommend-grid">
|
@touchend.prevent="endVoicePress"
|
||||||
<view
|
@touchcancel.prevent="cancelVoicePress"
|
||||||
v-for="item in recommendations"
|
>
|
||||||
:key="item.text"
|
<view class="mic-symbol">
|
||||||
class="recommend-card"
|
<view class="mic-head"></view>
|
||||||
@click="useRecommendation(item.text)"
|
<view class="mic-stem"></view>
|
||||||
>
|
<view class="mic-base"></view>
|
||||||
<text class="recommend-text">{{ item.text }}</text>
|
|
||||||
<text class="recommend-tag">{{ item.tag || item.category || '灵感' }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<text class="voice-copy">{{ voiceCopy }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="viewState === 'generating'" class="generation-view">
|
<view v-else-if="viewState === 'generating'" class="generation-view">
|
||||||
|
<scroll-view
|
||||||
|
class="generation-scroll"
|
||||||
|
scroll-y
|
||||||
|
:scroll-into-view="generationScrollTarget"
|
||||||
|
:scroll-with-animation="true"
|
||||||
|
:enhanced="true"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
@touchstart="pauseGenerationAutoScroll"
|
||||||
|
@scrolltolower="resumeGenerationAutoScroll"
|
||||||
|
>
|
||||||
|
<view class="generation-scroll-content">
|
||||||
<view class="conversation">
|
<view class="conversation">
|
||||||
<view class="chat-bubble user">
|
<view class="chat-bubble user">
|
||||||
<text>{{ wishText }}</text>
|
<text>{{ wishText }}</text>
|
||||||
<text class="bubble-time">{{ currentMessageTime }}</text>
|
<text class="bubble-time">{{ currentMessageTime }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="chat-bubble system">
|
<view class="chat-bubble system">
|
||||||
<text>心愿实现中……</text>
|
<text>{{ generationTitle }}</text>
|
||||||
<text v-if="streamContent" class="stream-preview">{{ streamContent }}</text>
|
<view v-if="showThinkingDots" class="thinking-dots">
|
||||||
|
<view></view>
|
||||||
|
<view></view>
|
||||||
|
<view></view>
|
||||||
|
</view>
|
||||||
|
<text v-if="generationStatus === 'failed'" class="generation-error">{{ generationFailureCopy }}</text>
|
||||||
|
<text v-else-if="visibleStreamContent" class="stream-preview typing">{{ visibleStreamContent }}<text v-if="generating" class="typing-cursor">|</text></text>
|
||||||
<text class="bubble-time">{{ currentMessageTime }}</text>
|
<text class="bubble-time">{{ currentMessageTime }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="loading-orbit">
|
<view class="generation-loading">
|
||||||
|
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming', failed: generationStatus === 'failed' }">
|
||||||
|
<view class="orbit-ring outer"></view>
|
||||||
|
<view class="orbit-ring inner"></view>
|
||||||
<view class="orbit-core"></view>
|
<view class="orbit-core"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="loading-copy">正在把你的心愿写成故事</text>
|
<text class="loading-copy">{{ generationCopy }}</text>
|
||||||
|
<text v-if="generationStatus !== 'failed'" class="loading-subcopy">{{ generationSubcopy }}</text>
|
||||||
|
<view v-else class="generation-actions">
|
||||||
|
<button class="generation-action primary" @click="retryGeneration">再试一次</button>
|
||||||
|
<button class="generation-action secondary" @click="returnToEdit">返回修改</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view :id="generationScrollAnchor" class="generation-scroll-anchor"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="result-view">
|
<scroll-view
|
||||||
|
v-else
|
||||||
|
class="result-view"
|
||||||
|
scroll-y
|
||||||
|
:enhanced="true"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
>
|
||||||
<view class="conversation compact">
|
<view class="conversation compact">
|
||||||
<view class="chat-bubble user">
|
<view class="chat-bubble user">
|
||||||
<text>{{ wishText }}</text>
|
<text>{{ wishText }}</text>
|
||||||
@@ -141,17 +183,18 @@
|
|||||||
<button class="action-btn primary" @click="trackTtsClick">{{ ttsButtonText }}</button>
|
<button class="action-btn primary" @click="trackTtsClick">{{ ttsButtonText }}</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
import * as socialImport from '../../services/socialImport.js'
|
import * as socialImport from '../../services/socialImport.js'
|
||||||
import * as epicScriptService from '../../services/epicScript.js'
|
import * as epicScriptService from '../../services/epicScript.js'
|
||||||
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
|
||||||
|
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
|
||||||
import { transcribeAudio } from '../../services/asr.js'
|
import { transcribeAudio } from '../../services/asr.js'
|
||||||
import { streamAiScene } from '../../services/aiRuntime.js'
|
import { streamAiScene } from '../../services/aiRuntime.js'
|
||||||
|
|
||||||
@@ -166,6 +209,14 @@ const currentMessageTime = ref('')
|
|||||||
const currentResultTime = ref('')
|
const currentResultTime = ref('')
|
||||||
const streamContent = ref('')
|
const streamContent = ref('')
|
||||||
const generating = ref(false)
|
const generating = ref(false)
|
||||||
|
const streamWriter = useTypewriterStream({ interval: 24, step: 1 })
|
||||||
|
const generationStatus = ref('idle')
|
||||||
|
const generationError = ref('')
|
||||||
|
const generationHintIndex = ref(0)
|
||||||
|
const generationScrollAnchor = ref('generation-stream-anchor-a')
|
||||||
|
const generationScrollTarget = ref('')
|
||||||
|
const generationAutoFollow = ref(true)
|
||||||
|
const lastSubmitSource = ref('text')
|
||||||
const remainingCount = ref(3)
|
const remainingCount = ref(3)
|
||||||
const style = ref('career')
|
const style = ref('career')
|
||||||
const randomRecommendations = ref([])
|
const randomRecommendations = ref([])
|
||||||
@@ -175,6 +226,17 @@ const ttsPlayer = useTtsPlayer({ pagePath })
|
|||||||
let recorderManager = null
|
let recorderManager = null
|
||||||
let recordStartedAt = 0
|
let recordStartedAt = 0
|
||||||
let recordCancelled = false
|
let recordCancelled = false
|
||||||
|
let generationHintTimer = null
|
||||||
|
let generationSlowTimer = null
|
||||||
|
let generationVerySlowTimer = null
|
||||||
|
let generationScrollTimer = null
|
||||||
|
|
||||||
|
const generationHints = [
|
||||||
|
'正在从你的心愿里寻找故事的起点',
|
||||||
|
'正在整理人生素材和情绪线索',
|
||||||
|
'正在安排一次更爽的命运转弯',
|
||||||
|
'正在让故事慢慢靠近你'
|
||||||
|
]
|
||||||
|
|
||||||
const fallbackRecommendations = [
|
const fallbackRecommendations = [
|
||||||
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
|
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
|
||||||
@@ -211,6 +273,71 @@ const resultContent = computed(() => {
|
|||||||
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
|
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
|
||||||
|
|
||||||
|
const scrollGenerationToLatest = () => {
|
||||||
|
if (viewState.value !== 'generating') return
|
||||||
|
if (!generationAutoFollow.value) return
|
||||||
|
if (generationScrollTimer) clearTimeout(generationScrollTimer)
|
||||||
|
generationScrollTimer = setTimeout(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a'
|
||||||
|
? 'generation-stream-anchor-b'
|
||||||
|
: 'generation-stream-anchor-a'
|
||||||
|
generationScrollTarget.value = generationScrollAnchor.value
|
||||||
|
})
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseGenerationAutoScroll = () => {
|
||||||
|
generationAutoFollow.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeGenerationAutoScroll = () => {
|
||||||
|
generationAutoFollow.value = true
|
||||||
|
scrollGenerationToLatest()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visibleStreamContent, (text) => {
|
||||||
|
if (!text) return
|
||||||
|
scrollGenerationToLatest()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(generationStatus, () => {
|
||||||
|
scrollGenerationToLatest()
|
||||||
|
})
|
||||||
|
|
||||||
|
const generationTitle = computed(() => {
|
||||||
|
if (generationStatus.value === 'failed') return '心愿暂时没有抵达'
|
||||||
|
if (visibleStreamContent.value) return '故事正在展开'
|
||||||
|
return '心愿实现中……'
|
||||||
|
})
|
||||||
|
|
||||||
|
const showThinkingDots = computed(() => {
|
||||||
|
return generationStatus.value !== 'failed' && !visibleStreamContent.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const generationCopy = computed(() => {
|
||||||
|
if (generationStatus.value === 'failed') return '这次生成没有顺利完成'
|
||||||
|
if (generationStatus.value === 'verySlow') return '这次需要久一点,仍在努力连接灵感'
|
||||||
|
if (generationStatus.value === 'slow') return '还在整理,请再给我一点时间'
|
||||||
|
if (streamWriter.isWaiting.value) return '正在理解你的心愿,整理人生素材'
|
||||||
|
if (streamWriter.isDraining.value) return '故事马上完成,正在收束最后一句'
|
||||||
|
if (streamWriter.isStreaming.value) return '正在把你的心愿写成故事'
|
||||||
|
return '正在把你的心愿写成故事'
|
||||||
|
})
|
||||||
|
|
||||||
|
const generationSubcopy = computed(() => {
|
||||||
|
if (generationStatus.value === 'verySlow') return '如果网络波动,稍后会给你温和提示,不会丢掉当前心愿。'
|
||||||
|
if (generationStatus.value === 'slow') return 'AI 还没有吐出第一句话,但请求仍在进行中。'
|
||||||
|
if (streamWriter.isStreaming.value || streamWriter.isDraining.value) return '已经收到内容,正在逐字展示给你。'
|
||||||
|
return generationHints[generationHintIndex.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const generationFailureCopy = computed(() => {
|
||||||
|
return generationError.value || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
|
||||||
|
})
|
||||||
|
|
||||||
const ttsButtonText = computed(() => {
|
const ttsButtonText = computed(() => {
|
||||||
if (!currentResult.value?.id) return '生成保存后可语音播放'
|
if (!currentResult.value?.id) return '生成保存后可语音播放'
|
||||||
return ttsPlayer.buttonText.value
|
return ttsPlayer.buttonText.value
|
||||||
@@ -223,6 +350,54 @@ const formatMessageTime = () => {
|
|||||||
return `${hours}:${minutes}`
|
return `${hours}:${minutes}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearGenerationFeedbackTimers = () => {
|
||||||
|
if (generationHintTimer) {
|
||||||
|
clearInterval(generationHintTimer)
|
||||||
|
generationHintTimer = null
|
||||||
|
}
|
||||||
|
if (generationSlowTimer) {
|
||||||
|
clearTimeout(generationSlowTimer)
|
||||||
|
generationSlowTimer = null
|
||||||
|
}
|
||||||
|
if (generationVerySlowTimer) {
|
||||||
|
clearTimeout(generationVerySlowTimer)
|
||||||
|
generationVerySlowTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startGenerationFeedback = () => {
|
||||||
|
clearGenerationFeedbackTimers()
|
||||||
|
generationStatus.value = 'waiting'
|
||||||
|
generationError.value = ''
|
||||||
|
generationHintIndex.value = 0
|
||||||
|
generationHintTimer = setInterval(() => {
|
||||||
|
if (generationStatus.value === 'failed' || visibleStreamContent.value) return
|
||||||
|
generationHintIndex.value = (generationHintIndex.value + 1) % generationHints.length
|
||||||
|
}, 3600)
|
||||||
|
generationSlowTimer = setTimeout(() => {
|
||||||
|
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
|
||||||
|
generationStatus.value = 'slow'
|
||||||
|
}
|
||||||
|
}, 8000)
|
||||||
|
generationVerySlowTimer = setTimeout(() => {
|
||||||
|
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
|
||||||
|
generationStatus.value = 'verySlow'
|
||||||
|
}
|
||||||
|
}, 20000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markGenerationStreaming = () => {
|
||||||
|
if (generationStatus.value === 'failed') return
|
||||||
|
generationStatus.value = 'streaming'
|
||||||
|
}
|
||||||
|
|
||||||
|
const markGenerationFailed = (message) => {
|
||||||
|
clearGenerationFeedbackTimers()
|
||||||
|
generationStatus.value = 'failed'
|
||||||
|
generationError.value = message || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
|
||||||
|
streamWriter.fail(generationError.value)
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeGeneratedScript = (data) => {
|
const normalizeGeneratedScript = (data) => {
|
||||||
const script = data?.script || data || {}
|
const script = data?.script || data || {}
|
||||||
const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null
|
const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null
|
||||||
@@ -387,12 +562,22 @@ const generateScriptByStream = async (text) => {
|
|||||||
},
|
},
|
||||||
onDelta: (_delta, output) => {
|
onDelta: (_delta, output) => {
|
||||||
streamContent.value = output
|
streamContent.value = output
|
||||||
|
markGenerationStreaming()
|
||||||
|
streamWriter.push(output)
|
||||||
|
},
|
||||||
|
onDone: (_event, output) => {
|
||||||
|
streamWriter.finish(output)
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
streamWriter.fail(message || 'AI 流式生成失败')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const content = streamRes.output?.trim()
|
const content = streamRes.output?.trim()
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new Error('AI 流式输出为空')
|
throw new Error('AI 流式输出为空')
|
||||||
}
|
}
|
||||||
|
streamWriter.finish(content)
|
||||||
|
await streamWriter.waitForDone()
|
||||||
const saveRes = await store.createScript({
|
const saveRes = await store.createScript({
|
||||||
title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
|
title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
|
||||||
theme: text,
|
theme: text,
|
||||||
@@ -424,6 +609,7 @@ const generateScriptByStream = async (text) => {
|
|||||||
const submitWish = async (source = 'text') => {
|
const submitWish = async (source = 'text') => {
|
||||||
const text = wishText.value.trim()
|
const text = wishText.value.trim()
|
||||||
if (!text || generating.value) return
|
if (!text || generating.value) return
|
||||||
|
lastSubmitSource.value = source
|
||||||
|
|
||||||
analytics.track('script_wish_submit', {
|
analytics.track('script_wish_submit', {
|
||||||
source,
|
source,
|
||||||
@@ -434,6 +620,11 @@ const submitWish = async (source = 'text') => {
|
|||||||
generationStartedAt.value = Date.now()
|
generationStartedAt.value = Date.now()
|
||||||
generating.value = true
|
generating.value = true
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
|
generationScrollTarget.value = ''
|
||||||
|
generationScrollAnchor.value = 'generation-stream-anchor-a'
|
||||||
|
generationAutoFollow.value = true
|
||||||
|
streamWriter.reset()
|
||||||
|
startGenerationFeedback()
|
||||||
ttsPlayer.reset()
|
ttsPlayer.reset()
|
||||||
viewState.value = 'generating'
|
viewState.value = 'generating'
|
||||||
analytics.track('script_generation_progress_view', {
|
analytics.track('script_generation_progress_view', {
|
||||||
@@ -445,6 +636,7 @@ const submitWish = async (source = 'text') => {
|
|||||||
try {
|
try {
|
||||||
res = await generateScriptByStream(text)
|
res = await generateScriptByStream(text)
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
|
markGenerationFailed(streamError?.message || 'AI 流式生成失败')
|
||||||
analytics.track('script_generate_stream_fail', {
|
analytics.track('script_generate_stream_fail', {
|
||||||
source,
|
source,
|
||||||
error: streamError?.message || 'stream_failed'
|
error: streamError?.message || 'stream_failed'
|
||||||
@@ -456,6 +648,10 @@ const submitWish = async (source = 'text') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generating.value = false
|
generating.value = false
|
||||||
|
if (res.success) {
|
||||||
|
clearGenerationFeedbackTimers()
|
||||||
|
if (streamContent.value) streamWriter.finish(streamContent.value)
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
analytics.track('script_generate_fail', {
|
analytics.track('script_generate_fail', {
|
||||||
@@ -463,8 +659,7 @@ const submitWish = async (source = 'text') => {
|
|||||||
error: res.error || 'unknown',
|
error: res.error || 'unknown',
|
||||||
duration_ms: Date.now() - generationStartedAt.value
|
duration_ms: Date.now() - generationStartedAt.value
|
||||||
}, { eventType: 'script', pagePath })
|
}, { eventType: 'script', pagePath })
|
||||||
viewState.value = 'home'
|
markGenerationFailed(res.error || '生成失败')
|
||||||
uni.showToast({ title: res.error || '生成失败', icon: 'none' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,10 +680,36 @@ const submitWish = async (source = 'text') => {
|
|||||||
length: currentResult.value.length || ''
|
length: currentResult.value.length || ''
|
||||||
}, { eventType: 'script', pagePath })
|
}, { eventType: 'script', pagePath })
|
||||||
|
|
||||||
|
generationStatus.value = 'idle'
|
||||||
viewState.value = 'result'
|
viewState.value = 'result'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retryGeneration = () => {
|
||||||
|
if (generating.value) return
|
||||||
|
analytics.track('script_generation_retry_click', {
|
||||||
|
prompt_length: wishText.value.trim().length
|
||||||
|
}, { eventType: 'script', pagePath })
|
||||||
|
generationStatus.value = 'idle'
|
||||||
|
generationError.value = ''
|
||||||
|
submitWish(lastSubmitSource.value || 'text')
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnToEdit = () => {
|
||||||
|
clearGenerationFeedbackTimers()
|
||||||
|
generating.value = false
|
||||||
|
generationStatus.value = 'idle'
|
||||||
|
generationError.value = ''
|
||||||
|
streamContent.value = ''
|
||||||
|
streamWriter.reset()
|
||||||
|
analytics.track('script_generation_back_edit_click', {
|
||||||
|
prompt_length: wishText.value.trim().length
|
||||||
|
}, { eventType: 'script', pagePath })
|
||||||
|
viewState.value = 'home'
|
||||||
|
}
|
||||||
|
|
||||||
const closeResult = () => {
|
const closeResult = () => {
|
||||||
|
clearGenerationFeedbackTimers()
|
||||||
|
generationStatus.value = 'idle'
|
||||||
viewState.value = 'home'
|
viewState.value = 'home'
|
||||||
currentResult.value = null
|
currentResult.value = null
|
||||||
ttsPlayer.reset()
|
ttsPlayer.reset()
|
||||||
@@ -534,6 +755,12 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
clearGenerationFeedbackTimers()
|
||||||
|
if (generationScrollTimer) {
|
||||||
|
clearTimeout(generationScrollTimer)
|
||||||
|
generationScrollTimer = null
|
||||||
|
}
|
||||||
|
streamWriter.dispose()
|
||||||
if (recorderManager && voiceState.value === 'pressing') {
|
if (recorderManager && voiceState.value === 'pressing') {
|
||||||
recordCancelled = true
|
recordCancelled = true
|
||||||
recorderManager.stop()
|
recorderManager.stop()
|
||||||
@@ -543,14 +770,177 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.script-view {
|
.script-view {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
|
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cosmic-background {
|
||||||
|
position: absolute;
|
||||||
|
inset: -80rpx -80rpx;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmic-background::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 22%, rgba(255, 216, 107, 0.06), transparent 30%),
|
||||||
|
radial-gradient(circle at 72% 6%, rgba(209, 138, 255, 0.11), transparent 24%),
|
||||||
|
linear-gradient(180deg, rgba(12, 4, 31, 0.1), rgba(5, 2, 13, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmic-stars {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmic-stars.layer-one {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.52) 0 2rpx, transparent 3rpx),
|
||||||
|
radial-gradient(circle at 32% 9%, rgba(255, 216, 107, 0.42) 0 2rpx, transparent 3rpx),
|
||||||
|
radial-gradient(circle at 74% 32%, rgba(255, 255, 255, 0.36) 0 2rpx, transparent 3rpx),
|
||||||
|
radial-gradient(circle at 88% 48%, rgba(209, 138, 255, 0.42) 0 2rpx, transparent 3rpx),
|
||||||
|
radial-gradient(circle at 24% 72%, rgba(255, 255, 255, 0.32) 0 2rpx, transparent 3rpx);
|
||||||
|
animation: starFloat 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmic-stars.layer-two {
|
||||||
|
opacity: 0.32;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 42%, rgba(255, 255, 255, 0.5) 0 1rpx, transparent 2rpx),
|
||||||
|
radial-gradient(circle at 46% 28%, rgba(209, 138, 255, 0.5) 0 1rpx, transparent 2rpx),
|
||||||
|
radial-gradient(circle at 64% 70%, rgba(255, 216, 107, 0.5) 0 1rpx, transparent 2rpx),
|
||||||
|
radial-gradient(circle at 92% 18%, rgba(255, 255, 255, 0.42) 0 1rpx, transparent 2rpx);
|
||||||
|
animation: starFloat 11s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmic-planet {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-main {
|
||||||
|
top: 118rpx;
|
||||||
|
right: -52rpx;
|
||||||
|
width: 188rpx;
|
||||||
|
height: 188rpx;
|
||||||
|
opacity: 0.38;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 36% 28%, rgba(255, 231, 163, 0.96), rgba(209, 138, 255, 0.7) 36%, rgba(93, 38, 193, 0.78) 68%, rgba(23, 9, 56, 0.9));
|
||||||
|
box-shadow: 0 0 76rpx rgba(168, 85, 247, 0.32);
|
||||||
|
animation: planetDrift 9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-main::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -24rpx;
|
||||||
|
top: 78rpx;
|
||||||
|
width: 238rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid rgba(255, 216, 107, 0.18);
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-soft {
|
||||||
|
left: -64rpx;
|
||||||
|
bottom: 180rpx;
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
opacity: 0.2;
|
||||||
|
background: radial-gradient(circle at 50% 42%, rgba(140, 68, 242, 0.78), rgba(30, 8, 76, 0.92));
|
||||||
|
box-shadow: 0 0 64rpx rgba(140, 68, 242, 0.28);
|
||||||
|
animation: planetDrift 12s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meteor {
|
||||||
|
position: absolute;
|
||||||
|
width: 132rpx;
|
||||||
|
height: 3rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), rgba(255, 216, 107, 0.18), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-22deg);
|
||||||
|
animation: meteorFall 5.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meteor-one {
|
||||||
|
top: 142rpx;
|
||||||
|
left: -160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meteor-two {
|
||||||
|
top: 318rpx;
|
||||||
|
left: 120rpx;
|
||||||
|
width: 104rpx;
|
||||||
|
animation-delay: 1.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meteor-three {
|
||||||
|
top: 520rpx;
|
||||||
|
right: -120rpx;
|
||||||
|
width: 88rpx;
|
||||||
|
animation-delay: 3.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes starFloat {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.36;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(18rpx);
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes planetDrift {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate3d(-14rpx, 18rpx, 0) scale(1.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes meteorFall {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0) rotate(-22deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
9% {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
34% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.wish-home,
|
.wish-home,
|
||||||
.generation-view,
|
.generation-view,
|
||||||
.result-view {
|
.result-view {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -562,6 +952,13 @@ onUnmounted(() => {
|
|||||||
gap: 32rpx;
|
gap: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-view {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 190rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.home-head {
|
.home-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -623,17 +1020,28 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.hero-copy {
|
.hero-copy {
|
||||||
margin-top: 12rpx;
|
margin-top: 12rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
font-size: 76rpx;
|
align-items: baseline;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 56rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.26;
|
line-height: 1.18;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-highlight {
|
.hero-highlight {
|
||||||
|
margin: 0 4rpx;
|
||||||
color: #d18aff;
|
color: #d18aff;
|
||||||
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
|
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
|
||||||
}
|
}
|
||||||
@@ -822,40 +1230,58 @@ onUnmounted(() => {
|
|||||||
.recommend-grid {
|
.recommend-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 18rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommend-card {
|
.recommend-card {
|
||||||
min-height: 142rpx;
|
min-height: 68rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
justify-content: space-between;
|
min-width: 0;
|
||||||
gap: 16rpx;
|
padding: 0 22rpx;
|
||||||
padding: 22rpx;
|
border-radius: 24rpx;
|
||||||
border-radius: 36rpx;
|
|
||||||
background: linear-gradient(180deg, rgba(48, 24, 89, 0.78), rgba(32, 14, 62, 0.76));
|
background: linear-gradient(180deg, rgba(48, 24, 89, 0.78), rgba(32, 14, 62, 0.76));
|
||||||
border: 1rpx solid rgba(168, 85, 247, 0.22);
|
border: 1rpx solid rgba(168, 85, 247, 0.22);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommend-text {
|
.recommend-text {
|
||||||
font-size: 28rpx;
|
width: 100%;
|
||||||
line-height: 40rpx;
|
min-width: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 68rpx;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
.recommend-tag {
|
white-space: nowrap;
|
||||||
align-self: flex-start;
|
|
||||||
padding: 5rpx 14rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #d18aff;
|
|
||||||
background: rgba(168, 85, 247, 0.22);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.generation-view {
|
.generation-view {
|
||||||
justify-content: center;
|
flex: 1;
|
||||||
gap: 52rpx;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-scroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-scroll-content {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 42rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 40rpx 0 190rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-scroll-anchor {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation {
|
.conversation {
|
||||||
@@ -892,14 +1318,78 @@ onUnmounted(() => {
|
|||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thinking-dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dots view {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffd86b;
|
||||||
|
box-shadow: 0 0 18rpx rgba(255, 216, 107, 0.52);
|
||||||
|
animation: thinkingDot 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dots view:nth-child(2) {
|
||||||
|
animation-delay: 0.18s;
|
||||||
|
background: #d18aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dots view:nth-child(3) {
|
||||||
|
animation-delay: 0.36s;
|
||||||
|
background: #8c44f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thinkingDot {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
opacity: 0.38;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-8rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stream-preview {
|
.stream-preview {
|
||||||
display: block;
|
display: block;
|
||||||
max-height: 320rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
color: rgba(255, 255, 255, 0.86);
|
color: rgba(255, 255, 255, 0.86);
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
line-height: 44rpx;
|
line-height: 44rpx;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-preview.typing {
|
||||||
|
padding-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-cursor {
|
||||||
|
color: #ffd86b;
|
||||||
|
animation: cursorBlink 0.9s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-error {
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 231, 163, 0.92);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursorBlink {
|
||||||
|
0%, 45% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
46%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble.done {
|
.chat-bubble.done {
|
||||||
@@ -920,6 +1410,24 @@ onUnmounted(() => {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 3rpx solid rgba(192, 132, 252, 0.28);
|
border: 3rpx solid rgba(192, 132, 252, 0.28);
|
||||||
box-shadow: 0 0 44rpx rgba(168, 85, 247, 0.55);
|
box-shadow: 0 0 44rpx rgba(168, 85, 247, 0.55);
|
||||||
|
animation: orbitBreath 2.4s ease-in-out infinite;
|
||||||
|
transition: opacity 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-orbit.streaming {
|
||||||
|
filter: saturate(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-orbit.failed {
|
||||||
|
opacity: 0.52;
|
||||||
|
filter: grayscale(0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-orbit::after {
|
.loading-orbit::after {
|
||||||
@@ -932,6 +1440,22 @@ onUnmounted(() => {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ffd86b;
|
background: #ffd86b;
|
||||||
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.72);
|
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.72);
|
||||||
|
animation: orbitLight 1.8s linear infinite;
|
||||||
|
transform-origin: -72rpx 70rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: -22rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1rpx solid rgba(255, 216, 107, 0.16);
|
||||||
|
animation: ringPulse 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-ring.inner {
|
||||||
|
inset: 18rpx;
|
||||||
|
border-color: rgba(209, 138, 255, 0.2);
|
||||||
|
animation-delay: 0.7s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orbit-core {
|
.orbit-core {
|
||||||
@@ -939,6 +1463,41 @@ onUnmounted(() => {
|
|||||||
inset: 42rpx;
|
inset: 42rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, #c084fc, #5c1bb0);
|
background: radial-gradient(circle, #c084fc, #5c1bb0);
|
||||||
|
box-shadow: inset 0 0 22rpx rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbitBreath {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(0.96);
|
||||||
|
box-shadow: 0 0 36rpx rgba(168, 85, 247, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.04);
|
||||||
|
box-shadow: 0 0 70rpx rgba(168, 85, 247, 0.66);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbitLight {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ringPulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.78;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-copy {
|
.loading-copy {
|
||||||
@@ -947,6 +1506,51 @@ onUnmounted(() => {
|
|||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-subcopy {
|
||||||
|
display: block;
|
||||||
|
max-width: 620rpx;
|
||||||
|
text-align: center;
|
||||||
|
padding: 18rpx 24rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
color: rgba(255, 231, 163, 0.78);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
background: rgba(255, 216, 107, 0.07);
|
||||||
|
border: 1rpx solid rgba(255, 216, 107, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-action {
|
||||||
|
min-width: 176rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 27rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-action::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-action.primary {
|
||||||
|
color: #1b0b31;
|
||||||
|
background: linear-gradient(145deg, #ffd86b, #d18aff);
|
||||||
|
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generation-action.secondary {
|
||||||
|
color: #e8ccff;
|
||||||
|
background: rgba(43, 19, 83, 0.68);
|
||||||
|
border: 1rpx solid rgba(168, 85, 247, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
.story-card {
|
.story-card {
|
||||||
border-radius: 52rpx;
|
border-radius: 52rpx;
|
||||||
padding: 34rpx;
|
padding: 34rpx;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<view class="stars"></view>
|
<view class="stars"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="safe-top" :style="{ height: safeAreaTop + 14 + 'px' }"></view>
|
<view class="safe-top" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
|
|
||||||
<scroll-view class="content" scroll-y :enhanced="true" :show-scrollbar="false">
|
<scroll-view class="content" scroll-y :enhanced="true" :show-scrollbar="false">
|
||||||
<ScriptView v-if="activeTab === 'script'" />
|
<ScriptView v-if="activeTab === 'script'" />
|
||||||
@@ -48,31 +48,36 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import RecordView from './RecordView.vue'
|
import RecordView from './RecordView.vue'
|
||||||
import ScriptView from './ScriptView.vue'
|
import ScriptView from './ScriptView.vue'
|
||||||
import MineView from './MineView.vue'
|
import MineView from './MineView.vue'
|
||||||
import MusicPlayer from '../../components/MusicPlayer.vue'
|
import MusicPlayer from '../../components/MusicPlayer.vue'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const activeTab = ref('script')
|
const activeTab = ref('script')
|
||||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
|
||||||
const safeAreaBottom = ref(uni.getStorageSync('safeAreaBottom') || 0)
|
|
||||||
const pagePath = '/pages/main/index'
|
const pagePath = '/pages/main/index'
|
||||||
|
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
|
const validTabs = ['script', 'record', 'mine']
|
||||||
|
|
||||||
|
const normalizeTab = (tab) => validTabs.includes(tab) ? tab : 'script'
|
||||||
|
|
||||||
const switchTab = (tab) => {
|
const switchTab = (tab) => {
|
||||||
|
tab = normalizeTab(tab)
|
||||||
if (activeTab.value === tab) return
|
if (activeTab.value === tab) return
|
||||||
analytics.track('page_leave', { tab: activeTab.value }, { eventType: 'page', pagePath })
|
analytics.track('page_leave', { tab: activeTab.value }, { eventType: 'page', pagePath })
|
||||||
activeTab.value = tab
|
activeTab.value = tab
|
||||||
analytics.track('page_view', { tab }, { eventType: 'page', pagePath })
|
analytics.track('page_view', { tab }, { eventType: 'page', pagePath })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onLoad((options = {}) => {
|
||||||
const windowInfo = uni.getWindowInfo()
|
activeTab.value = normalizeTab(options.tab)
|
||||||
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
|
})
|
||||||
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
const session = await store.restoreSession()
|
const session = await store.restoreSession()
|
||||||
if (session.status !== store.SESSION_STATUS.AUTHENTICATED) {
|
if (session.status !== store.SESSION_STATUS.AUTHENTICATED) {
|
||||||
uni.reLaunch({ url: '/pages/login/index' })
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-page">
|
<view class="profile-page">
|
||||||
<view class="space-bg"></view>
|
<view class="space-bg"></view>
|
||||||
<view class="status-space" :style="{ height: statusBarHeight + 'px' }"></view>
|
<view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
|
|
||||||
<view class="topbar">
|
<view class="topbar" :style="topbarStyle">
|
||||||
<text class="back" @click="goBack">‹</text>
|
<text class="back" @click="goBack">‹</text>
|
||||||
<text class="title">编辑资料 <text class="gold">✦</text></text>
|
<text class="title">编辑资料 <text class="gold">✦</text></text>
|
||||||
<text class="save" @click="saveProfile">保存</text>
|
<text class="save" @click="saveProfile">保存</text>
|
||||||
@@ -185,9 +185,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const statusBarHeight = ref(20)
|
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const birthday = ref('')
|
const birthday = ref('')
|
||||||
@@ -321,17 +322,16 @@ const saveProfile = async () => {
|
|||||||
uni.showToast({ title: '已保存', icon: 'success' })
|
uni.showToast({ title: '已保存', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isEdit.value) uni.navigateBack()
|
if (isEdit.value) uni.navigateBack()
|
||||||
else uni.reLaunch({ url: '/pages/main/index' })
|
else uni.reLaunch({ url: '/pages/main/index?tab=script' })
|
||||||
}, 350)
|
}, 350)
|
||||||
}
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
if (isEdit.value) uni.navigateBack()
|
if (isEdit.value) uni.navigateBack()
|
||||||
else uni.reLaunch({ url: '/pages/main/index' })
|
else uni.reLaunch({ url: '/pages/main/index?tab=script' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
statusBarHeight.value = uni.getStorageSync('statusBarHeight') || 20
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
isEdit.value = pages[pages.length - 1]?.options?.edit === '1'
|
isEdit.value = pages[pages.length - 1]?.options?.edit === '1'
|
||||||
syncFromStore()
|
syncFromStore()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-page">
|
<view class="profile-page">
|
||||||
<view class="top-safe" :style="{ height: safeAreaTop + 'px' }"></view>
|
<view class="top-safe" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
<view class="header">
|
<view class="header" :style="topbarStyle">
|
||||||
<text class="back" @click="goBack">‹</text>
|
<text class="back" @click="goBack">‹</text>
|
||||||
<text class="title">个人中心</text>
|
<text class="title">个人中心</text>
|
||||||
<text></text>
|
<text></text>
|
||||||
@@ -12,15 +12,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import MineView from '../main/MineView.vue'
|
import MineView from '../main/MineView.vue'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const safeAreaTop = ref(20)
|
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const info = uni.getWindowInfo()
|
|
||||||
safeAreaTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
|
|
||||||
})
|
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
||||||
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }">
|
<view class="nav" :style="navStyle">
|
||||||
<text class="back" @click="goBack">‹</text>
|
<text class="back" @click="goBack">‹</text>
|
||||||
<text class="title">社交数据导入</text>
|
<text class="title">社交数据导入</text>
|
||||||
<text class="manage" @click="openInsights">画像</text>
|
<text class="manage" @click="openInsights">画像</text>
|
||||||
@@ -47,12 +47,13 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
import * as socialImport from '../../services/socialImport.js'
|
import * as socialImport from '../../services/socialImport.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
|
|
||||||
const pagePath = '/pages/social-import/index'
|
const pagePath = '/pages/social-import/index'
|
||||||
const platform = ref('xiaohongshu')
|
const platform = ref('xiaohongshu')
|
||||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
const { navStyle } = useMenuButtonSafeArea()
|
||||||
|
|
||||||
const platforms = [
|
const platforms = [
|
||||||
{ label: '小红书', value: 'xiaohongshu' },
|
{ label: '小红书', value: 'xiaohongshu' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
||||||
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }">
|
<view class="nav" :style="navStyle">
|
||||||
<text class="back" @click="goBack">‹</text>
|
<text class="back" @click="goBack">‹</text>
|
||||||
<text class="title">人生素材画像</text>
|
<text class="title">人生素材画像</text>
|
||||||
<text class="add" @click="openImport">导入</text>
|
<text class="add" @click="openImport">导入</text>
|
||||||
@@ -55,13 +55,14 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import * as socialImport from '../../services/socialImport.js'
|
import * as socialImport from '../../services/socialImport.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const pagePath = '/pages/social-import/insights'
|
const pagePath = '/pages/social-import/insights'
|
||||||
const insights = ref([])
|
const insights = ref([])
|
||||||
const activeStatus = ref('all')
|
const activeStatus = ref('all')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
const { navStyle } = useMenuButtonSafeArea()
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
@@ -302,10 +303,17 @@ onMounted(() => {
|
|||||||
|
|
||||||
.empty button {
|
.empty button {
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
|
min-width: 190rpx;
|
||||||
padding: 0 34rpx;
|
padding: 0 34rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
background: linear-gradient(135deg, #a855f7, #6d28d9);
|
background: linear-gradient(135deg, #a855f7, #6d28d9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
|
||||||
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }">
|
<view class="nav" :style="navStyle">
|
||||||
<text class="back" @click="goBack">‹</text>
|
<text class="back" @click="goBack">‹</text>
|
||||||
<text class="title">导入预览</text>
|
<text class="title">导入预览</text>
|
||||||
<text class="ghost"></text>
|
<text class="ghost"></text>
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import * as socialImport from '../../services/socialImport.js'
|
import * as socialImport from '../../services/socialImport.js'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const pagePath = '/pages/social-import/preview'
|
const pagePath = '/pages/social-import/preview'
|
||||||
const draft = uni.getStorageSync('social_import_draft') || {}
|
const draft = uni.getStorageSync('social_import_draft') || {}
|
||||||
@@ -55,7 +56,7 @@ const titleText = ref(draft.title || '')
|
|||||||
const contentText = ref(draft.content || '')
|
const contentText = ref(draft.content || '')
|
||||||
const approvedForAi = ref(true)
|
const approvedForAi = ref(true)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
|
const { navStyle } = useMenuButtonSafeArea()
|
||||||
|
|
||||||
const platforms = [
|
const platforms = [
|
||||||
{ label: '小红书', value: 'xiaohongshu' },
|
{ label: '小红书', value: 'xiaohongshu' },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const resolveInitialRoute = async () => {
|
|||||||
const session = await store.restoreSession()
|
const session = await store.restoreSession()
|
||||||
|
|
||||||
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
|
||||||
const target = session.hasProfile ? '/pages/main/index' : '/pages/onboarding/index'
|
const target = session.hasProfile ? '/pages/main/index?tab=script' : '/pages/onboarding/index'
|
||||||
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
|
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const getAuthHeader = () => {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createRequestId = () => {
|
||||||
|
const random = Math.random().toString(36).slice(2, 10)
|
||||||
|
return `mp-${Date.now()}-${random}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
const decodeChunk = (arrayBuffer) => {
|
const decodeChunk = (arrayBuffer) => {
|
||||||
if (typeof TextDecoder !== 'undefined') {
|
if (typeof TextDecoder !== 'undefined') {
|
||||||
return new TextDecoder('utf-8').decode(arrayBuffer)
|
return new TextDecoder('utf-8').decode(arrayBuffer)
|
||||||
@@ -37,34 +44,192 @@ const parseSseFrame = (frame) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryRuntimeResult = (requestId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: `${API_BASE_URL}/ai/runtime/result?requestId=${encodeURIComponent(requestId)}`,
|
||||||
|
method: 'GET',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...getAuthHeader()
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
success: (res) => {
|
||||||
|
const data = res.data?.data
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 200 && data) {
|
||||||
|
resolve(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error(res.data?.message || 'AI 结果还在生成中'))
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
reject(new Error(error.errMsg || 'AI 结果查询失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverRuntimeOutput = async (requestId, { onDelta, onDone }) => {
|
||||||
|
const maxAttempts = 150
|
||||||
|
for (let index = 0; index < maxAttempts; index++) {
|
||||||
|
try {
|
||||||
|
const log = await queryRuntimeResult(requestId)
|
||||||
|
const output = String(log.outputText || '').trim()
|
||||||
|
if (output) {
|
||||||
|
onDelta?.(output, output, {
|
||||||
|
type: 'delta',
|
||||||
|
metadata: { recovered: true, requestId }
|
||||||
|
})
|
||||||
|
onDone?.({
|
||||||
|
type: 'done',
|
||||||
|
metadata: { recovered: true, requestId, source: 'call-log' }
|
||||||
|
}, output)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
if (log.status === 'failed') {
|
||||||
|
throw new Error(log.errorMessage || 'AI 生成失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (index === maxAttempts - 1) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(2000)
|
||||||
|
}
|
||||||
|
throw new Error('AI 生成结果暂时没有返回')
|
||||||
|
}
|
||||||
|
|
||||||
export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone, onError }) => {
|
export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone, onError }) => {
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
let output = ''
|
let output = ''
|
||||||
let closed = false
|
let closed = false
|
||||||
|
const requestId = inputs.requestId || createRequestId()
|
||||||
|
const requestInputs = {
|
||||||
|
...inputs,
|
||||||
|
requestId
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
let requestTask
|
||||||
|
let recoveryTimer
|
||||||
|
let recoveryPromise
|
||||||
|
|
||||||
|
const clearRecoveryTimer = () => {
|
||||||
|
if (recoveryTimer) {
|
||||||
|
clearTimeout(recoveryTimer)
|
||||||
|
recoveryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverOnce = () => {
|
||||||
|
if (!recoveryPromise) {
|
||||||
|
recoveryPromise = recoverRuntimeOutput(requestId, { onDelta, onDone })
|
||||||
|
}
|
||||||
|
return recoveryPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeFromRecoveredOutput = async () => {
|
||||||
|
if (closed) return
|
||||||
|
try {
|
||||||
|
const recoveredOutput = await recoverOnce()
|
||||||
|
if (closed) return
|
||||||
|
output = recoveredOutput
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
requestTask?.abort?.()
|
||||||
|
resolve({ output })
|
||||||
|
} catch (error) {
|
||||||
|
if (!closed) {
|
||||||
|
recoveryPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRecoveryWatcher = () => {
|
||||||
|
clearRecoveryTimer()
|
||||||
|
recoveryTimer = setTimeout(() => {
|
||||||
|
completeFromRecoveredOutput()
|
||||||
|
}, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishRecovered = (message, event) => {
|
||||||
|
if (!output.trim()) return false
|
||||||
|
if (closed) return true
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
onDone?.({
|
||||||
|
type: 'done',
|
||||||
|
metadata: {
|
||||||
|
...(event?.metadata || {}),
|
||||||
|
recovered: true,
|
||||||
|
warningCode: event?.code,
|
||||||
|
warningMessage: message || event?.message
|
||||||
|
}
|
||||||
|
}, output)
|
||||||
|
resolve({ output })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverOrFail = async (message, event) => {
|
||||||
|
if (closed) return
|
||||||
|
if (finishRecovered(message, event)) return
|
||||||
|
try {
|
||||||
|
const recoveredOutput = await recoverOnce()
|
||||||
|
if (closed) return
|
||||||
|
output = recoveredOutput
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
resolve({ output })
|
||||||
|
} catch (recoverError) {
|
||||||
|
if (closed) return
|
||||||
|
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
onError?.(finalMessage, event)
|
||||||
|
reject(new Error(finalMessage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const failStream = (message, event) => {
|
const failStream = (message, event) => {
|
||||||
|
recoverOrFail(message, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const failWithoutRecovery = (message, event) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
closed = true
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
onError?.(message, event)
|
onError?.(message, event)
|
||||||
reject(new Error(message))
|
reject(new Error(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestTask = uni.request({
|
const finishWithOutputOrRecover = async () => {
|
||||||
|
if (closed) return
|
||||||
|
if (output.trim()) {
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
resolve({ output })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await recoverOrFail('AI 生成结果暂时没有返回')
|
||||||
|
}
|
||||||
|
|
||||||
|
startRecoveryWatcher()
|
||||||
|
|
||||||
|
requestTask = uni.request({
|
||||||
url: `${API_BASE_URL}/ai/runtime/stream`,
|
url: `${API_BASE_URL}/ai/runtime/stream`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { sceneCode, inputs },
|
data: { sceneCode, requestId, inputs: requestInputs },
|
||||||
header: {
|
header: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getAuthHeader()
|
...getAuthHeader()
|
||||||
},
|
},
|
||||||
enableChunked: true,
|
enableChunked: true,
|
||||||
timeout: 120000,
|
timeout: 240000,
|
||||||
success: (res) => {
|
success: async (res) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
const message = res.data?.message || 'AI流式请求失败'
|
const message = res.data?.message || 'AI流式请求失败'
|
||||||
failStream(message)
|
failWithoutRecovery(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (typeof res.data === 'string' && res.data) {
|
if (typeof res.data === 'string' && res.data) {
|
||||||
@@ -73,8 +238,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
consumeText('\n\n', failStream)
|
consumeText('\n\n', failStream)
|
||||||
}
|
}
|
||||||
closed = true
|
await finishWithOutputOrRecover()
|
||||||
resolve({ output })
|
|
||||||
},
|
},
|
||||||
fail: (error) => {
|
fail: (error) => {
|
||||||
failStream(error.errMsg || 'AI流式请求失败')
|
failStream(error.errMsg || 'AI流式请求失败')
|
||||||
|
|||||||
+172
-27
@@ -19,6 +19,16 @@ export interface StreamAiSceneOptions {
|
|||||||
onError?: (message: string, event?: AiStreamEvent) => void
|
onError?: (message: string, event?: AiStreamEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeLog {
|
||||||
|
status?: string
|
||||||
|
outputText?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRequestId = () => `web-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
const parseSseFrame = (frame: string): AiStreamEvent | null => {
|
const parseSseFrame = (frame: string): AiStreamEvent | null => {
|
||||||
const parsed = { type: 'message', data: '' }
|
const parsed = { type: 'message', data: '' }
|
||||||
frame.split(/\r?\n/).forEach((line) => {
|
frame.split(/\r?\n/).forEach((line) => {
|
||||||
@@ -33,6 +43,49 @@ const parseSseFrame = (frame: string): AiStreamEvent | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authHeaders = () => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryRuntimeResult = async (requestId: string): Promise<RuntimeLog> => {
|
||||||
|
const response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/result?requestId=${encodeURIComponent(requestId)}`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
})
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
if (response.ok && payload?.code === 200 && payload.data) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
throw new Error(payload?.message || 'AI 结果还在生成中')
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverRuntimeOutput = async (
|
||||||
|
requestId: string,
|
||||||
|
callbacks: Pick<StreamAiSceneOptions, 'onDelta' | 'onDone'>
|
||||||
|
): Promise<string> => {
|
||||||
|
const maxAttempts = 150
|
||||||
|
for (let index = 0; index < maxAttempts; index++) {
|
||||||
|
try {
|
||||||
|
const log = await queryRuntimeResult(requestId)
|
||||||
|
const output = String(log.outputText || '').trim()
|
||||||
|
if (output) {
|
||||||
|
callbacks.onDelta?.(output, output, { type: 'delta', metadata: { recovered: true, requestId } })
|
||||||
|
callbacks.onDone?.({ type: 'done', metadata: { recovered: true, requestId, source: 'call-log' } }, output)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
if (log.status === 'failed') {
|
||||||
|
throw new Error(log.errorMessage || 'AI 生成失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (index === maxAttempts - 1) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(2000)
|
||||||
|
}
|
||||||
|
throw new Error('AI 生成结果暂时没有返回')
|
||||||
|
}
|
||||||
|
|
||||||
export const streamAiScene = async ({
|
export const streamAiScene = async ({
|
||||||
sceneCode,
|
sceneCode,
|
||||||
inputs = {},
|
inputs = {},
|
||||||
@@ -41,26 +94,79 @@ export const streamAiScene = async ({
|
|||||||
onDone,
|
onDone,
|
||||||
onError
|
onError
|
||||||
}: StreamAiSceneOptions): Promise<{ output: string }> => {
|
}: StreamAiSceneOptions): Promise<{ output: string }> => {
|
||||||
const token = localStorage.getItem('access_token')
|
const requestId = String(inputs.requestId || createRequestId())
|
||||||
const response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/stream`, {
|
const requestInputs = { ...inputs, requestId }
|
||||||
method: 'POST',
|
const controller = new AbortController()
|
||||||
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 buffer = ''
|
||||||
let output = ''
|
let output = ''
|
||||||
|
let closed = false
|
||||||
|
let recovered = false
|
||||||
|
let recoveryTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let recoveryPromise: Promise<string> | undefined
|
||||||
|
|
||||||
|
const clearRecoveryTimer = () => {
|
||||||
|
if (recoveryTimer) {
|
||||||
|
clearTimeout(recoveryTimer)
|
||||||
|
recoveryTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverOnce = () => {
|
||||||
|
if (!recoveryPromise) {
|
||||||
|
recoveryPromise = recoverRuntimeOutput(requestId, { onDelta, onDone })
|
||||||
|
}
|
||||||
|
return recoveryPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeFromRecoveredOutput = async () => {
|
||||||
|
if (closed) return
|
||||||
|
try {
|
||||||
|
const recoveredOutput = await recoverOnce()
|
||||||
|
if (closed) return
|
||||||
|
output = recoveredOutput
|
||||||
|
recovered = true
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
controller.abort()
|
||||||
|
} catch {
|
||||||
|
if (!closed) recoveryPromise = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryTimer = setTimeout(() => {
|
||||||
|
completeFromRecoveredOutput()
|
||||||
|
}, 8000)
|
||||||
|
|
||||||
|
const finishRecovered = (event?: AiStreamEvent, message?: string) => {
|
||||||
|
if (!output.trim()) return false
|
||||||
|
recovered = true
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
onDone?.({
|
||||||
|
type: 'done',
|
||||||
|
metadata: {
|
||||||
|
...(event?.metadata || {}),
|
||||||
|
recovered: true,
|
||||||
|
warningCode: event?.code,
|
||||||
|
warningMessage: message || event?.message
|
||||||
|
}
|
||||||
|
}, output)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverOrThrow = async (message?: string, event?: AiStreamEvent) => {
|
||||||
|
if (finishRecovered(event, message)) return
|
||||||
|
try {
|
||||||
|
output = await recoverOnce()
|
||||||
|
recovered = true
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
|
} catch (error: any) {
|
||||||
|
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回'
|
||||||
|
onError?.(finalMessage, event)
|
||||||
|
throw new Error(finalMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const consumeText = (text: string) => {
|
const consumeText = (text: string) => {
|
||||||
buffer += text
|
buffer += text
|
||||||
@@ -76,22 +182,61 @@ export const streamAiScene = async ({
|
|||||||
output += delta
|
output += delta
|
||||||
onDelta?.(delta, output, event)
|
onDelta?.(delta, output, event)
|
||||||
} else if (event.type === 'done') {
|
} else if (event.type === 'done') {
|
||||||
|
closed = true
|
||||||
|
clearRecoveryTimer()
|
||||||
onDone?.(event, output)
|
onDone?.(event, output)
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
const message = event.message || event.code || 'AI流式请求失败'
|
throw Object.assign(new Error(event.message || event.code || 'AI 流式请求失败'), { event })
|
||||||
onError?.(message, event)
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
let response: Response
|
||||||
const { value, done } = await reader.read()
|
try {
|
||||||
if (done) break
|
response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/stream`, {
|
||||||
consumeText(decoder.decode(value, { stream: true }))
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sceneCode, requestId, inputs: requestInputs }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!closed) await recoverOrThrow(error?.message)
|
||||||
|
return { output }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
await recoverOrThrow(`AI 流式请求失败(${response.status})`)
|
||||||
|
return { output }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
consumeText(decoder.decode(value, { stream: true }))
|
||||||
|
if (closed || recovered) break
|
||||||
|
}
|
||||||
|
if (!closed && !recovered) {
|
||||||
|
consumeText(decoder.decode())
|
||||||
|
if (buffer.trim()) consumeText('\n\n')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!closed) {
|
||||||
|
await recoverOrThrow(error?.message, error?.event)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearRecoveryTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!output.trim() && !closed) {
|
||||||
|
await recoverOrThrow('AI 生成结果暂时没有返回')
|
||||||
}
|
}
|
||||||
consumeText(decoder.decode())
|
|
||||||
if (buffer.trim()) consumeText('\n\n')
|
|
||||||
return { output }
|
return { output }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-3
@@ -49,6 +49,70 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const messageQueue = ref<QueuedMessage[]>([])
|
const messageQueue = ref<QueuedMessage[]>([])
|
||||||
const isProcessingQueue = ref(false)
|
const isProcessingQueue = ref(false)
|
||||||
const queueProcessingInterval = ref<number | null>(null)
|
const queueProcessingInterval = ref<number | null>(null)
|
||||||
|
const streamBuffers = new Map<string, string>()
|
||||||
|
const streamDoneIds = new Set<string>()
|
||||||
|
const streamTimers = new Map<string, number>()
|
||||||
|
const TYPEWRITER_INTERVAL = 18
|
||||||
|
const TYPEWRITER_STEP = 1
|
||||||
|
|
||||||
|
const stopStreamTimer = (messageId: string) => {
|
||||||
|
const timer = streamTimers.get(messageId)
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
streamTimers.delete(messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopStreamTypewriter = (messageId: string) => {
|
||||||
|
stopStreamTimer(messageId)
|
||||||
|
streamBuffers.delete(messageId)
|
||||||
|
streamDoneIds.delete(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushStreamBuffer = (messageId: string) => {
|
||||||
|
const message = messages.value.find(m => m.id === messageId)
|
||||||
|
if (!message) {
|
||||||
|
stopStreamTypewriter(messageId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const buffer = streamBuffers.get(messageId) || ''
|
||||||
|
if (buffer) {
|
||||||
|
const nextText = buffer.slice(0, TYPEWRITER_STEP)
|
||||||
|
message.content += nextText
|
||||||
|
streamBuffers.set(messageId, buffer.slice(TYPEWRITER_STEP))
|
||||||
|
message.status = 'sending'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (streamDoneIds.has(messageId)) {
|
||||||
|
message.status = 'sent'
|
||||||
|
streamDoneIds.delete(messageId)
|
||||||
|
}
|
||||||
|
stopStreamTimer(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureStreamTimer = (messageId: string) => {
|
||||||
|
if (streamTimers.has(messageId)) return
|
||||||
|
const timer = window.setInterval(() => flushStreamBuffer(messageId), TYPEWRITER_INTERVAL)
|
||||||
|
streamTimers.set(messageId, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendStreamText = (messageId: string, text: string) => {
|
||||||
|
if (!text) return
|
||||||
|
streamBuffers.set(messageId, `${streamBuffers.get(messageId) || ''}${text}`)
|
||||||
|
ensureStreamTimer(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishStreamText = (messageId: string) => {
|
||||||
|
streamDoneIds.add(messageId)
|
||||||
|
ensureStreamTimer(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAllStreamTypewriters = () => {
|
||||||
|
streamTimers.forEach(timer => window.clearInterval(timer))
|
||||||
|
streamTimers.clear()
|
||||||
|
streamBuffers.clear()
|
||||||
|
streamDoneIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const currentMessages = computed(() => {
|
const currentMessages = computed(() => {
|
||||||
@@ -457,7 +521,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
// 添加AI消息到队列,使用后端的messageId
|
// 添加AI消息到队列,使用后端的messageId
|
||||||
const aiMessage = addMessage({
|
const aiMessage = addMessage({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
content: wsMessage.content.trim(),
|
content: '',
|
||||||
type: 'ai',
|
type: 'ai',
|
||||||
conversationId: wsMessage.conversationId || currentSession.value?.id,
|
conversationId: wsMessage.conversationId || currentSession.value?.id,
|
||||||
timestamp: wsMessage.createTime || new Date().toISOString()
|
timestamp: wsMessage.createTime || new Date().toISOString()
|
||||||
@@ -467,6 +531,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
// 立即处理队列
|
// 立即处理队列
|
||||||
await processMessageQueue()
|
await processMessageQueue()
|
||||||
|
appendStreamText(aiMessage.id, wsMessage.content?.trim() || '')
|
||||||
|
finishStreamText(aiMessage.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => {
|
const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => {
|
||||||
@@ -496,25 +562,28 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
if (wsMessage.type === 'AI_STREAM_START') {
|
if (wsMessage.type === 'AI_STREAM_START') {
|
||||||
isTyping.value = true
|
isTyping.value = true
|
||||||
|
stopStreamTypewriter(message.id)
|
||||||
|
message.content = ''
|
||||||
message.status = 'sending'
|
message.status = 'sending'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wsMessage.type === 'AI_STREAM_DELTA') {
|
if (wsMessage.type === 'AI_STREAM_DELTA') {
|
||||||
isTyping.value = false
|
isTyping.value = false
|
||||||
message.content += wsMessage.content || ''
|
appendStreamText(message.id, wsMessage.content || '')
|
||||||
message.status = 'sending'
|
message.status = 'sending'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wsMessage.type === 'AI_STREAM_DONE') {
|
if (wsMessage.type === 'AI_STREAM_DONE') {
|
||||||
isTyping.value = false
|
isTyping.value = false
|
||||||
message.status = 'sent'
|
finishStreamText(message.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wsMessage.type === 'AI_STREAM_ERROR') {
|
if (wsMessage.type === 'AI_STREAM_ERROR') {
|
||||||
isTyping.value = false
|
isTyping.value = false
|
||||||
|
stopStreamTypewriter(message.id)
|
||||||
message.status = 'failed'
|
message.status = 'failed'
|
||||||
message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用'
|
message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用'
|
||||||
if (!message.content) {
|
if (!message.content) {
|
||||||
@@ -653,6 +722,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
const disconnectWebSocket = () => {
|
const disconnectWebSocket = () => {
|
||||||
stompWebSocketService.disconnect()
|
stompWebSocketService.disconnect()
|
||||||
|
stopAllStreamTypewriters()
|
||||||
wsConnected.value = false
|
wsConnected.value = false
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user