Compare commits

...

10 Commits

36 changed files with 3356 additions and 1217 deletions
@@ -61,8 +61,11 @@ public class TtsController {
@GetMapping("/tasks/by-source") @GetMapping("/tasks/by-source")
public Result<TtsTaskResponse> bySource(@Parameter(description = "来源类型") @RequestParam String sourceType, public Result<TtsTaskResponse> bySource(@Parameter(description = "来源类型") @RequestParam String sourceType,
@Parameter(description = "来源 ID") @RequestParam String sourceId, @Parameter(description = "来源 ID") @RequestParam String sourceId,
@Parameter(description = "音色") @RequestParam(required = false) String voice) { @Parameter(description = "音色") @RequestParam(required = false) String voice,
return Result.success(ttsTaskService.getBySource(sourceType, sourceId, voice)); @Parameter(description = "语速") @RequestParam(required = false) Double speechRate,
@Parameter(description = "音调") @RequestParam(required = false) Double pitch,
@Parameter(description = "情绪") @RequestParam(required = false) String emotion) {
return Result.success(ttsTaskService.getBySource(sourceType, sourceId, voice, speechRate, pitch, emotion));
} }
@Operation(summary = "获取音频文件", description = "返回已合成的音频音频文件(MP3 或 WAV 格式)。") @Operation(summary = "获取音频文件", description = "返回已合成的音频音频文件(MP3 或 WAV 格式)。")
@@ -3,6 +3,8 @@ package com.emotion.dto.request.tts;
import lombok.Data; import lombok.Data;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Size; import javax.validation.constraints.Size;
@Data @Data
@@ -18,4 +20,15 @@ public class TtsTaskCreateRequest {
@Size(max = 64) @Size(max = 64)
private String voice; private String voice;
@DecimalMin("0.60")
@DecimalMax("1.40")
private Double speechRate;
@DecimalMin("-20.00")
@DecimalMax("20.00")
private Double pitch;
@Size(max = 32)
private String emotion;
} }
@@ -12,6 +12,9 @@ public class TtsTaskResponse {
private String sourceId; private String sourceId;
private String status; private String status;
private String voice; private String voice;
private Double speechRate;
private Double pitch;
private String emotion;
private String audioUrl; private String audioUrl;
private Long durationMs; private Long durationMs;
private String errorMessage; private String errorMessage;
@@ -29,6 +29,9 @@ public class AiCallLog extends BaseEntity {
@TableField("user_id") @TableField("user_id")
private String userId; private String userId;
@TableField("user_name")
private String userName;
@TableField("request_id") @TableField("request_id")
private String requestId; private String requestId;
@@ -2,7 +2,37 @@ package com.emotion.service;
public interface TtsEngineClient { public interface TtsEngineClient {
TtsEngineResult synthesize(String text, String voice, String outputPath); TtsEngineResult synthesize(String text, String voice, String outputPath, SynthesisOptions options);
class SynthesisOptions {
private final Double speechRate;
private final Double pitch;
private final String emotion;
public SynthesisOptions(Double speechRate, Double pitch, String emotion) {
this.speechRate = speechRate;
this.pitch = pitch;
this.emotion = emotion;
}
public Double getSpeechRate() {
return speechRate;
}
public Double getPitch() {
return pitch;
}
public String getEmotion() {
return emotion;
}
public String cacheKey() {
return "rate=" + (speechRate == null ? "" : speechRate)
+ ";pitch=" + (pitch == null ? "" : pitch)
+ ";emotion=" + (emotion == null ? "" : emotion);
}
}
class TtsEngineResult { class TtsEngineResult {
private final boolean success; private final boolean success;
@@ -11,5 +11,6 @@ public interface TtsTaskService extends IService<TtsTask> {
TtsTaskResponse getTask(String id); TtsTaskResponse getTask(String id);
TtsTaskResponse getBySource(String sourceType, String sourceId, String voice); TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
Double speechRate, Double pitch, String emotion);
} }
@@ -69,6 +69,7 @@ public class AiRuntimeServiceImpl implements AiRuntimeService {
callLog.setRequestId(requestId); callLog.setRequestId(requestId);
callLog.setSceneCode(request.getSceneCode()); callLog.setSceneCode(request.getSceneCode());
callLog.setUserId(resolveUserId(request)); callLog.setUserId(resolveUserId(request));
callLog.setUserName(request.getUserName());
callLog.setInputText(JSON.toJSONString(request.getInputs())); callLog.setInputText(JSON.toJSONString(request.getInputs()));
callLog.setStatus("running"); callLog.setStatus("running");
@@ -221,6 +222,7 @@ public class AiRuntimeServiceImpl implements AiRuntimeService {
callLog.setEndpointCode(endpoint.getEndpointCode()); callLog.setEndpointCode(endpoint.getEndpointCode());
callLog.setProviderCode(provider.getProviderCode()); callLog.setProviderCode(provider.getProviderCode());
callLog.setUserId(request.getUserId()); callLog.setUserId(request.getUserId());
callLog.setUserName(request.getUserName());
callLog.setInputText(JSON.toJSONString(request.getInputs())); callLog.setInputText(JSON.toJSONString(request.getInputs()));
callLog.setStatus("running"); callLog.setStatus("running");
callLogService.save(callLog); callLogService.save(callLog);
@@ -5,7 +5,9 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
@Service @Service
@@ -21,13 +23,23 @@ public class HttpTtsEngineClient implements TtsEngineClient {
} }
@Override @Override
public TtsEngineResult synthesize(String text, String voice, String outputPath) { public TtsEngineResult synthesize(String text, String voice, String outputPath, SynthesisOptions options) {
try { try {
Map<String, Object> body = Map.of( Map<String, Object> body = new HashMap<>();
"text", text, body.put("text", text);
"voice", voice, body.put("voice", voice);
"outputPath", outputPath body.put("outputPath", outputPath);
); if (options != null) {
if (options.getSpeechRate() != null) {
body.put("speechRate", options.getSpeechRate());
}
if (options.getPitch() != null) {
body.put("pitch", options.getPitch());
}
if (StringUtils.hasText(options.getEmotion())) {
body.put("emotion", options.getEmotion());
}
}
ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/synthesize", body, Map.class); ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/synthesize", body, Map.class);
Map<?, ?> data = response.getBody(); Map<?, ?> data = response.getBody();
boolean success = data != null && Boolean.TRUE.equals(data.get("success")); boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
@@ -19,6 +19,9 @@ import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@@ -32,6 +35,10 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
private static final String STATUS_PROCESSING = "processing"; private static final String STATUS_PROCESSING = "processing";
private static final String STATUS_SUCCESS = "success"; private static final String STATUS_SUCCESS = "success";
private static final String STATUS_FAILED = "failed"; private static final String STATUS_FAILED = "failed";
private static final double FALLBACK_SPEECH_RATE = 0.92D;
private static final double FALLBACK_PITCH = 0D;
private static final String FALLBACK_EMOTION = "story";
private static final int NATURAL_PARAGRAPH_LIMIT = 140;
private final EpicScriptMapper epicScriptMapper; private final EpicScriptMapper epicScriptMapper;
private final TtsEngineClient ttsEngineClient; private final TtsEngineClient ttsEngineClient;
@@ -52,6 +59,15 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
@Value("${emotion.tts.default-voice:default_zh_female}") @Value("${emotion.tts.default-voice:default_zh_female}")
private String defaultVoice; private String defaultVoice;
@Value("${emotion.tts.default-speech-rate:0.92}")
private double defaultSpeechRate;
@Value("${emotion.tts.default-pitch:0}")
private double defaultPitch;
@Value("${emotion.tts.default-emotion:story}")
private String defaultEmotion;
public TtsTaskServiceImpl(EpicScriptMapper epicScriptMapper, public TtsTaskServiceImpl(EpicScriptMapper epicScriptMapper,
TtsEngineClient ttsEngineClient, TtsEngineClient ttsEngineClient,
@Qualifier("taskExecutor") Executor taskExecutor) { @Qualifier("taskExecutor") Executor taskExecutor) {
@@ -70,15 +86,16 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
String sourceType = normalizeSourceType(request.getSourceType()); String sourceType = normalizeSourceType(request.getSourceType());
String sourceId = request.getSourceId().trim(); String sourceId = request.getSourceId().trim();
String voice = resolveVoice(request.getVoice()); String voice = resolveVoice(request.getVoice());
TtsEngineClient.SynthesisOptions options = resolveOptions(request);
String cleaned = cleanText(loadSourceText(userId, sourceType, sourceId)); String cleaned = cleanText(loadSourceText(userId, sourceType, sourceId));
if (!StringUtils.hasText(cleaned)) { if (!StringUtils.hasText(cleaned)) {
throw new IllegalArgumentException("Source text is empty"); throw new IllegalArgumentException("Source text is empty");
} }
if (cleaned.length() > maxTextLength) { if (cleaned.length() > maxTextLength) {
cleaned = cleaned.substring(0, maxTextLength); cleaned = limitReadableText(cleaned, maxTextLength);
} }
String hash = DigestUtils.md5DigestAsHex((voice + "\n" + cleaned).getBytes(StandardCharsets.UTF_8)); String hash = DigestUtils.md5DigestAsHex((voice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
TtsTask owned = findOwnedTask(userId, sourceType, sourceId, voice, hash); TtsTask owned = findOwnedTask(userId, sourceType, sourceId, voice, hash);
if (owned != null) { if (owned != null) {
incrementRequestCount(owned); incrementRequestCount(owned);
@@ -100,7 +117,7 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
TtsTask task = buildTask(userId, sourceType, sourceId, voice, hash, cleaned.length()); TtsTask task = buildTask(userId, sourceType, sourceId, voice, hash, cleaned.length());
save(task); save(task);
String synthesisText = cleaned; String synthesisText = cleaned;
CompletableFuture.runAsync(() -> process(task.getId(), synthesisText, voice, task.getAudioPath()), taskExecutor); CompletableFuture.runAsync(() -> process(task.getId(), synthesisText, voice, task.getAudioPath(), options), taskExecutor);
return toResponse(task); return toResponse(task);
} }
@@ -115,20 +132,22 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
} }
@Override @Override
public TtsTaskResponse getBySource(String sourceType, String sourceId, String voice) { public TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
Double speechRate, Double pitch, String emotion) {
String userId = currentUserId(); String userId = currentUserId();
TtsTask task = getOne(new LambdaQueryWrapper<TtsTask>() String normalizedSourceType = normalizeSourceType(sourceType);
.eq(TtsTask::getUserId, userId) String normalizedVoice = resolveVoice(voice);
.eq(TtsTask::getSourceType, normalizeSourceType(sourceType)) TtsEngineClient.SynthesisOptions options = resolveOptions(speechRate, pitch, emotion);
.eq(TtsTask::getSourceId, sourceId) String cleaned = cleanText(loadSourceText(userId, normalizedSourceType, sourceId));
.eq(TtsTask::getVoice, resolveVoice(voice)) if (cleaned.length() > maxTextLength) {
.eq(TtsTask::getIsDeleted, 0) cleaned = limitReadableText(cleaned, maxTextLength);
.orderByDesc(TtsTask::getCreateTime) }
.last("LIMIT 1")); String hash = DigestUtils.md5DigestAsHex((normalizedVoice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
TtsTask task = findOwnedTask(userId, normalizedSourceType, sourceId, normalizedVoice, hash);
return task == null ? null : toResponse(task); return task == null ? null : toResponse(task);
} }
private void process(String taskId, String text, String voice, String outputPath) { private void process(String taskId, String text, String voice, String outputPath, TtsEngineClient.SynthesisOptions options) {
try { try {
TtsTask task = getById(taskId); TtsTask task = getById(taskId);
if (task == null) { if (task == null) {
@@ -138,7 +157,7 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
task.setErrorMessage(null); task.setErrorMessage(null);
updateById(task); updateById(task);
TtsEngineClient.TtsEngineResult result = ttsEngineClient.synthesize(text, voice, outputPath); TtsEngineClient.TtsEngineResult result = ttsEngineClient.synthesize(text, voice, outputPath, options);
task = getById(taskId); task = getById(taskId);
if (task == null) { if (task == null) {
return; return;
@@ -220,13 +239,15 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
StringBuilder text = new StringBuilder(); StringBuilder text = new StringBuilder();
append(text, script.getTitle()); append(text, script.getTitle());
Map<String, Object> plotJson = script.getPlotJson();
Object fullContent = plotJson == null ? null : plotJson.get("fullContent");
if (fullContent != null && StringUtils.hasText(String.valueOf(fullContent))) {
append(text, String.valueOf(fullContent));
} else {
append(text, script.getPlotIntro()); append(text, script.getPlotIntro());
append(text, script.getPlotTurning()); append(text, script.getPlotTurning());
append(text, script.getPlotClimax()); append(text, script.getPlotClimax());
append(text, script.getPlotEnding()); append(text, script.getPlotEnding());
Map<String, Object> plotJson = script.getPlotJson();
if (plotJson != null && plotJson.get("fullContent") != null) {
append(text, String.valueOf(plotJson.get("fullContent")));
} }
return text.toString(); return text.toString();
} }
@@ -235,9 +256,39 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
if (text == null) { if (text == null) {
return ""; return "";
} }
return text.replaceAll("[#>*_`\\-]", "") String normalized = text.replace("\r\n", "\n")
.replaceAll("\\s+", " ") .replace('\r', '\n')
.trim(); .replaceAll("!\\[[^\\]]*]\\([^)]*\\)", "")
.replaceAll("\\[([^\\]]+)]\\([^)]*\\)", "$1")
.replaceAll("(?m)^\\s{0,3}#{1,6}\\s*", "")
.replaceAll("(?m)^\\s*>\\s?", "")
.replaceAll("(?m)^\\s*[-*+]\\s+", "")
.replaceAll("(?m)^\\s*\\d+[.)、]\\s+", "")
.replaceAll("<[^>]+>", "")
.replaceAll("[*_`~]", "")
.replaceAll("[“”]", "\"")
.replaceAll("[‘’]", "'")
.replaceAll("\\.{3,}", "……")
.replaceAll("-{2,}", "")
.replaceAll("[\\t\\u00A0]+", " ")
.replaceAll(" {2,}", " ")
.replaceAll("(?<=[\\p{IsHan}])[ \\t\\u00A0]+(?=[\\p{IsHan}])", "")
.replaceAll("[ \\t\\u00A0]*([,。!?;:、,.!?;:])[ \\t\\u00A0]*", "$1")
.replaceAll(",", "")
.replaceAll("!", "")
.replaceAll("\\?", "")
.replaceAll(";", "")
.replaceAll(":", "");
List<String> paragraphs = new ArrayList<>();
for (String paragraph : normalized.split("\\n+")) {
String trimmed = paragraph.trim();
if (!StringUtils.hasText(trimmed)) {
continue;
}
paragraphs.addAll(toReadableParagraphs(trimmed));
}
return String.join("\n\n", paragraphs).trim();
} }
private TtsTaskResponse toResponse(TtsTask task) { private TtsTaskResponse toResponse(TtsTask task) {
@@ -253,6 +304,23 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
.build(); .build();
} }
private TtsEngineClient.SynthesisOptions resolveOptions(TtsTaskCreateRequest request) {
return resolveOptions(request.getSpeechRate(), request.getPitch(), request.getEmotion());
}
private TtsEngineClient.SynthesisOptions resolveOptions(Double requestSpeechRate, Double requestPitch, String requestEmotion) {
double speechRate = requestSpeechRate == null ? defaultOrFallback(defaultSpeechRate, FALLBACK_SPEECH_RATE) : requestSpeechRate;
double pitch = requestPitch == null ? defaultPitch : requestPitch;
String emotion = StringUtils.hasText(requestEmotion)
? requestEmotion.trim()
: (StringUtils.hasText(defaultEmotion) ? defaultEmotion.trim() : FALLBACK_EMOTION);
return new TtsEngineClient.SynthesisOptions(
round(clamp(speechRate, 0.60D, 1.40D)),
round(clamp(pitch, -20D, 20D)),
emotion.toLowerCase(Locale.ROOT)
);
}
private String currentUserId() { private String currentUserId() {
String userId = UserContextHolder.getCurrentUserId(); String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) { if (!StringUtils.hasText(userId)) {
@@ -275,6 +343,91 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
} }
} }
private static List<String> toReadableParagraphs(String paragraph) {
List<String> parts = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int index = 0; index < paragraph.length(); index++) {
char ch = paragraph.charAt(index);
current.append(ch);
if (isHardSentenceEnd(ch) || (current.length() >= NATURAL_PARAGRAPH_LIMIT && isSoftPause(ch))) {
addReadablePart(parts, current);
}
}
addReadablePart(parts, current);
return parts;
}
private static void addReadablePart(List<String> parts, StringBuilder current) {
String value = current.toString().trim();
current.setLength(0);
if (!StringUtils.hasText(value)) {
return;
}
if (value.length() > NATURAL_PARAGRAPH_LIMIT + 40) {
splitLongText(parts, value);
return;
}
parts.add(ensureSentenceEnding(value));
}
private static void splitLongText(List<String> parts, String value) {
StringBuilder chunk = new StringBuilder();
for (int index = 0; index < value.length(); index++) {
char ch = value.charAt(index);
chunk.append(ch);
if (chunk.length() >= NATURAL_PARAGRAPH_LIMIT) {
parts.add(ensureSentenceEnding(chunk.toString().trim()));
chunk.setLength(0);
}
}
if (chunk.length() > 0) {
parts.add(ensureSentenceEnding(chunk.toString().trim()));
}
}
private static String ensureSentenceEnding(String value) {
if (!StringUtils.hasText(value)) {
return "";
}
char last = value.charAt(value.length() - 1);
return isHardSentenceEnd(last) || isSoftPause(last) ? value : value + "";
}
private static boolean isHardSentenceEnd(char ch) {
return ch == '。' || ch == '' || ch == '' || ch == '' || ch == '…';
}
private static boolean isSoftPause(char ch) {
return ch == '' || ch == '、' || ch == '';
}
private static String limitReadableText(String text, int limit) {
if (text.length() <= limit) {
return text;
}
String truncated = text.substring(0, limit);
int cut = Math.max(
Math.max(truncated.lastIndexOf('。'), truncated.lastIndexOf('')),
Math.max(truncated.lastIndexOf(''), truncated.lastIndexOf('\n'))
);
if (cut > limit * 0.75) {
return truncated.substring(0, cut + 1).trim();
}
return ensureSentenceEnding(truncated.trim());
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
private static double round(double value) {
return Math.round(value * 100D) / 100D;
}
private static double defaultOrFallback(double value, double fallback) {
return value <= 0D ? fallback : value;
}
private static String joinPath(String prefix, String filename) { private static String joinPath(String prefix, String filename) {
if (prefix.endsWith("/")) { if (prefix.endsWith("/")) {
return prefix + filename; return prefix + filename;
@@ -69,6 +69,9 @@ emotion:
public-url-prefix: /tts/audio public-url-prefix: /tts/audio
max-text-length: 5000 max-text-length: 5000
default-voice: default_zh_female default-voice: default_zh_female
default-speech-rate: 0.92
default-pitch: 0
default-emotion: story
# Speech-to-text config # Speech-to-text config
asr: asr:
@@ -106,6 +106,9 @@ emotion:
public-url-prefix: /tts/audio public-url-prefix: /tts/audio
max-text-length: 5000 max-text-length: 5000
default-voice: default_zh_female default-voice: default_zh_female
default-speech-rate: 0.92
default-pitch: 0
default-emotion: story
# Speech-to-text config # Speech-to-text config
asr: asr:
@@ -0,0 +1,2 @@
-- 调用日志表新增 user_name 字段
ALTER TABLE t_ai_call_log ADD COLUMN user_name VARCHAR(100) DEFAULT NULL COMMENT '用户昵称' AFTER user_id;
@@ -80,6 +80,7 @@ class AiRuntimeServiceImplTest {
request.setSceneCode("script_generate"); request.setSceneCode("script_generate");
request.setUserId("user-1"); request.setUserId("user-1");
request.setRequestId("client-request-1"); request.setRequestId("client-request-1");
request.setUserName("测试用户");
List<AiStreamEvent> events = new ArrayList<>(); List<AiStreamEvent> events = new ArrayList<>();
service.invokeStream(request, events::add); service.invokeStream(request, events::add);
@@ -98,5 +99,6 @@ class AiRuntimeServiceImplTest {
assertEquals("client-request-1", savedLog.getRequestId()); assertEquals("client-request-1", savedLog.getRequestId());
assertEquals("success", savedLog.getStatus()); assertEquals("success", savedLog.getStatus());
assertEquals("完整输出", savedLog.getOutputText()); assertEquals("完整输出", savedLog.getOutputText());
assertEquals("测试用户", savedLog.getUserName());
} }
} }
@@ -12,11 +12,19 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class TtsTaskServiceTest { class TtsTaskServiceTest {
@Test @Test
@DisplayName("cleanText strips markdown and normalizes whitespace") @DisplayName("cleanText strips markdown but keeps Chinese narration rhythm")
void cleanTextStripsMarkdownAndNormalizesWhitespace() { void cleanTextStripsMarkdownButKeepsChineseNarrationRhythm() {
String cleaned = TtsTaskServiceImpl.cleanText("# Title\n\n> **hello** `world` - ok"); String cleaned = TtsTaskServiceImpl.cleanText("# 第一章\n\n> **她 终于** 看见了自己\n\n- 转身离开");
assertEquals("Title hello world ok", cleaned); assertEquals("第一章。\n\n她终于看见了自己。\n\n转身离开。", cleaned);
}
@Test
@DisplayName("cleanText preserves sentence punctuation for natural pauses")
void cleanTextPreservesSentencePunctuationForNaturalPauses() {
String cleaned = TtsTaskServiceImpl.cleanText("他说: 这一次,我想自己选择!\n\n你听见了吗?");
assertEquals("他说:这一次,我想自己选择!\n\n你听见了吗?", cleaned);
} }
@Test @Test
@@ -28,6 +36,9 @@ class TtsTaskServiceTest {
@Test @Test
@DisplayName("TtsEngineResult exposes synthesis result fields") @DisplayName("TtsEngineResult exposes synthesis result fields")
void ttsEngineResultExposesFields() { void ttsEngineResultExposesFields() {
TtsEngineClient.SynthesisOptions options = new TtsEngineClient.SynthesisOptions(0.92D, 0D, "story");
assertEquals("rate=0.92;pitch=0.0;emotion=story", options.cacheKey());
TtsEngineClient.TtsEngineResult result = TtsEngineClient.TtsEngineResult result =
new TtsEngineClient.TtsEngineResult(true, "/tmp/a.mp3", 1200L, null); new TtsEngineClient.TtsEngineResult(true, "/tmp/a.mp3", 1200L, null);
+38 -2
View File
@@ -1,5 +1,6 @@
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -16,6 +17,42 @@ class SynthesizeRequest(BaseModel):
text: str = Field(min_length=1, max_length=5000) text: str = Field(min_length=1, max_length=5000)
voice: str = "default_zh_female" voice: str = "default_zh_female"
outputPath: str outputPath: str
speechRate: Optional[float] = Field(default=0.92, ge=0.6, le=1.4)
pitch: Optional[float] = Field(default=0.0, ge=-20.0, le=20.0)
emotion: Optional[str] = "story"
def clamp(value: float, minimum: float, maximum: float) -> float:
return max(minimum, min(maximum, value))
def resolve_piper_args(request: SynthesizeRequest) -> list[str]:
speech_rate = clamp(float(request.speechRate or 0.92), 0.6, 1.4)
emotion = (request.emotion or "story").lower()
length_scale = round(1.0 / speech_rate, 2)
sentence_silence = 0.46
noise_scale = 0.64
noise_w = 0.72
if emotion in {"calm", "soft", "warm"}:
sentence_silence = 0.5
noise_scale = 0.58
noise_w = 0.68
elif emotion in {"story", "narration", "expressive"}:
sentence_silence = 0.48
noise_scale = 0.68
noise_w = 0.76
return [
"--sentence-silence",
str(sentence_silence),
"--length_scale",
str(length_scale),
"--noise_scale",
str(noise_scale),
"--noise_w",
str(noise_w),
]
@app.get("/health") @app.get("/health")
@@ -47,8 +84,7 @@ def synthesize(request: SynthesizeRequest):
str(PIPER_CONFIG), str(PIPER_CONFIG),
"--output_file", "--output_file",
str(output), str(output),
"--sentence-silence", *resolve_piper_args(request),
"0.35",
], ],
input=request.text, input=request.text,
text=True, text=True,
@@ -0,0 +1,234 @@
# 如愿星球小程序修改需求整理
## 1. 需求背景
根据图片中的《如愿星球小程序修改意见整理》,本需求文档用于记录小程序后续 UI、交互和生成后对话能力的调整方向。
本次需求主要围绕两个页面状态:
- 爽文生成首页
- 生成后的小说对话界面
目标是在不破坏现有 AI 生成、流式输出、语音输入、历史记录、小说保存等流程的前提下,优化首页布局、底部导航、输入区位置,以及生成后继续对话修改小说的能力。
## 2. 爽文生成首页需求
### 2.1 默认进入爽文生成页
打开小程序后,默认进入爽文生成页面。
要求:
- 用户登录后进入小程序首页时,默认展示爽文生成页面。
- 不应默认进入个人资料页面、人生轨迹页面或其他页面。
- 如果后续存在 tab 参数或跳转来源,也应优先保证普通启动路径进入爽文生成页面。
### 2.2 底部导航位置调整
当前底部导航需要调整为:
- 爽文生成位于底部导航中间位置。
- 人生轨迹位于底部导航最左侧。
要求:
- 底部导航仍保留现有核心入口。
- 调整后不影响原有 tab 切换能力。
- 导航文案、图标和选中态需要与现有主题风格保持一致。
### 2.3 首页主标题放大
首页文案“今天有什么心愿想实现”需要更醒目。
要求:
- “今天有什么心愿想实现”相关文案整体可以比当前更大一些。
- 标题需要居中展示。
- 标题放大后不能与顶部操作区、灵感区、输入区重叠。
- 保持当前星空、星球、紫色主题风格。
### 2.4 “灵感一下”和“换一换”尺寸统一
“灵感一下”和“换一换”按钮/文案的视觉大小需要调整一致,并且整体再小一些。
要求:
- 两个文案在视觉上高度、字号、对齐方式保持协调。
- “换一换”仍然保持可点击。
- 调整后不影响灵感卡片刷新逻辑。
### 2.5 文字输入框放到界面最下方
首页文字输入框需要放到界面最下方。
要求:
- 输入框固定或稳定展示在页面底部区域。
- 不应被底部导航遮挡。
- 不应与小程序系统安全区冲突。
- 保留发送按钮能力。
- 保留语音输入入口和现有输入能力。
### 2.6 输入框点击后可变高
文字输入框点击后可以变高一些,方便用户查看已经输入的文字。
要求:
- 未聚焦时保持紧凑,不占用过多空间。
- 聚焦后输入区域高度增加。
- 支持查看较长输入内容。
- 高度变化不应导致页面元素错位或遮挡。
- 输入、发送、语音识别流程不受影响。
## 3. 生成后对话界面需求
### 3.1 生成后支持继续对话
用户输入并生成小说后,页面需要进入对话界面。
要求:
- 生成完成后,用户可以继续输入文字。
- 支持语音转文字输入。
- 用户可以直接与 agent 对话。
- 对话内容用于对当前生成的小说提出修改建议。
### 3.2 支持对当前小说提出修改建议
生成后的对话不是重新开始一个无上下文聊天,而是围绕当前生成小说继续沟通。
要求:
- agent 需要知道当前小说内容。
- 用户可以提出“换个方向”“这里不像我”“增加某种情节”等修改需求。
- 后台需要能识别当前修改建议与当前小说的关联关系。
- 后续重新生成时,需要基于当前小说和用户新需求生成。
### 3.3 左上角增加历史回顾功能
生成后对话界面左上角需要增加历史回顾入口。
要求:
- 用户可以查看历史生成记录或历史对话记录。
- 历史回顾入口不应与微信原生右上角胶囊按钮冲突。
- 历史入口需要与当前页面主题风格一致。
### 3.4 小说卡片支持收缩/折叠
生成出来的小说卡片最好可以收缩或折叠。
要求:
- 默认状态可以展示小说核心内容。
- 用户可以收起长文本,减少页面占用。
- 用户也可以重新展开查看完整内容。
- 收缩/展开不影响后续对话输入。
- 收缩/展开状态需要有明确的视觉提示。
### 3.5 生成过程中的透明卡片 UI 保留
生成过程中,当前透明卡片 UI 效果可以保留。
要求:
- 生成过程中继续使用透明卡片承载流式输出。
- 保持当前逐字输出体验。
- 内容过长时支持滚动查看最新输出。
- 不要因为新增对话能力而破坏生成过程中的视觉样式。
### 3.6 生成结束后样式不改变
生成结束后,不要改变当前生成结果的整体样式。
要求:
- 生成完成后,小说展示样式应尽量延续生成过程中的透明卡片视觉。
- 不应突然切换成完全不同风格的卡片。
- 下方原有操作按钮保持当前视觉样式。
### 3.7 保留“换个方向”和“不像我”按钮
下方原有按钮:
- “换个方向”
- “不像我”
不需要改变。
要求:
- 按钮文案不变。
- 按钮位置和风格尽量保持现有设计。
- 按钮点击逻辑需要升级为进入对话确认模式。
### 3.8 点击“换个方向”或“不像我”后进入聊天确认模式
用户点击“换个方向”或“不像我”后,不应立即盲目重新生成。
要求:
- 点击后进入聊天对话模式。
- agent 需要先与用户确认具体修改需求。
- 用户确认需求后,再触发重新生成。
- 重新生成需要基于当前小说内容和用户确认后的修改方向。
## 4. 功能影响范围
预计涉及小程序以下模块:
- 爽文生成首页布局
- 底部导航配置
- 生成中流式输出展示
- 生成后小说结果展示
- 生成后对话输入区
- 语音转文字输入
- 历史记录入口
- 小说卡片折叠/展开
- “换个方向”“不像我”按钮交互
- AI 场景调用和上下文传参
## 5. 非目标
本需求整理文档只记录需求,不直接约束具体实现方案。
以下内容不在本次需求整理范围内:
- 后台 AI 配置管理页面改版
- Dify/Coze 服务商配置调整
- 管理后台埋点分析改版
- 新增付费、会员或权限体系
- 大规模重做小程序视觉主题
## 6. 验收标准
### 6.1 首页验收
- 打开小程序后默认进入爽文生成页面。
- 底部导航中,爽文生成位于中间,人生轨迹位于最左侧。
- 首页标题更醒目,居中展示。
- “灵感一下”和“换一换”大小一致,并比当前更小。
- 输入框位于界面最下方,不被底部导航和安全区遮挡。
- 输入框聚焦后可以变高,方便查看已输入文字。
### 6.2 生成后对话验收
- 小说生成完成后,用户可以继续输入文字与 agent 对话。
- 支持语音转文字后继续对话。
- 用户可以围绕当前小说提出修改意见。
- 左上角有历史回顾入口。
- 小说卡片支持收缩和展开。
- 生成过程透明卡片 UI 保留。
- 生成结束后整体样式不突变。
- “换个方向”“不像我”按钮保留。
- 点击“换个方向”或“不像我”后,先进入对话确认模式,再根据确认后的需求重新生成。
### 6.3 回归验收
- 原有爽文生成能力正常。
- 原有流式输出和逐字展示正常。
- 原有语音输入能力正常。
- 原有历史记录和小说保存能力正常。
- 底部导航切换正常。
- 页面顶部按钮不与微信原生胶囊按钮重叠。
+43 -5
View File
@@ -18,6 +18,33 @@ const parseSseFrame = (frame) => {
} }
}; };
const findOverlapLength = (current, next) => {
const max = Math.min(current.length, next.length);
for (let size = max; size > 0; size -= 1) {
if (current.slice(-size) === next.slice(0, size)) return size;
}
return 0;
};
const mergeStreamOutput = (current, chunk) => {
const next = String(chunk || '');
if (!next) return { output: current, delta: '' };
if (!current) return { output: next, delta: next };
if (next === current) return { output: current, delta: '' };
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' };
if (next.startsWith(current)) {
return { output: next, delta: next.slice(current.length) };
}
const currentIndex = next.length > current.length ? next.indexOf(current) : -1;
if (currentIndex >= 0) {
return { output: next, delta: next.slice(currentIndex + current.length) };
}
const overlap = findOverlapLength(current, next);
if (overlap < 8) return { output: current + next, delta: next };
const delta = next.slice(overlap);
return { output: current + delta, delta };
};
const authHeaders = () => { const authHeaders = () => {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
@@ -73,6 +100,7 @@ export const streamAiScene = async ({
let output = ''; let output = '';
let closed = false; let closed = false;
let recovered = false; let recovered = false;
let streamStarted = false;
let recoveryTimer; let recoveryTimer;
let recoveryPromise; let recoveryPromise;
@@ -92,6 +120,7 @@ export const streamAiScene = async ({
const completeFromRecoveredOutput = async () => { const completeFromRecoveredOutput = async () => {
if (closed) return; if (closed) return;
if (streamStarted || output.trim()) return;
try { try {
const recoveredOutput = await recoverOnce(); const recoveredOutput = await recoverOnce();
if (closed) return; if (closed) return;
@@ -107,7 +136,7 @@ export const streamAiScene = async ({
recoveryTimer = setTimeout(() => { recoveryTimer = setTimeout(() => {
completeFromRecoveredOutput(); completeFromRecoveredOutput();
}, 8000); }, 25000);
const finishRecovered = (event, message) => { const finishRecovered = (event, message) => {
if (!output.trim()) return false; if (!output.trim()) return false;
@@ -127,13 +156,13 @@ export const streamAiScene = async ({
}; };
const recoverOrThrow = async (message, event) => { const recoverOrThrow = async (message, event) => {
if (finishRecovered(event, message)) return;
try { try {
output = await recoverOnce(); output = await recoverOnce();
recovered = true; recovered = true;
closed = true; closed = true;
clearRecoveryTimer(); clearRecoveryTimer();
} catch (error) { } catch (error) {
if (finishRecovered(event, message || error?.message)) return;
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回'; const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回';
onError?.(finalMessage, event); onError?.(finalMessage, event);
throw new Error(finalMessage); throw new Error(finalMessage);
@@ -148,12 +177,19 @@ export const streamAiScene = async ({
const event = parseSseFrame(frame); const event = parseSseFrame(frame);
if (!event) return; if (!event) return;
if (event.type === 'start') { if (event.type === 'start') {
streamStarted = true;
clearRecoveryTimer();
onStart?.(event); onStart?.(event);
} else if (event.type === 'delta') { } else if (event.type === 'delta') {
const delta = event.content || ''; streamStarted = true;
output += delta; clearRecoveryTimer();
onDelta?.(delta, output, event); const merged = mergeStreamOutput(output, event.content);
output = merged.output;
if (merged.delta) {
onDelta?.(merged.delta, output, event);
}
} else if (event.type === 'done') { } else if (event.type === 'done') {
streamStarted = true;
closed = true; closed = true;
clearRecoveryTimer(); clearRecoveryTimer();
onDone?.(event, output); onDone?.(event, output);
@@ -191,6 +227,8 @@ export const streamAiScene = async ({
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
streamStarted = true;
clearRecoveryTimer();
consumeText(decoder.decode(value, { stream: true })); consumeText(decoder.decode(value, { stream: true }));
if (closed || recovered) break; if (closed || recovered) break;
} }
+1 -1
View File
@@ -18,7 +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 pathWriter = useTypewriterStream({ interval: 30, step: 1 });
const selectedScript = getSelectedScript(); const selectedScript = getSelectedScript();
+1 -1
View File
@@ -40,7 +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 scriptWriter = useTypewriterStream({ interval: 30, step: 1 });
// 编辑模态框状态 // 编辑模态框状态
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+1 -1
View File
@@ -94,7 +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 }); const feedbackWriter = useTypewriterStream({ interval: 30, step: 1 });
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID) // 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
const [editingEventId, setEditingEventId] = useState(null); const [editingEventId, setEditingEventId] = useState(null);
+121 -9
View File
@@ -1,8 +1,16 @@
<template> <template>
<view class="music-player" :style="{ bottom: bottomPosition }"> <view
v-if="positionReady"
class="music-player"
:style="playerStyle"
@touchstart.stop="handleTouchStart"
@touchmove.stop.prevent="handleTouchMove"
@touchend.stop="handleTouchEnd"
@touchcancel.stop="handleTouchEnd"
>
<view <view
class="music-toggle" class="music-toggle"
:class="{ playing: isPlaying }" :class="{ playing: isPlaying, dragging: isDragging }"
@click="toggleMusic" @click="toggleMusic"
> >
<view class="music-disc" :class="{ spinning: isPlaying }"></view> <view class="music-disc" :class="{ spinning: isPlaying }"></view>
@@ -12,14 +20,62 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
const isPlaying = ref(false) const isPlaying = ref(false)
const bottomPosition = ref('180rpx') const positionReady = ref(false)
const isDragging = ref(false)
const playerPosition = ref({ x: 0, y: 0 })
let audioInstance = null let audioInstance = null
let windowMetrics = {
width: 375,
height: 667,
statusBarHeight: 20,
safeAreaBottom: 0,
buttonSize: 44
}
let dragStart = null
let suppressNextClick = false
// 背景音乐 URL - 使用原型中的音乐 // 背景音乐 URL - 使用原型中的音乐
const MUSIC_URL = 'https://v3b.fal.media/files/b/0a8c9a0b/rStj8V-2tCe6bVYpCCcLN_output.mp3' const MUSIC_URL = 'https://v3b.fal.media/files/b/0a8c9a0b/rStj8V-2tCe6bVYpCCcLN_output.mp3'
const STORAGE_KEY = 'music_player_position'
const playerStyle = computed(() => ({
left: `${playerPosition.value.x}px`,
top: `${playerPosition.value.y}px`
}))
const rpxToPx = (rpx, windowWidth = windowMetrics.width) => windowWidth * rpx / 750
const clamp = (value, min, max) => Math.max(min, Math.min(max, value))
const clampPosition = (position) => {
const margin = 8
const minY = Math.max(margin, windowMetrics.statusBarHeight + 8)
const maxX = windowMetrics.width - windowMetrics.buttonSize - margin
const maxY = windowMetrics.height - windowMetrics.safeAreaBottom - windowMetrics.buttonSize - margin
return {
x: clamp(Number(position?.x) || margin, margin, Math.max(margin, maxX)),
y: clamp(Number(position?.y) || minY, minY, Math.max(minY, maxY))
}
}
const savePosition = () => {
uni.setStorageSync(STORAGE_KEY, playerPosition.value)
}
const restorePosition = () => {
const saved = uni.getStorageSync(STORAGE_KEY)
if (saved && typeof saved === 'object') {
playerPosition.value = clampPosition(saved)
return
}
playerPosition.value = clampPosition({
x: windowMetrics.width - windowMetrics.buttonSize - rpxToPx(16),
y: windowMetrics.height - windowMetrics.safeAreaBottom - windowMetrics.buttonSize - 96
})
}
const initAudio = () => { const initAudio = () => {
if (!audioInstance) { if (!audioInstance) {
@@ -49,6 +105,7 @@ const initAudio = () => {
} }
const toggleMusic = async () => { const toggleMusic = async () => {
if (suppressNextClick || isDragging.value) return
initAudio() initAudio()
if (isPlaying.value) { if (isPlaying.value) {
@@ -63,11 +120,58 @@ const toggleMusic = async () => {
} }
} }
const handleTouchStart = (event) => {
const touch = event.touches?.[0]
if (!touch) return
dragStart = {
x: touch.clientX,
y: touch.clientY,
originX: playerPosition.value.x,
originY: playerPosition.value.y,
moved: false
}
}
const handleTouchMove = (event) => {
const touch = event.touches?.[0]
if (!touch || !dragStart) return
const deltaX = touch.clientX - dragStart.x
const deltaY = touch.clientY - dragStart.y
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
dragStart.moved = true
isDragging.value = true
}
if (!dragStart.moved) return
playerPosition.value = clampPosition({
x: dragStart.originX + deltaX,
y: dragStart.originY + deltaY
})
}
const handleTouchEnd = () => {
if (dragStart?.moved) {
playerPosition.value = clampPosition(playerPosition.value)
savePosition()
suppressNextClick = true
setTimeout(() => {
suppressNextClick = false
}, 180)
}
dragStart = null
isDragging.value = false
}
onMounted(() => { onMounted(() => {
// 使用新的推荐 API 替代已弃用的 getSystemInfoSync const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
const windowInfo = uni.getWindowInfo() windowMetrics = {
const safeAreaBottom = windowInfo.safeAreaInsets?.bottom || 0 width: windowInfo.windowWidth || 375,
bottomPosition.value = `${safeAreaBottom + 96}px` height: windowInfo.windowHeight || 667,
statusBarHeight: windowInfo.statusBarHeight || 20,
safeAreaBottom: windowInfo.safeAreaInsets?.bottom || 0,
buttonSize: rpxToPx(88, windowInfo.windowWidth || 375)
}
restorePosition()
positionReady.value = true
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -86,8 +190,9 @@ defineExpose({
<style scoped> <style scoped>
.music-player { .music-player {
position: fixed; position: fixed;
right: 16rpx;
z-index: 1000; z-index: 1000;
width: 88rpx;
height: 88rpx;
} }
.music-toggle { .music-toggle {
@@ -106,6 +211,13 @@ defineExpose({
opacity: 0.4; opacity: 0.4;
} }
.music-toggle.dragging {
transform: scale(1.06);
opacity: 0.78;
border-color: rgba(216, 180, 254, 0.42);
box-shadow: 0 0 34rpx rgba(168, 85, 247, 0.32);
}
.music-toggle:active { .music-toggle:active {
transform: scale(0.95); transform: scale(0.95);
opacity: 0.6; opacity: 0.6;
+1 -1
View File
@@ -176,7 +176,7 @@ const pagePath = '/pages/life-event/form'
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 }) const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const saving = ref(false) const saving = ref(false)
const assisting = ref(false) const assisting = ref(false)
const assistWriter = useTypewriterStream({ interval: 24, step: 1 }) const assistWriter = useTypewriterStream({ interval: 32, step: 1 })
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const form = reactive({ const form = reactive({
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -53,7 +53,7 @@ const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const pathData = ref(null) const pathData = ref(null)
const pathGenerating = ref(false) const pathGenerating = ref(false)
const pathWriter = useTypewriterStream({ interval: 24, step: 1 }) const pathWriter = useTypewriterStream({ interval: 32, step: 1 })
const selectedScript = computed(() => { const selectedScript = computed(() => {
return store.scripts.find(s => s.isSelected) return store.scripts.find(s => s.isSelected)
+16 -19
View File
@@ -66,9 +66,8 @@
</view> </view>
<text class="section-subtitle">你的成长之路正在展开</text> <text class="section-subtitle">你的成长之路正在展开</text>
</view> </view>
<view class="map-btn kos-pill" @click="openMap"> <view class="social-import-btn" @click="openSocialImport">
<view class="map-icon"></view> <text>导入社交数据</text>
<text>轨迹地图</text>
</view> </view>
</view> </view>
@@ -263,8 +262,8 @@ const editProfile = () => {
uni.navigateTo({ url: '/pages/onboarding/index?edit=1' }) uni.navigateTo({ url: '/pages/onboarding/index?edit=1' })
} }
const openMap = () => { const openSocialImport = () => {
uni.navigateTo({ url: '/pages/main/PathView' }) uni.navigateTo({ url: '/pages/social-import/index' })
} }
const addFilter = () => { const addFilter = () => {
@@ -567,23 +566,21 @@ const addFilter = () => {
font-size: 24rpx; font-size: 24rpx;
} }
.map-btn { .social-import-btn {
height: 56rpx; height: 64rpx;
padding: 0 20rpx; padding: 0 22rpx;
border-radius: 999rpx; border-radius: 999rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10rpx; justify-content: center;
color: #caa9ff; color: #fff;
font-size: 22rpx; font-size: 25rpx;
} font-weight: 800;
white-space: nowrap;
.map-icon { background:
width: 24rpx; radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.26), transparent 26%),
height: 20rpx; linear-gradient(135deg, #b045ff, #612eff);
border: 3rpx solid currentColor; box-shadow: 0 14rpx 34rpx rgba(129, 66, 255, 0.34);
border-radius: 4rpx;
transform: skewY(-12deg);
} }
.filters { .filters {
@@ -6,7 +6,7 @@
<view class="topbar" :style="topbarStyle"> <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="continueCurrent">继续</button>
</view> </view>
<scroll-view class="scroll" scroll-y :show-scrollbar="false"> <scroll-view class="scroll" scroll-y :show-scrollbar="false">
@@ -28,10 +28,6 @@
<text class="stat-label">字数</text> <text class="stat-label">字数</text>
</view> </view>
</view> </view>
<view class="audio-inline" @click="trackTtsClick">
<text class="audio-inline-icon"></text>
<text class="audio-inline-text">{{ detailTtsButtonText }}</text>
</view>
</view> </view>
<view class="tabs kos-card"> <view class="tabs kos-card">
@@ -56,7 +52,11 @@
<view class="bottom-actions"> <view class="bottom-actions">
<button class="secondary-btn kos-pill" @click="goBack">返回列表</button> <button class="secondary-btn kos-pill" @click="goBack">返回列表</button>
<button class="primary-btn kos-primary" @click="selectCurrent">映射实现路径</button> <button class="voice-btn kos-pill" @click="trackTtsClick">
<text class="voice-icon">{{ detailTtsIcon }}</text>
<text>{{ detailTtsActionText }}</text>
</button>
<button class="primary-btn kos-primary" @click="continueCurrent">继续生成</button>
</view> </view>
</view> </view>
</template> </template>
@@ -83,9 +83,14 @@ const lengthText = computed(() => {
return map[script.value?.length] || script.value?.length || '中篇' return map[script.value?.length] || script.value?.length || '中篇'
}) })
const detailTtsButtonText = computed(() => { const detailTtsActionText = computed(() => {
if (!script.value?.id) return '生成保存后可语音播放' if (!script.value?.id) return '播放'
return ttsPlayer.buttonText.value if (ttsPlayer.loading.value) return '生成中'
return ttsPlayer.playing.value ? '暂停' : '播放'
})
const detailTtsIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
}) })
const outline = computed(() => { const outline = computed(() => {
@@ -120,19 +125,17 @@ const loadScript = async () => {
} }
} }
const selectCurrent = async () => { const continueCurrent = () => {
if (!script.value?.id) return if (!script.value?.id) return
analytics.track('path_select', { uni.setStorageSync('pending_open_script_chat', {
id: script.value.id
})
analytics.track('script_detail_continue_click', {
script_id: script.value.id, script_id: script.value.id,
style: script.value.style || '', style: script.value.style || '',
length: script.value.length || '' length: script.value.length || ''
}, { eventType: 'script', pagePath }) }, { eventType: 'script', pagePath })
const res = await store.selectScript(script.value.id) uni.reLaunch({ url: '/pages/main/index?tab=script' })
if (!res.success) {
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/main/PathView' })
} }
const trackTtsClick = () => { const trackTtsClick = () => {
@@ -272,41 +275,6 @@ onUnmounted(() => {
margin-top: 28rpx; margin-top: 28rpx;
} }
.audio-inline {
height: 76rpx;
margin-top: 26rpx;
border-radius: 999rpx;
padding: 0 26rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
color: #fff;
font-size: 25rpx;
font-weight: 800;
line-height: 1;
background: linear-gradient(135deg, #24c6dc, #7f5af0);
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22);
}
.audio-inline-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(5, 6, 21, 0.86);
font-size: 18rpx;
font-weight: 900;
background: rgba(255, 255, 255, 0.86);
}
.audio-inline-text {
line-height: 1.2;
text-align: center;
}
.stat { .stat {
padding: 18rpx 10rpx; padding: 18rpx 10rpx;
border-radius: 20rpx; border-radius: 20rpx;
@@ -416,13 +384,14 @@ onUnmounted(() => {
padding: 16rpx 30rpx 26rpx; padding: 16rpx 30rpx 26rpx;
box-sizing: border-box; box-sizing: border-box;
display: grid; display: grid;
grid-template-columns: 1fr 1.45fr; grid-template-columns: 1fr 1fr 1.24fr;
gap: 18rpx; gap: 18rpx;
background: rgba(5, 6, 21, 0.72); background: rgba(5, 6, 21, 0.72);
backdrop-filter: blur(24rpx); backdrop-filter: blur(24rpx);
} }
.secondary-btn, .secondary-btn,
.voice-btn,
.primary-btn { .primary-btn {
height: 82rpx; height: 82rpx;
border-radius: 999rpx; border-radius: 999rpx;
@@ -439,4 +408,15 @@ onUnmounted(() => {
.secondary-btn { .secondary-btn {
color: #caa0ff; color: #caa0ff;
} }
.voice-btn {
gap: 8rpx;
color: #e8ccff;
border: 1rpx solid rgba(192, 132, 252, 0.32);
}
.voice-icon {
font-size: 22rpx;
font-weight: 900;
}
</style> </style>
@@ -0,0 +1,829 @@
<template>
<view class="script-library">
<view class="page-head">
<view class="back-title" @click="backToScript">
<text class="back-arrow"></text>
<text class="back-text">返回</text>
</view>
<view class="head-actions">
<view class="circle-btn" @click="openSearch">
<view class="search-icon"></view>
</view>
<view class="circle-btn" @click="openMoreMenu">
<view class="more-icon">
<view></view>
<view></view>
<view></view>
</view>
</view>
</view>
</view>
<view class="type-tabs">
<text
v-for="tab in typeTabs"
:key="tab.value"
class="type-tab"
:class="{ active: activeType === tab.value }"
@click="activeType = tab.value"
>{{ tab.label }}</text>
<view class="new-script" @click="createScript">
<text class="plus"></text>
<text>新建剧本</text>
</view>
</view>
<view class="filter-bar">
<scroll-view class="status-scroll" scroll-x :show-scrollbar="false">
<view class="status-row">
<text
v-for="filter in statusFilters"
:key="filter.value"
class="status-chip"
:class="{ active: activeStatus === filter.value }"
@click="activeStatus = filter.value"
>{{ filter.label }}</text>
</view>
</scroll-view>
<view class="sort-tools">
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
<view v-for="i in 4" :key="i"></view>
</view>
</view>
</view>
<view v-if="visibleScripts.length" class="script-list" :class="{ grid: viewMode === 'grid' }">
<view
v-for="(script, index) in visibleScripts"
:key="script.id || index"
class="script-card"
@click="viewScript(script)"
>
<view class="cover" :class="'cover-' + (index % 6)">
<text>{{ getInitial(script) }}</text>
</view>
<view class="card-main">
<view class="card-top">
<view class="title-wrap">
<text class="script-title">{{ script.title }}</text>
<text class="length-badge">{{ getLengthLabel(script.length) }}</text>
</view>
<view class="right-state">
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
<text class="ellipsis" @click.stop="openScriptMenu(script)"></text>
</view>
</view>
<view class="tag-row">
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
</view>
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
<view class="meta-row">
<text>{{ getChapterCount(script) }}</text>
<text>|</text>
<text>{{ getWordCount(script) }}</text>
<text>|</text>
<text>{{ getDateText(script) }}</text>
</view>
<view v-if="getStatus(script) === 'progress'" class="progress-row">
<view class="progress-track">
<view class="progress-fill" :style="{ width: getProgress(script) + '%' }"></view>
</view>
<text>{{ getProgress(script) }}%</text>
</view>
<view v-else-if="isFavorite(script)" class="favorite-row">
<text class="favorite-star"></text>
<text>已收藏</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-card">
<view class="empty-book">
<view></view>
<view></view>
</view>
<text class="empty-title">还没有人生剧本</text>
<text class="empty-text">去爽文生成页写下一句灵感生成你的第一段平行人生</text>
<view class="empty-action" @click="createScript">新建剧本</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useAppStore } from '../../stores/app.js'
const store = useAppStore()
const activeType = ref('long')
const activeStatus = ref('all')
const keyword = ref('')
const sortMode = ref('updated')
const viewMode = ref('list')
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
const typeTabs = [
{ label: '长篇', value: 'long' },
{ label: '短篇', value: 'short' },
{ label: '风格', value: 'style' }
]
const statusFilters = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'progress' },
{ label: '已完成', value: 'done' },
{ label: '草稿箱', value: 'draft' },
{ label: '收藏夹', value: 'favorite' }
]
const fallbackScripts = [
{
id: 'demo-1',
title: '逆袭人生:从低谷到巅峰',
length: 'long',
status: 'progress',
tags: ['逆袭成长', '都市', '事业', '热血'],
summary: '从被分手、被否定的低谷开始,凭借天赋、努力与智慧,一步步逆袭成为行业巅峰,收获事业、财富...',
chapterCount: 28,
wordCount: 128000,
updatedAt: '今天 21:30',
progress: 28
},
{
id: 'demo-2',
title: '如果那年我没有放弃',
length: 'long',
status: 'done',
tags: ['成长治愈', '校园', '爱情', '温暖'],
summary: '重回十八岁,弥补遗憾,勇敢追梦,守护那些曾经错过的人和事。',
chapterCount: 36,
wordCount: 156000,
completedAt: '2025.05.10',
isFavorite: true
},
{
id: 'demo-3',
title: '重生之我在未来等你',
length: 'long',
status: 'progress',
tags: ['重生', '科幻', '爱情', '未来'],
summary: '一觉醒来,回到十年前的那一天。这一次,我不仅要改变自己的人生,还要找到你。',
chapterCount: 18,
wordCount: 83000,
updatedAt: '昨天 18:47',
progress: 46
},
{
id: 'demo-4',
title: '天才作曲家的璀璨之路',
length: 'long',
status: 'draft',
tags: ['音乐', '励志', '天赋', '梦想'],
summary: '从默默无闻到享誉全球,用音符征服世界,写下属于自己的传奇乐章。',
chapterCount: 9,
wordCount: 31000,
createdAt: '2025.05.08'
},
{
id: 'demo-5',
title: '咖啡馆里的奇遇',
length: 'short',
status: 'done',
tags: ['生活', '治愈', '奇幻', '温暖'],
summary: '一杯咖啡,一次奇遇,改变了我平凡的生活,也让我遇见了最特别的你。',
chapterCount: 1,
wordCount: 23000,
completedAt: '2025.05.01',
isFavorite: true
},
{
id: 'demo-6',
title: '赛博时代的追光者',
length: 'long',
status: 'draft',
tags: ['科幻', '未来', '冒险', '热血'],
summary: '在数据与代码构建的世界里,我追寻光明,也在黑暗中寻找真正的自由。',
chapterCount: 3,
wordCount: 12000,
createdAt: '2025.05.12'
}
]
const scripts = computed(() => {
const list = store.scripts || []
return list.length ? list : fallbackScripts
})
const visibleScripts = computed(() => {
const filtered = scripts.value.filter(script => {
const status = getStatus(script)
if (keyword.value) {
const haystack = [script.title, script.summary, script.content, script.style, ...(script.tags || [])].join(' ')
if (!haystack.includes(keyword.value)) return false
}
if (activeStatus.value === 'favorite') return isFavorite(script)
if (activeStatus.value !== 'all' && status !== activeStatus.value) return false
if (activeType.value === 'short') return script.length === 'short'
if (activeType.value === 'long') return script.length !== 'short'
return true
})
return [...filtered].sort((a, b) => {
if (sortMode.value === 'words') return Number(b.wordCount || 0) - Number(a.wordCount || 0)
if (sortMode.value === 'progress') return getProgress(b) - getProgress(a)
return String(b.updateTime || b.updatedAt || b.createTime || b.date || '').localeCompare(String(a.updateTime || a.updatedAt || a.createTime || a.date || ''))
})
})
const sortLabel = computed(() => {
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
return map[sortMode.value] || '最近更新⌄'
})
const getStatus = (script) => {
if (script.status) return script.status
if (script.isDraft) return 'draft'
if (script.isCompleted || script.completedAt) return 'done'
return script.progress ? 'progress' : 'done'
}
const getStatusLabel = (script) => {
const map = { progress: '进行中', done: '已完成', draft: '草稿' }
return map[getStatus(script)] || '已完成'
}
const getLengthLabel = (length) => {
return length === 'short' ? '短篇' : '长篇'
}
const getTags = (script) => {
if (Array.isArray(script.tags) && script.tags.length) return script.tags.slice(0, 4)
return [script.style || '逆袭成长', '都市', '事业', '热血']
}
const getChapterCount = (script) => script.chapterCount || script.chapters || Math.max(1, Math.round((script.wordCount || 30000) / 4500))
const getWordCount = (script) => {
const count = Number(script.wordCount || 0)
if (!count) return '3.1万字'
if (count >= 10000) return `${(count / 10000).toFixed(1)}万字`
return `${count}`
}
const getDateText = (script) => {
if (getStatus(script) === 'done') return `完成于:${script.completedAt || script.date || '2025.05.10'}`
if (getStatus(script) === 'draft') return `创建于:${script.createdAt || script.date || '2025.05.08'}`
return `最近更新:${script.updatedAt || script.date || '今天 21:30'}`
}
const getProgress = (script) => Math.max(1, Math.min(99, Number(script.progress || 28)))
const getInitial = (script) => (script.title || '剧').slice(0, 1)
const isFavorite = (script) => {
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
}
const openScriptChat = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.setStorageSync('pending_open_script_chat', {
id: script.id
})
uni.$emit('switchTab', 'script')
setTimeout(() => {
uni.$emit('openScriptChat', { id: script.id, script })
}, 80)
}
const viewScript = (script) => {
openScriptChat(script)
}
const openScriptDetail = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
}
const createScript = () => {
uni.$emit('switchTab', 'script')
}
const backToScript = () => {
uni.$emit('switchTab', 'script')
}
const openSearch = () => {
uni.showModal({
title: '搜索剧本',
editable: true,
placeholderText: '输入标题、标签或关键词',
success: (res) => {
if (res.confirm) keyword.value = String(res.content || '').trim()
}
})
}
const openMoreMenu = () => {
uni.showActionSheet({
itemList: ['清空搜索', '只看收藏', '查看全部'],
success: ({ tapIndex }) => {
if (tapIndex === 0) keyword.value = ''
if (tapIndex === 1) activeStatus.value = 'favorite'
if (tapIndex === 2) {
keyword.value = ''
activeStatus.value = 'all'
}
}
})
}
const toggleSort = () => {
const order = ['updated', 'words', 'progress']
sortMode.value = order[(order.indexOf(sortMode.value) + 1) % order.length]
}
const toggleViewMode = () => {
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
}
const openScriptMenu = (script) => {
const favorite = isFavorite(script)
uni.showActionSheet({
itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
const next = { ...localFavorites.value }
if (favorite) delete next[String(script.id)]
else next[String(script.id)] = true
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
}
if (tapIndex === 1) viewScript(script)
if (tapIndex === 2) openScriptDetail(script)
}
})
}
</script>
<style scoped>
.script-library {
display: flex;
flex-direction: column;
gap: 24rpx;
padding-bottom: 26rpx;
}
.page-head,
.back-title,
.head-actions,
.type-tabs,
.filter-bar,
.sort-tools,
.card-top,
.title-wrap,
.right-state,
.meta-row,
.progress-row,
.favorite-row {
display: flex;
align-items: center;
}
.page-head {
justify-content: space-between;
}
.back-title {
height: 60rpx;
gap: 10rpx;
color: rgba(255, 255, 255, 0.94);
font-weight: 900;
}
.back-arrow {
font-size: 58rpx;
line-height: 1;
transform: translateY(-2rpx);
}
.back-text {
font-size: 32rpx;
}
.head-actions {
gap: 20rpx;
}
.circle-btn {
width: 58rpx;
height: 58rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(142, 105, 255, 0.36);
background: rgba(10, 11, 38, 0.72);
}
.search-icon {
width: 25rpx;
height: 25rpx;
border: 4rpx solid #fff;
border-radius: 50%;
position: relative;
}
.search-icon::after {
content: '';
position: absolute;
right: -9rpx;
bottom: -8rpx;
width: 14rpx;
height: 4rpx;
border-radius: 999rpx;
background: #fff;
transform: rotate(45deg);
}
.more-icon {
display: flex;
gap: 5rpx;
}
.more-icon view {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: #fff;
}
.type-tabs {
justify-content: space-between;
border-bottom: 1rpx solid rgba(126, 87, 255, 0.18);
padding-bottom: 16rpx;
}
.type-tab {
position: relative;
color: rgba(224, 214, 243, 0.7);
font-size: 31rpx;
font-weight: 900;
padding: 0 20rpx 14rpx;
}
.type-tab.active {
color: #fff;
}
.type-tab.active::after {
content: '';
position: absolute;
left: 20rpx;
right: 20rpx;
bottom: -17rpx;
height: 5rpx;
border-radius: 999rpx;
background: #b246ff;
box-shadow: 0 0 18rpx rgba(178, 70, 255, 0.8);
}
.new-script {
margin-left: auto;
height: 64rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: #fff;
font-size: 24rpx;
font-weight: 800;
background: linear-gradient(135deg, #b346ff, #7330ff);
box-shadow: 0 0 26rpx rgba(168, 85, 247, 0.54);
}
.plus {
font-size: 32rpx;
line-height: 1;
}
.filter-bar {
gap: 14rpx;
}
.status-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.status-row {
display: inline-flex;
gap: 16rpx;
}
.status-chip {
height: 52rpx;
min-width: 88rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.78);
font-size: 23rpx;
border: 1rpx solid rgba(151, 111, 255, 0.42);
background: rgba(255, 255, 255, 0.02);
}
.status-chip.active {
color: #fff;
border-color: rgba(206, 82, 255, 0.92);
background: rgba(130, 48, 220, 0.42);
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.46);
}
.sort-tools {
gap: 14rpx;
flex-shrink: 0;
}
.sort-text {
color: #c99fff;
font-size: 23rpx;
}
.grid-icon {
width: 48rpx;
height: 48rpx;
border-radius: 18rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6rpx;
padding: 11rpx;
box-sizing: border-box;
border: 1rpx solid rgba(151, 111, 255, 0.32);
}
.grid-icon view {
border: 2rpx solid rgba(230, 222, 250, 0.78);
border-radius: 3rpx;
}
.script-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.script-list.grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
.script-list.grid .script-card {
grid-template-columns: 1fr;
}
.script-list.grid .cover {
width: 100%;
}
.grid-icon.active {
border-color: rgba(206, 82, 255, 0.9);
background: rgba(130, 48, 220, 0.28);
}
.script-card {
display: grid;
grid-template-columns: 150rpx 1fr;
gap: 22rpx;
min-height: 196rpx;
padding: 20rpx;
border-radius: 24rpx;
border: 1rpx solid rgba(105, 79, 210, 0.34);
background:
radial-gradient(circle at 100% 0%, rgba(112, 72, 255, 0.14), transparent 38%),
rgba(9, 12, 42, 0.72);
box-shadow: inset 0 0 28rpx rgba(92, 57, 197, 0.08);
}
.cover {
width: 150rpx;
height: 150rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 54rpx;
font-weight: 900;
overflow: hidden;
background: linear-gradient(135deg, #3b1a90, #d65cff);
}
.cover-0 { background: linear-gradient(135deg, #29135f, #9037ff 48%, #191b5e); }
.cover-1 { background: linear-gradient(135deg, #3c1c4a, #f2b3cc 48%, #16143b); }
.cover-2 { background: linear-gradient(135deg, #1a225f, #7d4cff 48%, #0a0f2c); }
.cover-3 { background: linear-gradient(135deg, #2f240b, #f7b44a 48%, #0d0a16); }
.cover-4 { background: linear-gradient(135deg, #3f2417, #d8b58a 48%, #17101d); }
.cover-5 { background: linear-gradient(135deg, #141451, #cc46ff 48%, #0c0b28); }
.card-main {
min-width: 0;
}
.card-top {
justify-content: space-between;
gap: 14rpx;
}
.title-wrap {
min-width: 0;
gap: 10rpx;
}
.script-title {
color: #fff;
font-size: 27rpx;
line-height: 1.25;
font-weight: 900;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.length-badge {
flex-shrink: 0;
padding: 4rpx 9rpx;
border-radius: 8rpx;
color: #c985ff;
font-size: 18rpx;
border: 1rpx solid rgba(182, 92, 255, 0.5);
background: rgba(128, 55, 204, 0.22);
}
.right-state {
flex-shrink: 0;
gap: 14rpx;
}
.state-pill {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
font-size: 19rpx;
}
.state-progress {
color: #ffbf4c;
background: rgba(170, 103, 20, 0.22);
}
.state-done {
color: #79e6a9;
background: rgba(44, 146, 88, 0.2);
}
.state-draft {
color: rgba(224, 214, 243, 0.76);
background: rgba(255, 255, 255, 0.06);
}
.ellipsis {
color: rgba(224, 214, 243, 0.66);
font-size: 24rpx;
letter-spacing: 3rpx;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
.tag {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
color: #d49cff;
font-size: 19rpx;
background: rgba(149, 55, 255, 0.2);
}
.summary {
display: -webkit-box;
margin-top: 14rpx;
color: rgba(226, 215, 246, 0.72);
font-size: 22rpx;
line-height: 1.55;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta-row {
gap: 14rpx;
margin-top: 14rpx;
color: rgba(218, 204, 243, 0.66);
font-size: 21rpx;
}
.progress-row {
justify-content: flex-end;
gap: 14rpx;
margin-top: 14rpx;
color: #bd72ff;
font-size: 22rpx;
font-weight: 800;
}
.progress-track {
width: 118rpx;
height: 6rpx;
border-radius: 999rpx;
background: rgba(173, 160, 210, 0.18);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #b246ff, #d878ff);
}
.favorite-row {
justify-content: flex-end;
gap: 8rpx;
margin-top: 14rpx;
color: #b768ff;
font-size: 23rpx;
font-weight: 800;
}
.favorite-star {
font-size: 28rpx;
}
.empty-card {
margin-top: 30rpx;
border-radius: 26rpx;
padding: 44rpx 30rpx;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
border: 1rpx solid rgba(105, 79, 210, 0.34);
background: rgba(9, 12, 42, 0.72);
}
.empty-book {
display: flex;
gap: 6rpx;
margin-bottom: 18rpx;
}
.empty-book view {
width: 32rpx;
height: 46rpx;
border: 4rpx solid #b768ff;
border-radius: 8rpx 4rpx 4rpx 8rpx;
}
.empty-title {
color: #fff;
font-size: 28rpx;
font-weight: 900;
}
.empty-text {
margin-top: 12rpx;
color: rgba(226, 215, 246, 0.68);
font-size: 22rpx;
line-height: 1.5;
}
.empty-action {
margin-top: 22rpx;
height: 56rpx;
padding: 0 30rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
color: #fff;
font-size: 23rpx;
font-weight: 800;
background: linear-gradient(135deg, #b346ff, #7330ff);
}
</style>
File diff suppressed because it is too large Load Diff
+19 -11
View File
@@ -8,24 +8,17 @@
<view class="safe-top" :style="{ height: capsuleTopReservePx + '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" :class="{ 'content-immersive': isImmersiveTab }" scroll-y :enhanced="true" :show-scrollbar="false">
<ScriptView v-if="activeTab === 'script'" /> <ScriptView v-if="activeTab === 'script'" />
<RecordView v-if="activeTab === 'record'" /> <RecordView v-if="activeTab === 'record'" />
<MineView v-if="activeTab === 'mine'" /> <MineView v-if="activeTab === 'mine'" />
<ScriptLibraryView v-if="activeTab === 'library'" />
</scroll-view> </scroll-view>
<MusicPlayer ref="musicPlayer" /> <MusicPlayer ref="musicPlayer" />
<view class="bottom-nav"> <view class="bottom-nav">
<view class="nav-inner"> <view class="nav-inner">
<view class="nav-item" :class="{ active: activeTab === 'script' }" @click="switchTab('script')">
<view class="tab-icon book-star-icon">
<view class="book-page left"></view>
<view class="book-page right"></view>
<view class="book-sparkle"></view>
</view>
<text>爽文生成</text>
</view>
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')"> <view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
<view class="tab-icon planet-ring-icon"> <view class="tab-icon planet-ring-icon">
<view class="planet-core"></view> <view class="planet-core"></view>
@@ -33,6 +26,14 @@
</view> </view>
<text>人生轨迹</text> <text>人生轨迹</text>
</view> </view>
<view class="nav-item" :class="{ active: activeTab === 'script' || activeTab === 'library' }" @click="switchTab('script')">
<view class="tab-icon book-star-icon">
<view class="book-page left"></view>
<view class="book-page right"></view>
<view class="book-sparkle"></view>
</view>
<text>爽文生成</text>
</view>
<view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')"> <view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
<view class="tab-icon smile-face-icon"> <view class="tab-icon smile-face-icon">
<view class="smile-eye left"></view> <view class="smile-eye left"></view>
@@ -47,12 +48,13 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app' 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 ScriptLibraryView from './ScriptLibraryView.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' import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
@@ -61,7 +63,8 @@ const store = useAppStore()
const activeTab = ref('script') const activeTab = ref('script')
const pagePath = '/pages/main/index' const pagePath = '/pages/main/index'
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 }) const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const validTabs = ['script', 'record', 'mine'] const validTabs = ['script', 'record', 'mine', 'library']
const isImmersiveTab = computed(() => ['script', 'mine'].includes(activeTab.value))
const normalizeTab = (tab) => validTabs.includes(tab) ? tab : 'script' const normalizeTab = (tab) => validTabs.includes(tab) ? tab : 'script'
@@ -174,6 +177,11 @@ onUnmounted(() => {
padding: 0 28rpx 132rpx; padding: 0 28rpx 132rpx;
} }
.content-immersive {
padding-left: 0;
padding-right: 0;
}
.bottom-nav { .bottom-nav {
position: absolute; position: absolute;
left: 0; left: 0;
+59 -56
View File
@@ -188,7 +188,7 @@ import { useAppStore } from '../../stores/app.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js' import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const store = useAppStore() const store = useAppStore()
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 }) const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 2 })
const isEdit = ref(false) const isEdit = ref(false)
const saving = ref(false) const saving = ref(false)
const birthday = ref('') const birthday = ref('')
@@ -372,23 +372,23 @@ onMounted(() => {
} }
.topbar { .topbar {
height: 92rpx; height: 72rpx;
display: grid; display: grid;
grid-template-columns: 90rpx 1fr 90rpx; grid-template-columns: 76rpx 1fr 76rpx;
align-items: center; align-items: center;
padding: 0 32rpx; padding: 0 28rpx;
} }
.back { .back {
color: #fff; color: #fff;
font-size: 68rpx; font-size: 56rpx;
line-height: 1; line-height: 1;
} }
.title { .title {
text-align: center; text-align: center;
color: #fff; color: #fff;
font-size: 36rpx; font-size: 32rpx;
font-weight: 900; font-weight: 900;
} }
@@ -399,7 +399,7 @@ onMounted(() => {
.save { .save {
color: #b94cff; color: #b94cff;
font-size: 28rpx; font-size: 26rpx;
text-align: right; text-align: right;
} }
@@ -408,35 +408,36 @@ onMounted(() => {
height: 0; height: 0;
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
padding: 0 28rpx 28rpx; padding: 0 24rpx 28rpx;
margin-top: -6rpx;
} }
.glass-card { .glass-card {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 1rpx solid rgba(124, 75, 255, 0.34); border: 1rpx solid rgba(155, 110, 255, 0.14);
background: background:
radial-gradient(circle at 92% 12%, rgba(104, 66, 255, 0.14), transparent 34%), radial-gradient(circle at 92% 12%, rgba(104, 66, 255, 0.1), transparent 34%),
rgba(10, 13, 43, 0.74); rgba(10, 13, 43, 0.54);
box-shadow: inset 0 0 34rpx rgba(123, 60, 255, 0.08), 0 14rpx 48rpx rgba(0, 0, 0, 0.22); box-shadow: inset 0 0 30rpx rgba(123, 60, 255, 0.05), 0 10rpx 36rpx rgba(0, 0, 0, 0.16);
backdrop-filter: blur(24rpx); backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx); -webkit-backdrop-filter: blur(24rpx);
} }
.hero-card { .hero-card {
min-height: 190rpx; min-height: 156rpx;
border-radius: 24rpx; border-radius: 22rpx;
margin-bottom: 22rpx; margin-bottom: 18rpx;
padding: 22rpx 28rpx; padding: 18rpx 24rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 28rpx; gap: 22rpx;
} }
.avatar-wrap { .avatar-wrap {
position: relative; position: relative;
width: 136rpx; width: 118rpx;
height: 136rpx; height: 118rpx;
flex-shrink: 0; flex-shrink: 0;
padding: 5rpx; padding: 5rpx;
border-radius: 50%; border-radius: 50%;
@@ -455,8 +456,8 @@ onMounted(() => {
position: absolute; position: absolute;
right: -4rpx; right: -4rpx;
bottom: -2rpx; bottom: -2rpx;
width: 42rpx; width: 38rpx;
height: 42rpx; height: 38rpx;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #8f4dff, #582cff); background: linear-gradient(135deg, #8f4dff, #582cff);
box-shadow: 0 0 18rpx rgba(158, 91, 255, 0.62); box-shadow: 0 0 18rpx rgba(158, 91, 255, 0.62);
@@ -465,7 +466,7 @@ onMounted(() => {
.pen-icon { .pen-icon {
width: 17rpx; width: 17rpx;
height: 6rpx; height: 6rpx;
margin: 18rpx auto; margin: 16rpx auto;
border-radius: 999rpx; border-radius: 999rpx;
background: #fff; background: #fff;
transform: rotate(-45deg); transform: rotate(-45deg);
@@ -486,20 +487,20 @@ onMounted(() => {
.hero-name { .hero-name {
color: #fff; color: #fff;
font-size: 42rpx; font-size: 34rpx;
font-weight: 900; font-weight: 900;
line-height: 1.1; line-height: 1.1;
} }
.hero-star { .hero-star {
font-size: 26rpx; font-size: 22rpx;
} }
.hero-sub { .hero-sub {
display: block; display: block;
margin-top: 12rpx; margin-top: 8rpx;
color: rgba(239, 232, 255, 0.84); color: rgba(239, 232, 255, 0.84);
font-size: 25rpx; font-size: 23rpx;
} }
.hero-line { .hero-line {
@@ -511,9 +512,9 @@ onMounted(() => {
.hero-quote { .hero-quote {
display: block; display: block;
margin-top: 14rpx; margin-top: 12rpx;
color: #b94cff; color: #b94cff;
font-size: 25rpx; font-size: 23rpx;
font-weight: 700; font-weight: 700;
} }
@@ -550,9 +551,9 @@ onMounted(() => {
} }
.panel { .panel {
border-radius: 24rpx; border-radius: 22rpx;
margin-bottom: 18rpx; margin-bottom: 18rpx;
padding: 24rpx; padding: 22rpx 24rpx;
} }
.section-head, .section-head,
@@ -572,7 +573,7 @@ onMounted(() => {
.section-title { .section-title {
color: rgba(239, 232, 255, 0.9); color: rgba(239, 232, 255, 0.9);
font-size: 25rpx; font-size: 24rpx;
font-weight: 800; font-weight: 800;
} }
@@ -659,9 +660,9 @@ onMounted(() => {
.bio-title-icon::after { top: 15rpx; } .bio-title-icon::after { top: 15rpx; }
.profile-row { .profile-row {
min-height: 64rpx; min-height: 60rpx;
display: grid; display: grid;
grid-template-columns: 154rpx 1fr 24rpx; grid-template-columns: 142rpx 1fr 24rpx;
align-items: center; align-items: center;
border-top: 1rpx solid rgba(180, 139, 255, 0.16); border-top: 1rpx solid rgba(180, 139, 255, 0.16);
} }
@@ -672,14 +673,14 @@ onMounted(() => {
.row-label { .row-label {
color: rgba(205, 191, 238, 0.82); color: rgba(205, 191, 238, 0.82);
font-size: 24rpx; font-size: 23rpx;
} }
.row-input, .row-input,
.row-value { .row-value {
min-width: 0; min-width: 0;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
font-size: 24rpx; font-size: 23rpx;
text-align: left; text-align: left;
} }
@@ -693,7 +694,7 @@ onMounted(() => {
.chevron { .chevron {
color: rgba(218, 204, 243, 0.7); color: rgba(218, 204, 243, 0.7);
font-size: 44rpx; font-size: 38rpx;
line-height: 1; line-height: 1;
text-align: right; text-align: right;
} }
@@ -729,26 +730,28 @@ onMounted(() => {
} }
.astro-panel { .astro-panel {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
margin-top: 8rpx; gap: 22rpx;
margin-top: 10rpx;
padding-top: 16rpx;
border-top: 1rpx solid rgba(180, 139, 255, 0.16); border-top: 1rpx solid rgba(180, 139, 255, 0.16);
} }
.astro-col { .astro-col {
min-width: 0; min-width: 0;
padding: 18rpx 18rpx 0 0; padding: 0;
} }
.mbti-col { .mbti-col {
border-left: 1rpx solid rgba(180, 139, 255, 0.18); border-left: 0;
padding-left: 18rpx; padding-top: 18rpx;
padding-right: 0; border-top: 1rpx solid rgba(180, 139, 255, 0.12);
} }
.astro-title-row { .astro-title-row {
display: grid; display: grid;
grid-template-columns: 34rpx 74rpx 1fr 18rpx; grid-template-columns: 34rpx 76rpx 1fr 18rpx;
align-items: center; align-items: center;
gap: 8rpx; gap: 8rpx;
} }
@@ -773,26 +776,26 @@ onMounted(() => {
.astro-title { .astro-title {
color: rgba(222, 211, 240, 0.76); color: rgba(222, 211, 240, 0.76);
font-size: 24rpx; font-size: 23rpx;
} }
.astro-current { .astro-current {
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
font-size: 23rpx; font-size: 22rpx;
text-align: right; text-align: right;
} }
.select-title { .select-title {
display: block; display: block;
margin-top: 22rpx; margin-top: 16rpx;
color: rgba(222, 211, 240, 0.72); color: rgba(222, 211, 240, 0.72);
font-size: 21rpx; font-size: 21rpx;
} }
.zodiac-grid { .zodiac-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 14rpx 8rpx; gap: 14rpx 10rpx;
margin-top: 14rpx; margin-top: 14rpx;
} }
@@ -801,20 +804,20 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8rpx; gap: 7rpx;
color: rgba(226, 217, 246, 0.84); color: rgba(226, 217, 246, 0.84);
font-size: 19rpx; font-size: 20rpx;
} }
.zodiac-bubble { .zodiac-bubble {
width: 48rpx; width: 44rpx;
height: 48rpx; height: 44rpx;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #a855ff; color: #a855ff;
font-size: 28rpx; font-size: 26rpx;
background: rgba(124, 58, 237, 0.28); background: rgba(124, 58, 237, 0.28);
border: 1rpx solid rgba(173, 84, 255, 0.36); border: 1rpx solid rgba(173, 84, 255, 0.36);
} }
@@ -829,12 +832,12 @@ onMounted(() => {
.mbti-grid { .mbti-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 12rpx; gap: 12rpx 14rpx;
margin-top: 14rpx; margin-top: 14rpx;
} }
.mbti-chip { .mbti-chip {
height: 42rpx; height: 40rpx;
font-size: 20rpx; font-size: 20rpx;
} }
+45 -8
View File
@@ -44,6 +44,33 @@ const parseSseFrame = (frame) => {
} }
} }
const findOverlapLength = (current, next) => {
const max = Math.min(current.length, next.length)
for (let size = max; size > 0; size--) {
if (current.slice(-size) === next.slice(0, size)) return size
}
return 0
}
const mergeStreamOutput = (current, chunk) => {
const next = String(chunk || '')
if (!next) return { output: current, delta: '' }
if (!current) return { output: next, delta: next }
if (next === current) return { output: current, delta: '' }
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' }
if (next.startsWith(current)) {
return { output: next, delta: next.slice(current.length) }
}
const currentIndex = next.length > current.length ? next.indexOf(current) : -1
if (currentIndex >= 0) {
return { output: next, delta: next.slice(currentIndex + current.length) }
}
const overlap = findOverlapLength(current, next)
if (overlap < 8) return { output: current + next, delta: next }
const delta = next.slice(overlap)
return { output: current + delta, delta }
}
const queryRuntimeResult = (requestId) => { const queryRuntimeResult = (requestId) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
uni.request({ uni.request({
@@ -113,6 +140,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
let requestTask let requestTask
let recoveryTimer let recoveryTimer
let recoveryPromise let recoveryPromise
let streamStarted = false
const clearRecoveryTimer = () => { const clearRecoveryTimer = () => {
if (recoveryTimer) { if (recoveryTimer) {
@@ -130,6 +158,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const completeFromRecoveredOutput = async () => { const completeFromRecoveredOutput = async () => {
if (closed) return if (closed) return
if (streamStarted || output.trim()) return
try { try {
const recoveredOutput = await recoverOnce() const recoveredOutput = await recoverOnce()
if (closed) return if (closed) return
@@ -149,7 +178,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
clearRecoveryTimer() clearRecoveryTimer()
recoveryTimer = setTimeout(() => { recoveryTimer = setTimeout(() => {
completeFromRecoveredOutput() completeFromRecoveredOutput()
}, 8000) }, 25000)
} }
const finishRecovered = (message, event) => { const finishRecovered = (message, event) => {
@@ -172,7 +201,6 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const recoverOrFail = async (message, event) => { const recoverOrFail = async (message, event) => {
if (closed) return if (closed) return
if (finishRecovered(message, event)) return
try { try {
const recoveredOutput = await recoverOnce() const recoveredOutput = await recoverOnce()
if (closed) return if (closed) return
@@ -182,6 +210,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
resolve({ output }) resolve({ output })
} catch (recoverError) { } catch (recoverError) {
if (closed) return if (closed) return
if (finishRecovered(message || recoverError.message, event)) return
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回' const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
closed = true closed = true
clearRecoveryTimer() clearRecoveryTimer()
@@ -196,10 +225,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const failWithoutRecovery = (message, event) => { const failWithoutRecovery = (message, event) => {
if (closed) return if (closed) return
closed = true recoverOrFail(message, event)
clearRecoveryTimer()
onError?.(message, event)
reject(new Error(message))
} }
const finishWithOutputOrRecover = async () => { const finishWithOutputOrRecover = async () => {
@@ -247,6 +273,8 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
requestTask?.onChunkReceived?.((res) => { requestTask?.onChunkReceived?.((res) => {
try { try {
streamStarted = true
clearRecoveryTimer()
consumeText(decodeChunk(res.data), failStream) consumeText(decodeChunk(res.data), failStream)
} catch (error) { } catch (error) {
failStream(error.message || 'AI流式请求失败') failStream(error.message || 'AI流式请求失败')
@@ -262,11 +290,20 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const event = parseSseFrame(frame) const event = parseSseFrame(frame)
if (!event) return if (!event) return
if (event.type === 'start') { if (event.type === 'start') {
streamStarted = true
clearRecoveryTimer()
onStart?.(event) onStart?.(event)
} else if (event.type === 'delta') { } else if (event.type === 'delta') {
output += event.content || '' streamStarted = true
onDelta?.(event.content || '', output, event) clearRecoveryTimer()
const merged = mergeStreamOutput(output, event.content)
output = merged.output
if (merged.delta) {
onDelta?.(merged.delta, output, event)
}
} else if (event.type === 'done') { } else if (event.type === 'done') {
streamStarted = 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流式请求失败' const message = event.message || event.code || 'AI流式请求失败'
+12 -4
View File
@@ -3,6 +3,8 @@ import { getEnvValue } from '../config/env.js'
const DEFAULT_SOURCE_TYPE = 'epic_script' const DEFAULT_SOURCE_TYPE = 'epic_script'
const DEFAULT_VOICE = 'default_zh_female' const DEFAULT_VOICE = 'default_zh_female'
const DEFAULT_SPEECH_RATE = 0.92
const DEFAULT_EMOTION = 'story'
const normalizeAudioUrl = (task) => { const normalizeAudioUrl = (task) => {
if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) { if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) {
@@ -25,9 +27,12 @@ const normalizeResponse = (response) => {
export const createTtsTask = ({ export const createTtsTask = ({
sourceType = DEFAULT_SOURCE_TYPE, sourceType = DEFAULT_SOURCE_TYPE,
sourceId, sourceId,
voice = DEFAULT_VOICE voice = DEFAULT_VOICE,
speechRate = DEFAULT_SPEECH_RATE,
pitch = 0,
emotion = DEFAULT_EMOTION
}) => { }) => {
return post('/tts/tasks', { sourceType, sourceId, voice }).then(normalizeResponse) return post('/tts/tasks', { sourceType, sourceId, voice, speechRate, pitch, emotion }).then(normalizeResponse)
} }
export const getTtsTask = (id) => { export const getTtsTask = (id) => {
@@ -37,9 +42,12 @@ export const getTtsTask = (id) => {
export const getTtsTaskBySource = ({ export const getTtsTaskBySource = ({
sourceType = DEFAULT_SOURCE_TYPE, sourceType = DEFAULT_SOURCE_TYPE,
sourceId, sourceId,
voice = DEFAULT_VOICE voice = DEFAULT_VOICE,
speechRate = DEFAULT_SPEECH_RATE,
pitch = 0,
emotion = DEFAULT_EMOTION
}) => { }) => {
return get('/tts/tasks/by-source', { sourceType, sourceId, voice }).then(normalizeResponse) return get('/tts/tasks/by-source', { sourceType, sourceId, voice, speechRate, pitch, emotion }).then(normalizeResponse)
} }
export default { export default {
+28 -1
View File
@@ -319,6 +319,33 @@ export function normalizeAiText(value?: string): string {
} }
} }
function findOverlapLength(current: string, next: string) {
const max = Math.min(current.length, next.length)
for (let size = max; size > 0; size -= 1) {
if (current.slice(-size) === next.slice(0, size)) return size
}
return 0
}
function mergeStreamOutput(current: string, chunk?: string) {
const next = normalizeAiText(chunk || '')
if (!next) return { output: current, delta: '' }
if (!current) return { output: next, delta: next }
if (next === current) return { output: current, delta: '' }
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' }
if (next.startsWith(current)) {
return { output: next, delta: next.slice(current.length) }
}
const currentIndex = next.length > current.length ? next.indexOf(current) : -1
if (currentIndex >= 0) {
return { output: next, delta: next.slice(currentIndex + current.length) }
}
const overlap = findOverlapLength(current, next)
if (overlap < 8) return { output: current + next, delta: next }
const delta = next.slice(overlap)
return { output: current + delta, delta }
}
function extractTextValue(value: any): string { function extractTextValue(value: any): string {
if (!value || typeof value !== 'object' || Array.isArray(value)) return '' if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
for (const key of ['output', 'answer', 'content', 'text', 'result']) { for (const key of ['output', 'answer', 'content', 'text', 'result']) {
@@ -383,7 +410,7 @@ async function fetchSseStream(
const event = parseSseFrame(frame) const event = parseSseFrame(frame)
if (!event) return if (!event) return
if (event.type === 'delta') { if (event.type === 'delta') {
output += normalizeAiText(event.content || '') output = mergeStreamOutput(output, event.content).output
} }
onEvent(event, output) onEvent(event, output)
if (event.type === 'error' && finishRecovered(event)) { if (event.type === 'error' && finishRecovered(event)) {
+1
View File
@@ -247,6 +247,7 @@ export interface AiCallLog {
providerCode?: string providerCode?: string
endpointCode?: string endpointCode?: string
userId?: string userId?: string
userName?: string
requestId?: string requestId?: string
status?: string status?: string
inputText?: string inputText?: string
@@ -202,6 +202,11 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="调用时间" width="175" /> <el-table-column prop="createTime" label="调用时间" width="175" />
<el-table-column label="调用用户" width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ userDisplay(row) }}
</template>
</el-table-column>
<el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip /> <el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip />
<el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip /> <el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip />
<el-table-column prop="endpointCode" label="接口" min-width="180" show-overflow-tooltip /> <el-table-column prop="endpointCode" label="接口" min-width="180" show-overflow-tooltip />
@@ -640,6 +645,15 @@ function formatMs(ms?: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms` return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
} }
function userDisplay(row: AiCallLog): string {
const name = row.userName
const id = row.userId
if (name && id) return `${name}(${id})`
if (name) return name
if (id) return `-(ID: ${id})`
return '-'
}
function testOutput(result?: AiRuntimeTestResponse | null) { function testOutput(result?: AiRuntimeTestResponse | null) {
return normalizeAiText(result?.output || result?.errorMessage || '暂无输出') return normalizeAiText(result?.output || result?.errorMessage || '暂无输出')
} }
@@ -48,8 +48,8 @@
<span class="info-value">{{ log.streamChunks ?? '-' }}</span> <span class="info-value">{{ log.streamChunks ?? '-' }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">用户 ID</span> <span class="info-label">调用用户</span>
<span class="info-value">{{ log.userId || '-' }}</span> <span class="info-value">{{ userDisplay(log) }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">请求 ID</span> <span class="info-label">请求 ID</span>
@@ -112,6 +112,15 @@ function formatMs(ms?: number) {
if (ms == null) return '-' if (ms == null) return '-'
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms` return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
} }
function userDisplay(log: AiCallLog): string {
const name = log.userName
const id = log.userId
if (name && id) return `${name}(${id})`
if (name) return name
if (id) return `-(ID: ${id})`
return '-'
}
</script> </script>
<style scoped> <style scoped>