mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(wiki): real-time SSE progress + parallel page generation + partial-resume; fix concurrent slug collisions; fix skill overwrite + hub retry
This commit is contained in:
parent
2a2f862257
commit
ef8120413c
1
.gitignore
vendored
1
.gitignore
vendored
@ -74,6 +74,7 @@ mateclaw-server/src/main/resources/static/
|
||||
|
||||
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
||||
mateclaw-server/data/
|
||||
/data/
|
||||
|
||||
# VitePress build output and cache (do not commit)
|
||||
docs/.vitepress/cache/
|
||||
|
||||
@ -125,6 +125,7 @@ public class AgentGraphBuilder {
|
||||
private final vip.mate.llm.chatgpt.ChatGPTResponsesClient chatGPTResponsesClient;
|
||||
private final WikiContextService wikiContextService;
|
||||
private final vip.mate.workspace.core.service.WorkspaceService workspaceService;
|
||||
private final vip.mate.llm.cache.AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
|
||||
|
||||
/**
|
||||
* 根据 AgentEntity 构建完整的 Agent 实例
|
||||
@ -463,9 +464,24 @@ public class AgentGraphBuilder {
|
||||
|
||||
/**
|
||||
* 构建运行时 ChatModel(不包装为 ChatClient)
|
||||
* 用于 StateGraph 节点直接调用
|
||||
* 用于 StateGraph 节点直接调用。使用注入的共享 {@link #retryTemplate} 作为 Spring AI
|
||||
* 内层重试策略。
|
||||
*/
|
||||
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel) {
|
||||
return buildRuntimeChatModel(runtimeModel, this.retryTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建运行时 ChatModel,并指定自定义的 Spring AI {@link RetryTemplate}。
|
||||
* <p>
|
||||
* 用于调用方(如 Wiki 消化管线)已经有自己的外层重试策略,
|
||||
* 希望绕过 Spring AI 内层重试、独占重试控制权的场景:传入
|
||||
* {@code RetryTemplate.builder().maxAttempts(1).build()} 即可把内层降级为"只跑一次"。
|
||||
* <p>
|
||||
* DashScope 和 OpenAI-ChatGPT 分支不走 Spring AI 的 RetryTemplate 接口,
|
||||
* 本参数对它们无效(它们各自有内部重试或直通)。
|
||||
*/
|
||||
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel, RetryTemplate retryOverride) {
|
||||
ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
|
||||
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
|
||||
|
||||
@ -490,7 +506,7 @@ public class AgentGraphBuilder {
|
||||
return OpenAiChatModel.builder()
|
||||
.openAiApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retryTemplate)
|
||||
.retryTemplate(retryOverride)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
}
|
||||
@ -501,7 +517,7 @@ public class AgentGraphBuilder {
|
||||
return AnthropicChatModel.builder()
|
||||
.anthropicApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retryTemplate)
|
||||
.retryTemplate(retryOverride)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
}
|
||||
@ -981,6 +997,11 @@ public class AgentGraphBuilder {
|
||||
builder.maxTokens(4096);
|
||||
}
|
||||
}
|
||||
// RFC-014: 接入 Anthropic prompt caching(spring-ai 1.1.4 一等支持)
|
||||
// 通过 cacheOptions 配置 system / tools / conversation history 自动打 cache_control,
|
||||
// 多轮对话场景可节省 50–75% 输入 token 成本。
|
||||
builder.cacheOptions(anthropicCacheOptionsFactory.build());
|
||||
|
||||
return builder.internalToolExecutionEnabled(false).build();
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,12 @@ import java.time.format.DateTimeFormatter;
|
||||
* <p>
|
||||
* 参考 Claude Code 的 prependUserContext 模式:将时间信息作为首条 meta UserMessage 注入,
|
||||
* 而非修改 System Prompt,以保持 prompt cache 命中率。
|
||||
* <p>
|
||||
* <b>RFC-014 协同要点</b>:本类返回的内容必须始终包装为 {@code UserMessage} 注入,
|
||||
* 严禁拼接进 SystemMessage —— 否则每次时间变化都会击穿 spring-ai 的
|
||||
* {@code AnthropicCacheStrategy.SYSTEM_AND_TOOLS / CONVERSATION_HISTORY} system cache。
|
||||
* 内容长度(约 70 字符)远小于 spring-ai 的 USER min-content-length 阈值(≥1024 字符),
|
||||
* 因此不会被错误纳入对话 cache 块。新增调用点时请保持此约束。
|
||||
*/
|
||||
public final class RuntimeContextInjector {
|
||||
|
||||
|
||||
@ -272,6 +272,9 @@ public class NodeStreamingChatHelper {
|
||||
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||
AtomicInteger promptTokens = new AtomicInteger(0);
|
||||
AtomicInteger completionTokens = new AtomicInteger(0);
|
||||
// RFC-014: Anthropic prompt cache 计数(其它 provider 永远为 0)
|
||||
AtomicInteger cacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
||||
|
||||
// 重复检测器:检测 LLM 退化输出(如不断重复同一句话)
|
||||
RepetitionDetector contentRepDetector = new RepetitionDetector();
|
||||
@ -342,6 +345,10 @@ public class NodeStreamingChatHelper {
|
||||
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
|
||||
completionTokens.set(usage.getCompletionTokens().intValue());
|
||||
}
|
||||
// RFC-014: 反射抽取 Anthropic prompt cache 字段(DashScope/OpenAI 自然返回 0)
|
||||
var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
|
||||
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
|
||||
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
|
||||
}
|
||||
})
|
||||
.subscribe(
|
||||
@ -378,7 +385,8 @@ public class NodeStreamingChatHelper {
|
||||
phase, contentAccum.length(), thinkingAccum.length(),
|
||||
toolCallAccumulators.size(), conversationId);
|
||||
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(), phase);
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase);
|
||||
}
|
||||
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
|
||||
phase, conversationId);
|
||||
@ -410,7 +418,9 @@ public class NodeStreamingChatHelper {
|
||||
buildDeltaJson("LLM 响应中断,使用已生成的部分内容继续"));
|
||||
}
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(), phase, true, error.getMessage());
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
phase, true, error.getMessage());
|
||||
}
|
||||
|
||||
// ===== 无内容:分类错误并决定是否重试 =====
|
||||
@ -460,14 +470,16 @@ public class NodeStreamingChatHelper {
|
||||
// warning 已在 dispose 时广播,无需重复
|
||||
}
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(), phase,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
||||
truncatedByRepetition, truncatedByRepetition ? "output_truncated_repetition" : null);
|
||||
}
|
||||
|
||||
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
|
||||
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
|
||||
List<ToolCallAccumulator> toolCallAccumulators,
|
||||
int promptTok, int completionTok, String phase) {
|
||||
int promptTok, int completionTok,
|
||||
int cacheReadTok, int cacheWriteTok, String phase) {
|
||||
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
|
||||
String fullContent = contentAccum.toString();
|
||||
String fullThinking = thinkingAccum.toString();
|
||||
@ -487,13 +499,14 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
true, null, ErrorType.NONE, true);
|
||||
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
|
||||
}
|
||||
|
||||
/** 组装最终 StreamResult(成功或 partial) */
|
||||
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
|
||||
List<ToolCallAccumulator> toolCallAccumulators,
|
||||
int promptTok, int completionTok,
|
||||
int cacheReadTok, int cacheWriteTok,
|
||||
String phase, boolean partial, String errorMsg) {
|
||||
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
|
||||
String fullContent = contentAccum.toString();
|
||||
@ -522,7 +535,7 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
partial, errorMsg, ErrorType.NONE);
|
||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
|
||||
}
|
||||
|
||||
/** 构建纯错误 StreamResult(无任何内容) */
|
||||
@ -685,14 +698,18 @@ public class NodeStreamingChatHelper {
|
||||
/** 错误类型分类 */
|
||||
ErrorType errorType,
|
||||
/** 用户主动停止(stopRequested)导致的提前返回 */
|
||||
boolean stopped
|
||||
boolean stopped,
|
||||
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0) */
|
||||
int cacheReadTokens,
|
||||
/** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0) */
|
||||
int cacheWriteTokens
|
||||
) {
|
||||
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
|
||||
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
|
||||
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
|
||||
int promptTokens, int completionTokens) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, false, null, ErrorType.NONE, false);
|
||||
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0);
|
||||
}
|
||||
|
||||
/** 兼容 10-arg 调用点 */
|
||||
@ -701,7 +718,17 @@ public class NodeStreamingChatHelper {
|
||||
int promptTokens, int completionTokens,
|
||||
boolean partial, String errorMessage, ErrorType errorType) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, false);
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0);
|
||||
}
|
||||
|
||||
/** 兼容 12-arg 调用点(pre-RFC-014) */
|
||||
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
|
||||
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
|
||||
int promptTokens, int completionTokens,
|
||||
boolean partial, String errorMessage, ErrorType errorType,
|
||||
boolean stopped) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0);
|
||||
}
|
||||
|
||||
/** 是否有不可忽略的错误(无内容 + 有错误) */
|
||||
|
||||
@ -18,6 +18,10 @@ public class UsageDailyEntity {
|
||||
private Long totalTokens;
|
||||
private Long promptTokens;
|
||||
private Long completionTokens;
|
||||
/** RFC-014: Anthropic cache_read_input_tokens 累计 */
|
||||
private Long cacheReadTokens;
|
||||
/** RFC-014: Anthropic cache_creation_input_tokens 累计 */
|
||||
private Long cacheWriteTokens;
|
||||
private Integer toolCallCount;
|
||||
private Integer errorCount;
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
|
||||
78
mateclaw-server/src/main/java/vip/mate/llm/cache/AdaptiveCacheStrategy.java
vendored
Normal file
78
mateclaw-server/src/main/java/vip/mate/llm/cache/AdaptiveCacheStrategy.java
vendored
Normal file
@ -0,0 +1,78 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 自适应装饰器:包裹任一基础策略,根据真实命中率动态降级。
|
||||
*
|
||||
* <p>规则:
|
||||
* <ul>
|
||||
* <li>启动后默认走 delegate 策略</li>
|
||||
* <li>{@link #recordMiss()} / {@link #recordHit()} 由 usage 聚合层在解析响应后回调</li>
|
||||
* <li>连续 {@code missThreshold} 次 miss → 临时切到 {@link NoOpCacheStrategy},
|
||||
* 持续 {@code coolDownMillis} 毫秒后自动恢复</li>
|
||||
* <li>任意一次 hit 立即清零计数并恢复</li>
|
||||
* </ul></p>
|
||||
*
|
||||
* <p>这是有状态对象,建议作为 Spring 单例 bean。所有计数用原子类型,无锁、零分配热路径。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
public final class AdaptiveCacheStrategy implements PromptCacheStrategy {
|
||||
|
||||
private final PromptCacheStrategy delegate;
|
||||
private final int missThreshold;
|
||||
private final long coolDownMillis;
|
||||
|
||||
private final AtomicInteger consecutiveMisses = new AtomicInteger();
|
||||
private final AtomicLong degradedUntilEpochMs = new AtomicLong();
|
||||
|
||||
public AdaptiveCacheStrategy(PromptCacheStrategy delegate, int missThreshold, long coolDownMillis) {
|
||||
this.delegate = (delegate instanceof AdaptiveCacheStrategy)
|
||||
? ((AdaptiveCacheStrategy) delegate).delegate
|
||||
: delegate;
|
||||
this.missThreshold = Math.max(1, missThreshold);
|
||||
this.coolDownMillis = Math.max(0, coolDownMillis);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CachedPlan plan(CachePlanContext ctx) {
|
||||
return isDegraded() ? CachedPlan.none() : delegate.plan(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldCache(CachePlanContext ctx) {
|
||||
return !isDegraded() && delegate.shouldCache(ctx);
|
||||
}
|
||||
|
||||
/** 命中:清零并立刻恢复(如已降级)。 */
|
||||
public void recordHit() {
|
||||
consecutiveMisses.set(0);
|
||||
degradedUntilEpochMs.set(0);
|
||||
}
|
||||
|
||||
/** 未命中:累加;越过阈值则进入冷却期。 */
|
||||
public void recordMiss() {
|
||||
int n = consecutiveMisses.incrementAndGet();
|
||||
if (n >= missThreshold) {
|
||||
long until = System.currentTimeMillis() + coolDownMillis;
|
||||
degradedUntilEpochMs.set(until);
|
||||
log.warn("Prompt cache strategy degraded after {} consecutive misses; coolDown={}ms",
|
||||
n, coolDownMillis);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDegraded() {
|
||||
long until = degradedUntilEpochMs.get();
|
||||
if (until == 0) return false;
|
||||
if (System.currentTimeMillis() >= until) {
|
||||
// 冷却期已过:原子清零(CAS 防止并发覆写)
|
||||
degradedUntilEpochMs.compareAndSet(until, 0);
|
||||
consecutiveMisses.set(0);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
74
mateclaw-server/src/main/java/vip/mate/llm/cache/AnthropicCacheOptionsFactory.java
vendored
Normal file
74
mateclaw-server/src/main/java/vip/mate/llm/cache/AnthropicCacheOptionsFactory.java
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.springframework.ai.anthropic.api.AnthropicCacheOptions;
|
||||
import org.springframework.ai.anthropic.api.AnthropicCacheStrategy;
|
||||
import org.springframework.ai.anthropic.api.AnthropicCacheTtl;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 把 MateClaw 的 {@link CacheProperties} 翻译成 spring-ai 1.1.4 一等支持的 {@link AnthropicCacheOptions}。
|
||||
*
|
||||
* <p>设计说明:spring-ai 已内置完整的 Anthropic prompt cache 框架({@code CacheEligibilityResolver} +
|
||||
* {@code CacheBreakpointTracker}),它会按 {@link AnthropicCacheStrategy} 自动决策在 system / tools /
|
||||
* conversation history 上挂 {@code cache_control}。我们只需配置好策略与 TTL/min-length 即可,
|
||||
* 不必自己写 HTTP body 拦截器。</p>
|
||||
*
|
||||
* <p>映射规则(对应 RFC-014 Change 1 的 SystemAndTailCacheStrategy 默认行为):
|
||||
* <ul>
|
||||
* <li>{@code includeToolsBlock=true} → {@link AnthropicCacheStrategy#CONVERSATION_HISTORY}(system + tools + 最新一段对话;最完整也最贴近 hermes-agent system_and_3)</li>
|
||||
* <li>{@code includeToolsBlock=false} → {@link AnthropicCacheStrategy#SYSTEM_ONLY}</li>
|
||||
* <li>{@code ttl=extended-1h} → SYSTEM/USER 两个消息类型映射为 {@link AnthropicCacheTtl#ONE_HOUR}</li>
|
||||
* <li>{@code minPromptTokens} → 转字符长度(× 4,粗粒度估算)作为 SYSTEM 段的 min content length</li>
|
||||
* </ul></p>
|
||||
*
|
||||
* <p>无状态、线程安全。</p>
|
||||
*/
|
||||
@Component
|
||||
public class AnthropicCacheOptionsFactory {
|
||||
|
||||
/** token → 字符 的粗略乘子(英文 4 字符 ≈ 1 token;中文略低,但作为门槛足矣)。 */
|
||||
private static final int CHARS_PER_TOKEN_HEURISTIC = 4;
|
||||
|
||||
private final CacheProperties props;
|
||||
|
||||
public AnthropicCacheOptionsFactory(CacheProperties props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建启用了缓存的 {@link AnthropicCacheOptions};当 {@link CacheProperties#isEnabled()} 为 false
|
||||
* 时返回 {@link AnthropicCacheOptions#DISABLED}(spring-ai 内置的 NONE 策略快速路径)。
|
||||
*/
|
||||
public AnthropicCacheOptions build() {
|
||||
if (!props.isEnabled()) {
|
||||
return AnthropicCacheOptions.DISABLED;
|
||||
}
|
||||
|
||||
AnthropicCacheStrategy strategy = props.isIncludeToolsBlock()
|
||||
? AnthropicCacheStrategy.CONVERSATION_HISTORY
|
||||
: AnthropicCacheStrategy.SYSTEM_ONLY;
|
||||
|
||||
AnthropicCacheTtl ttl = (props.resolveCacheTtl() == CacheTtl.EXTENDED_1H)
|
||||
? AnthropicCacheTtl.ONE_HOUR
|
||||
: AnthropicCacheTtl.FIVE_MINUTES;
|
||||
|
||||
Map<MessageType, AnthropicCacheTtl> ttlMap = new EnumMap<>(MessageType.class);
|
||||
ttlMap.put(MessageType.SYSTEM, ttl);
|
||||
ttlMap.put(MessageType.USER, ttl);
|
||||
|
||||
Map<MessageType, Integer> minLenMap = new EnumMap<>(MessageType.class);
|
||||
int sysMinChars = Math.max(256, props.getMinPromptTokens() * CHARS_PER_TOKEN_HEURISTIC / 8);
|
||||
minLenMap.put(MessageType.SYSTEM, sysMinChars);
|
||||
|
||||
return AnthropicCacheOptions.builder()
|
||||
.strategy(strategy)
|
||||
.messageTypeTtl(ttlMap)
|
||||
.messageTypeMinContentLengths(minLenMap)
|
||||
.multiBlockSystemCaching(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
14
mateclaw-server/src/main/java/vip/mate/llm/cache/Breakpoint.java
vendored
Normal file
14
mateclaw-server/src/main/java/vip/mate/llm/cache/Breakpoint.java
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
/**
|
||||
* 一个缓存断点:序列化器据此决定在哪条消息(或 system / tools 段)上挂 cache_control。
|
||||
*
|
||||
* @param messageIndex 在最终 messages 数组中的索引;对 SYSTEM_TAIL / TOOLS_TAIL 取 -1
|
||||
* @param kind 断点语义
|
||||
*/
|
||||
public record Breakpoint(int messageIndex, BreakpointKind kind) {
|
||||
public static Breakpoint systemTail() { return new Breakpoint(-1, BreakpointKind.SYSTEM_TAIL); }
|
||||
public static Breakpoint toolsTail() { return new Breakpoint(-1, BreakpointKind.TOOLS_TAIL); }
|
||||
public static Breakpoint memoryBlock(int idx) { return new Breakpoint(idx, BreakpointKind.MEMORY_BLOCK); }
|
||||
public static Breakpoint messagesTail(int idx) { return new Breakpoint(idx, BreakpointKind.MESSAGES_TAIL); }
|
||||
}
|
||||
13
mateclaw-server/src/main/java/vip/mate/llm/cache/BreakpointKind.java
vendored
Normal file
13
mateclaw-server/src/main/java/vip/mate/llm/cache/BreakpointKind.java
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
/**
|
||||
* 缓存断点的语义类型。
|
||||
* <p>断点位置用于序列化器决定把 {@code cache_control} 放在请求体的哪个内容块上。
|
||||
* 顺序与 Anthropic Messages API 的天然布局对齐:system → tools → memory/wiki → messages tail。</p>
|
||||
*/
|
||||
public enum BreakpointKind {
|
||||
SYSTEM_TAIL,
|
||||
TOOLS_TAIL,
|
||||
MEMORY_BLOCK,
|
||||
MESSAGES_TAIL
|
||||
}
|
||||
38
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheDirectives.java
vendored
Normal file
38
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheDirectives.java
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 协议无关的"缓存指令包"。
|
||||
*
|
||||
* <p>序列化器从 {@link CachedPlan} 推导出本对象后,由具体的请求拦截层
|
||||
* (如 {@code CachingChatModelDecorator} 注册到 RestClient 的 {@code ClientHttpRequestInterceptor})
|
||||
* 把指令落到具体协议的请求体上。</p>
|
||||
*
|
||||
* @param breakpoints 最终要应用的断点(顺序敏感)
|
||||
* @param httpHeaders 需要附加到 HTTP 请求的额外 header(如 Anthropic extended-cache-ttl beta header)
|
||||
* @param ttl TTL 提示
|
||||
* @param protocol 生产此指令的协议;运行时拦截器据此选分支
|
||||
*/
|
||||
public record CacheDirectives(
|
||||
List<Breakpoint> breakpoints,
|
||||
Map<String, String> httpHeaders,
|
||||
CacheTtl ttl,
|
||||
vip.mate.llm.model.ModelProtocol protocol) {
|
||||
|
||||
public CacheDirectives {
|
||||
breakpoints = (breakpoints == null) ? List.of() : List.copyOf(breakpoints);
|
||||
httpHeaders = (httpHeaders == null) ? Map.of() : Map.copyOf(httpHeaders);
|
||||
if (ttl == null) ttl = CacheTtl.DEFAULT_5M;
|
||||
if (protocol == null) throw new IllegalArgumentException("protocol must not be null");
|
||||
}
|
||||
|
||||
public static CacheDirectives empty(vip.mate.llm.model.ModelProtocol protocol) {
|
||||
return new CacheDirectives(List.of(), Map.of(), CacheTtl.DEFAULT_5M, protocol);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return breakpoints.isEmpty();
|
||||
}
|
||||
}
|
||||
65
mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java
vendored
Normal file
65
mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 策略评估缓存方案时所需的只读上下文。
|
||||
*
|
||||
* <p>不持有 {@link Prompt} 引用以外的可变状态;所有派生指标(system 长度、tool 数等)按需懒计算
|
||||
* 并由 record 字段缓存,避免重复扫描。</p>
|
||||
*/
|
||||
public record CachePlanContext(
|
||||
Prompt prompt,
|
||||
ModelProtocol protocol,
|
||||
int totalPromptTokens,
|
||||
int turnCount,
|
||||
int minPromptTokens,
|
||||
int maxBreakpoints,
|
||||
boolean includeToolsBlock,
|
||||
CacheTtl ttl) {
|
||||
|
||||
public CachePlanContext {
|
||||
if (prompt == null) throw new IllegalArgumentException("prompt must not be null");
|
||||
if (protocol == null) throw new IllegalArgumentException("protocol must not be null");
|
||||
if (ttl == null) ttl = CacheTtl.DEFAULT_5M;
|
||||
if (maxBreakpoints <= 0) maxBreakpoints = 4;
|
||||
}
|
||||
|
||||
/** 消息列表(不可变视图;上层不应修改)。 */
|
||||
public List<Message> messages() {
|
||||
return prompt.getInstructions();
|
||||
}
|
||||
|
||||
/** 是否存在 system 消息(用于决定是否打 SYSTEM_TAIL 断点)。 */
|
||||
public boolean hasSystemMessage() {
|
||||
for (Message m : messages()) {
|
||||
if (m.getMessageType() == MessageType.SYSTEM) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 系统消息的字符长度总和,用于估算是否值得缓存 system。 */
|
||||
public int systemCharLen() {
|
||||
int n = 0;
|
||||
for (Message m : messages()) {
|
||||
if (m.getMessageType() == MessageType.SYSTEM) {
|
||||
String t = m.getText();
|
||||
if (t != null) n += t.length();
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 协议是否原生支持 cache_control 标记。DashScope/Ollama/Gemini 自有缓存机制,无需接入。 */
|
||||
public boolean protocolSupportsCacheControl() {
|
||||
return switch (protocol) {
|
||||
case ANTHROPIC_MESSAGES, OPENAI_CHATGPT -> true;
|
||||
case OPENAI_COMPATIBLE, DASHSCOPE_NATIVE, GEMINI_NATIVE -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
79
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheProperties.java
vendored
Normal file
79
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheProperties.java
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Prompt cache 总配置。
|
||||
*
|
||||
* <pre>
|
||||
* mateclaw:
|
||||
* llm:
|
||||
* cache:
|
||||
* enabled: true
|
||||
* min-prompt-tokens: 1024
|
||||
* max-breakpoints: 4
|
||||
* ttl: default # default | extended-1h
|
||||
* include-tools-block: true
|
||||
* adaptive:
|
||||
* enabled: true
|
||||
* miss-threshold: 5
|
||||
* cool-down: 60s
|
||||
* </pre>
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "mateclaw.llm.cache")
|
||||
public class CacheProperties {
|
||||
|
||||
/** 总开关;false 时全部走 NoOp。 */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** 累计 prompt token 低于此值则跳过缓存(避免 cache write 倒亏)。 */
|
||||
private int minPromptTokens = 1024;
|
||||
|
||||
/** 单请求最多打几个 cache_control 断点(Anthropic 上限 4)。 */
|
||||
private int maxBreakpoints = 4;
|
||||
|
||||
/** 默认 TTL;{@code extended-1h} 需要 Anthropic beta header。 */
|
||||
private Ttl ttl = Ttl.DEFAULT;
|
||||
|
||||
/** 是否把工具 schema 段也作为一个断点(独立断点,避免与 system 共享)。 */
|
||||
private boolean includeToolsBlock = true;
|
||||
|
||||
private final Adaptive adaptive = new Adaptive();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
public int getMinPromptTokens() { return minPromptTokens; }
|
||||
public void setMinPromptTokens(int minPromptTokens) { this.minPromptTokens = minPromptTokens; }
|
||||
|
||||
public int getMaxBreakpoints() { return maxBreakpoints; }
|
||||
public void setMaxBreakpoints(int maxBreakpoints) { this.maxBreakpoints = maxBreakpoints; }
|
||||
|
||||
public Ttl getTtl() { return ttl; }
|
||||
public void setTtl(Ttl ttl) { this.ttl = ttl; }
|
||||
|
||||
public boolean isIncludeToolsBlock() { return includeToolsBlock; }
|
||||
public void setIncludeToolsBlock(boolean includeToolsBlock) { this.includeToolsBlock = includeToolsBlock; }
|
||||
|
||||
public Adaptive getAdaptive() { return adaptive; }
|
||||
|
||||
public CacheTtl resolveCacheTtl() {
|
||||
return ttl == Ttl.EXTENDED_1H ? CacheTtl.EXTENDED_1H : CacheTtl.DEFAULT_5M;
|
||||
}
|
||||
|
||||
public enum Ttl { DEFAULT, EXTENDED_1H }
|
||||
|
||||
public static class Adaptive {
|
||||
private boolean enabled = true;
|
||||
private int missThreshold = 5;
|
||||
/** 字符串解析在 application.yml 由 Spring Boot 自动转换;默认 60_000 ms。 */
|
||||
private long coolDownMs = 60_000L;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public int getMissThreshold() { return missThreshold; }
|
||||
public void setMissThreshold(int missThreshold) { this.missThreshold = missThreshold; }
|
||||
public long getCoolDownMs() { return coolDownMs; }
|
||||
public void setCoolDownMs(long coolDownMs) { this.coolDownMs = coolDownMs; }
|
||||
}
|
||||
}
|
||||
27
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheSerializer.java
vendored
Normal file
27
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheSerializer.java
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
/**
|
||||
* 把 {@link CachedPlan} 转换为协议特化的 {@link CacheDirectives}。
|
||||
*
|
||||
* <p>sealed 锁定可选实现,避免反射式插件加载并支持穷尽 switch。</p>
|
||||
*
|
||||
* <p>注意:本接口只决定"要打哪些断点 + 需要哪些 HTTP header",不直接修改底层 HTTP body。
|
||||
* 真正写 JSON 的工作由调用层完成;这样保证序列化器是纯函数,便于单元测试。</p>
|
||||
*
|
||||
* <p><b>Anthropic 例外</b>:spring-ai 1.1.4+ 已通过 {@code AnthropicCacheOptions} 提供一等支持,
|
||||
* 由 {@link AnthropicCacheOptionsFactory} 直接装配到 {@code AnthropicChatOptions},不走本接口。
|
||||
* 本接口仅用于我们自有客户端(如 OpenAI Responses)需要手动装配缓存指令的协议。</p>
|
||||
*/
|
||||
public sealed interface CacheSerializer
|
||||
permits OpenAIResponsesCacheSerializer {
|
||||
|
||||
/** 此序列化器服务的协议;调度方据此挑选实现。 */
|
||||
vip.mate.llm.model.ModelProtocol protocol();
|
||||
|
||||
/**
|
||||
* @param plan 策略产出的方案(可能为空 → 返回 {@link CacheDirectives#empty} )
|
||||
* @param ctx 与策略相同的上下文,便于序列化器看 messages 大小决定是否缩减断点
|
||||
* @return 供 HTTP 拦截器消费的指令
|
||||
*/
|
||||
CacheDirectives serialize(CachedPlan plan, CachePlanContext ctx);
|
||||
}
|
||||
11
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheTtl.java
vendored
Normal file
11
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheTtl.java
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
/**
|
||||
* Anthropic prompt cache 的存活时间。
|
||||
* <p>{@link #EXTENDED_1H} 需要 beta header {@code anthropic-beta: extended-cache-ttl-2025-04-11},
|
||||
* 可通过 application.yml 的 {@code mateclaw.llm.cache.ttl=extended-1h} 启用。</p>
|
||||
*/
|
||||
public enum CacheTtl {
|
||||
DEFAULT_5M,
|
||||
EXTENDED_1H
|
||||
}
|
||||
91
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java
vendored
Normal file
91
mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java
vendored
Normal file
@ -0,0 +1,91 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.springframework.ai.chat.metadata.Usage;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* 从 Spring AI {@link Usage} 中提取 Anthropic 的 prompt cache token 计数。
|
||||
*
|
||||
* <p>spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens},
|
||||
* 没有 cache 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象。
|
||||
* 对 Anthropic 而言是 {@code AnthropicApi.Usage} record,含 {@code cacheCreationInputTokens}
|
||||
* 与 {@code cacheReadInputTokens}。</p>
|
||||
*
|
||||
* <p>采用反射调用以避免:
|
||||
* <ul>
|
||||
* <li>对 spring-ai 内部 record 形态的硬编码(未来字段重命名风险小)</li>
|
||||
* <li>对其它 provider(OpenAI 兼容、DashScope)的 ClassCastException</li>
|
||||
* </ul>
|
||||
* 反射结果按类缓存,热路径性能可接受。</p>
|
||||
*
|
||||
* <p>不可变、线程安全。</p>
|
||||
*/
|
||||
public final class CacheUsageExtractor {
|
||||
|
||||
/** {@code (cacheReadTokens, cacheWriteTokens)};任一字段不可得时为 0。 */
|
||||
public record CacheTokens(int cacheReadTokens, int cacheWriteTokens) {
|
||||
public static final CacheTokens EMPTY = new CacheTokens(0, 0);
|
||||
|
||||
public boolean isEmpty() { return cacheReadTokens == 0 && cacheWriteTokens == 0; }
|
||||
}
|
||||
|
||||
/** 缓存 (Class, methodName) → reflected Method(命中失败时为标记 NULL_METHOD)。 */
|
||||
private static final ConcurrentMap<String, Method> METHOD_CACHE = new ConcurrentHashMap<>();
|
||||
private static final Method NULL_METHOD;
|
||||
static {
|
||||
try {
|
||||
NULL_METHOD = Object.class.getMethod("toString");
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private CacheUsageExtractor() {}
|
||||
|
||||
/** 从 spring-ai Usage 中尽力抽取 cache token;不支持的 provider 返回 EMPTY。 */
|
||||
public static CacheTokens extract(Usage usage) {
|
||||
if (usage == null) return CacheTokens.EMPTY;
|
||||
Object native_ = usage.getNativeUsage();
|
||||
if (native_ == null) return CacheTokens.EMPTY;
|
||||
|
||||
int read = invokeIntAccessor(native_, "cacheReadInputTokens");
|
||||
int write = invokeIntAccessor(native_, "cacheCreationInputTokens");
|
||||
return (read == 0 && write == 0) ? CacheTokens.EMPTY : new CacheTokens(read, write);
|
||||
}
|
||||
|
||||
private static int invokeIntAccessor(Object target, String accessor) {
|
||||
Class<?> cls = target.getClass();
|
||||
String key = cls.getName() + "#" + accessor;
|
||||
Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor));
|
||||
if (m == NULL_METHOD) return 0;
|
||||
try {
|
||||
Object v = m.invoke(target);
|
||||
if (v instanceof Number n) return n.intValue();
|
||||
return 0;
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static Method resolveAccessor(Class<?> cls, String accessor) {
|
||||
// 1) record-style getter: cacheReadInputTokens()
|
||||
try {
|
||||
Method m = cls.getMethod(accessor);
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
// 2) bean-style getter: getCacheReadInputTokens()
|
||||
String beanName = "get" + Character.toUpperCase(accessor.charAt(0)) + accessor.substring(1);
|
||||
try {
|
||||
Method m = cls.getMethod(beanName);
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
} catch (NoSuchMethodException ignored2) {
|
||||
return NULL_METHOD;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
mateclaw-server/src/main/java/vip/mate/llm/cache/CachedPlan.java
vendored
Normal file
35
mateclaw-server/src/main/java/vip/mate/llm/cache/CachedPlan.java
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.SequencedCollection;
|
||||
|
||||
/**
|
||||
* 一次请求的缓存方案:要打哪些断点,TTL 多长。
|
||||
*
|
||||
* <p>使用 JDK 21 {@link SequencedCollection} 保证断点的固定顺序(首尾稳定,
|
||||
* 序列化器能直接按顺序写入),同时是不可变 record,便于在多线程间传递。</p>
|
||||
*
|
||||
* @param breakpoints 断点序列;调用方应当按顺序遍历
|
||||
* @param ttl 缓存 TTL;{@code null} 等价 {@link CacheTtl#DEFAULT_5M}
|
||||
*/
|
||||
public record CachedPlan(SequencedCollection<Breakpoint> breakpoints, CacheTtl ttl) {
|
||||
|
||||
public CachedPlan {
|
||||
if (breakpoints == null) {
|
||||
throw new IllegalArgumentException("breakpoints must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/** 空方案:不打任何断点(等价 NoOp)。 */
|
||||
public static CachedPlan none() {
|
||||
return new CachedPlan(List.of(), CacheTtl.DEFAULT_5M);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return breakpoints.isEmpty();
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return breakpoints.size();
|
||||
}
|
||||
}
|
||||
34
mateclaw-server/src/main/java/vip/mate/llm/cache/LlmCacheAutoConfiguration.java
vendored
Normal file
34
mateclaw-server/src/main/java/vip/mate/llm/cache/LlmCacheAutoConfiguration.java
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* RFC-014 prompt cache 装配。
|
||||
*
|
||||
* <p>暴露 {@link PromptCacheStrategy} 作为 Spring bean:当 {@code mateclaw.llm.cache.adaptive.enabled=true}
|
||||
* 时用 {@link AdaptiveCacheStrategy} 包装基础策略 {@link SystemAndTailCacheStrategy},
|
||||
* 后者负责实际断点计算;自适应层根据 cache miss 率自动降级。</p>
|
||||
*
|
||||
* <p>{@link AnthropicCacheOptionsFactory} 由 {@code @Component} 自动注册。</p>
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(CacheProperties.class)
|
||||
public class LlmCacheAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public PromptCacheStrategy promptCacheStrategy(CacheProperties props) {
|
||||
if (!props.isEnabled()) {
|
||||
return NoOpCacheStrategy.INSTANCE;
|
||||
}
|
||||
PromptCacheStrategy base = new SystemAndTailCacheStrategy();
|
||||
if (props.getAdaptive().isEnabled()) {
|
||||
return new AdaptiveCacheStrategy(
|
||||
base,
|
||||
props.getAdaptive().getMissThreshold(),
|
||||
props.getAdaptive().getCoolDownMs());
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
24
mateclaw-server/src/main/java/vip/mate/llm/cache/NoOpCacheStrategy.java
vendored
Normal file
24
mateclaw-server/src/main/java/vip/mate/llm/cache/NoOpCacheStrategy.java
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
/**
|
||||
* 占位策略:永不打缓存断点。
|
||||
*
|
||||
* <p>用于 {@link AdaptiveCacheStrategy} 在连续 cache miss 后降级,
|
||||
* 或在 {@code mateclaw.llm.cache.enabled=false} 时全局短路。</p>
|
||||
*/
|
||||
public final class NoOpCacheStrategy implements PromptCacheStrategy {
|
||||
|
||||
public static final NoOpCacheStrategy INSTANCE = new NoOpCacheStrategy();
|
||||
|
||||
private NoOpCacheStrategy() {}
|
||||
|
||||
@Override
|
||||
public CachedPlan plan(CachePlanContext ctx) {
|
||||
return CachedPlan.none();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldCache(CachePlanContext ctx) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
33
mateclaw-server/src/main/java/vip/mate/llm/cache/OpenAIResponsesCacheSerializer.java
vendored
Normal file
33
mateclaw-server/src/main/java/vip/mate/llm/cache/OpenAIResponsesCacheSerializer.java
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OpenAI Responses API 的 cache_control 序列化器。
|
||||
*
|
||||
* <p>OpenAI Responses 协议使用与 Anthropic 同形的 {@code cache_control} 字段挂在 input 数组的
|
||||
* content block 上,无需额外 HTTP header。M2 阶段保留断点信息,由
|
||||
* {@code ChatGPTResponsesClient} 在 buildRequestBody 时按指令落字段(M3 完成)。</p>
|
||||
*/
|
||||
public final class OpenAIResponsesCacheSerializer implements CacheSerializer {
|
||||
|
||||
@Override
|
||||
public ModelProtocol protocol() {
|
||||
return ModelProtocol.OPENAI_CHATGPT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CacheDirectives serialize(CachedPlan plan, CachePlanContext ctx) {
|
||||
if (plan == null || plan.isEmpty()) {
|
||||
return CacheDirectives.empty(ModelProtocol.OPENAI_CHATGPT);
|
||||
}
|
||||
// Responses API 没有 TTL 概念,全部按默认;headers 留空
|
||||
return new CacheDirectives(
|
||||
plan.breakpoints().stream().toList(),
|
||||
Map.of(),
|
||||
CacheTtl.DEFAULT_5M,
|
||||
ModelProtocol.OPENAI_CHATGPT);
|
||||
}
|
||||
}
|
||||
28
mateclaw-server/src/main/java/vip/mate/llm/cache/PromptCacheStrategy.java
vendored
Normal file
28
mateclaw-server/src/main/java/vip/mate/llm/cache/PromptCacheStrategy.java
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
/**
|
||||
* Prompt 缓存策略 SPI。
|
||||
*
|
||||
* <p>所有策略实现都被 sealed 锁定,便于 JDK 21 模式匹配 switch 穷尽分支、
|
||||
* 也避免运行期反射式插件加载带来的不可预期成本。</p>
|
||||
*
|
||||
* <p>调用契约:
|
||||
* <ol>
|
||||
* <li>装饰层先调 {@link #shouldCache(CachePlanContext)} 判定是否值得缓存</li>
|
||||
* <li>若 true,调 {@link #plan(CachePlanContext)} 拿到 {@link CachedPlan}</li>
|
||||
* <li>由协议特化的序列化器把断点写到具体请求体</li>
|
||||
* </ol></p>
|
||||
*/
|
||||
public sealed interface PromptCacheStrategy
|
||||
permits SystemAndTailCacheStrategy, NoOpCacheStrategy, AdaptiveCacheStrategy {
|
||||
|
||||
CachedPlan plan(CachePlanContext ctx);
|
||||
|
||||
default boolean shouldCache(CachePlanContext ctx) {
|
||||
if (!ctx.protocolSupportsCacheControl()) {
|
||||
return false;
|
||||
}
|
||||
// 短对话直接跳过:cache write 比常规调用贵 25%,单次性请求会倒亏
|
||||
return ctx.totalPromptTokens() >= ctx.minPromptTokens() && ctx.turnCount() >= 2;
|
||||
}
|
||||
}
|
||||
79
mateclaw-server/src/main/java/vip/mate/llm/cache/SystemAndTailCacheStrategy.java
vendored
Normal file
79
mateclaw-server/src/main/java/vip/mate/llm/cache/SystemAndTailCacheStrategy.java
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 默认策略:在 system / tools / messages 尾部最多打 4 个缓存断点(仿 hermes-agent {@code system_and_3})。
|
||||
*
|
||||
* <p>断点选择规则(按优先级递减):
|
||||
* <ol>
|
||||
* <li>{@link BreakpointKind#SYSTEM_TAIL} —— system 段长度足够时挂一个</li>
|
||||
* <li>{@link BreakpointKind#TOOLS_TAIL} —— {@code includeToolsBlock=true} 且至少有 1 个工具</li>
|
||||
* <li>{@link BreakpointKind#MESSAGES_TAIL} —— 倒数第 3 条 user/assistant 消息</li>
|
||||
* <li>{@link BreakpointKind#MESSAGES_TAIL} —— 最后一条 user 消息(若与 #3 不同索引)</li>
|
||||
* </ol></p>
|
||||
*
|
||||
* <p>这是无状态的纯函数实现,单例线程安全;任何调度方都可直接复用。</p>
|
||||
*/
|
||||
public final class SystemAndTailCacheStrategy implements PromptCacheStrategy {
|
||||
|
||||
/** system 段至少 256 字符才值得打断点(cache write 摊销下限)。 */
|
||||
private static final int MIN_SYSTEM_CHARS = 256;
|
||||
|
||||
@Override
|
||||
public CachedPlan plan(CachePlanContext ctx) {
|
||||
if (!shouldCache(ctx)) {
|
||||
return CachedPlan.none();
|
||||
}
|
||||
|
||||
List<Breakpoint> bps = new ArrayList<>(ctx.maxBreakpoints());
|
||||
|
||||
if (ctx.hasSystemMessage() && ctx.systemCharLen() >= MIN_SYSTEM_CHARS) {
|
||||
bps.add(Breakpoint.systemTail());
|
||||
}
|
||||
if (ctx.includeToolsBlock() && bps.size() < ctx.maxBreakpoints()) {
|
||||
bps.add(Breakpoint.toolsTail());
|
||||
}
|
||||
|
||||
// messages 尾部断点:倒数第 3 与最后 1(去重)
|
||||
List<Message> msgs = ctx.messages();
|
||||
int lastUserIdx = lastIndexOfType(msgs, MessageType.USER);
|
||||
int thirdLastTurnIdx = nthLastTurnIndex(msgs, 3);
|
||||
|
||||
if (thirdLastTurnIdx >= 0 && bps.size() < ctx.maxBreakpoints()) {
|
||||
bps.add(Breakpoint.messagesTail(thirdLastTurnIdx));
|
||||
}
|
||||
if (lastUserIdx >= 0 && lastUserIdx != thirdLastTurnIdx
|
||||
&& bps.size() < ctx.maxBreakpoints()) {
|
||||
bps.add(Breakpoint.messagesTail(lastUserIdx));
|
||||
}
|
||||
|
||||
return bps.isEmpty() ? CachedPlan.none() : new CachedPlan(bps, ctx.ttl());
|
||||
}
|
||||
|
||||
private static int lastIndexOfType(List<Message> msgs, MessageType type) {
|
||||
for (int i = msgs.size() - 1; i >= 0; i--) {
|
||||
if (msgs.get(i).getMessageType() == type) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 找倒数第 n 个 user/assistant 消息的索引(system 与 tool 不计)。
|
||||
* n=1 → 最后一条;n=3 → 倒数第三条。
|
||||
*/
|
||||
private static int nthLastTurnIndex(List<Message> msgs, int n) {
|
||||
int seen = 0;
|
||||
for (int i = msgs.size() - 1; i >= 0; i--) {
|
||||
MessageType t = msgs.get(i).getMessageType();
|
||||
if (t == MessageType.USER || t == MessageType.ASSISTANT) {
|
||||
if (++seen == n) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@ -93,6 +93,11 @@ public class SkillHubClient {
|
||||
|
||||
/**
|
||||
* 获取 skill bundle 详情
|
||||
* <p>
|
||||
* 与 {@link #search} 共享同一套重试策略:408/429/5xx 状态码或 IO 异常时按
|
||||
* 指数退避(800/1600/3200ms)重试,最多 {@code httpRetries} 次。
|
||||
* 此外当 bundle 内容({@code content})为空时直接返回 null —— 空 bundle
|
||||
* 重装会清空用户的 SKILL.md,是不可接受的"成功"。
|
||||
*/
|
||||
public SkillBundle fetchBundle(String slug, String version) {
|
||||
String path = version != null && !version.isBlank()
|
||||
@ -100,27 +105,54 @@ public class SkillHubClient {
|
||||
: "/api/v1/skills/" + slug;
|
||||
String url = properties.getBaseUrl() + path;
|
||||
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(properties.getHttpTimeout()))
|
||||
.GET()
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "MateClaw/1.0")
|
||||
.build();
|
||||
for (int attempt = 0; attempt <= properties.getHttpRetries(); attempt++) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(properties.getHttpTimeout()))
|
||||
.GET()
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "MateClaw/1.0")
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
int status = response.statusCode();
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
return parseBundleResponse(response.body(), slug);
|
||||
if (status == 200) {
|
||||
SkillBundle bundle = parseBundleResponse(response.body(), slug);
|
||||
if (bundle == null || bundle.content() == null || bundle.content().isBlank()) {
|
||||
log.warn("Hub fetchBundle returned empty content for '{}'; treat as failure to avoid wiping local SKILL.md", slug);
|
||||
return null;
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
if (isRetryable(status) && attempt < properties.getHttpRetries()) {
|
||||
log.warn("Hub fetchBundle attempt {} for '{}' failed with status {}, retrying...", attempt + 1, slug, status);
|
||||
Thread.sleep(backoffMs(attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
log.warn("Hub fetchBundle failed for '{}': status {}", slug, status);
|
||||
return null;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
if (attempt < properties.getHttpRetries()) {
|
||||
log.warn("Hub fetchBundle attempt {} for '{}' error: {}, retrying...", attempt + 1, slug, e.getMessage());
|
||||
try {
|
||||
Thread.sleep(backoffMs(attempt));
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
log.error("Hub fetchBundle failed after {} attempts for '{}': {}", properties.getHttpRetries() + 1, slug, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("Hub fetchBundle failed for '{}': status {}", slug, response.statusCode());
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("Hub fetchBundle error for '{}': {}", slug, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
@ -151,7 +151,8 @@ public class SkillInstaller {
|
||||
if (exists) {
|
||||
workspaceManager.cleanWorkspaceDataDirs(skillName);
|
||||
}
|
||||
workspaceManager.initWorkspace(skillName, bundle.content());
|
||||
// 重装时 (exists=true) 覆写 SKILL.md;否则保留已有内容(向后兼容首次创建语义)
|
||||
workspaceManager.initWorkspace(skillName, bundle.content(), exists);
|
||||
|
||||
// 写入 references/
|
||||
if (bundle.references() != null) {
|
||||
|
||||
@ -93,13 +93,26 @@ public class SkillWorkspaceManager {
|
||||
// ==================== 生命周期操作 ====================
|
||||
|
||||
/**
|
||||
* 初始化 skill 工作区目录
|
||||
* 初始化 skill 工作区目录(兼容旧调用:overwrite=false)
|
||||
*
|
||||
* @param skillName skill 名称
|
||||
* @param initialContent SKILL.md 初始内容(可为 null)
|
||||
* @return 创建的工作区路径
|
||||
*/
|
||||
public Path initWorkspace(String skillName, String initialContent) {
|
||||
return initWorkspace(skillName, initialContent, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 skill 工作区目录
|
||||
*
|
||||
* @param skillName skill 名称
|
||||
* @param initialContent SKILL.md 初始内容(可为 null)
|
||||
* @param overwrite true 时无条件覆写 SKILL.md(用于重装 / 导出场景),
|
||||
* false 时仅在 SKILL.md 不存在时写入(用于首次创建)
|
||||
* @return 创建的工作区路径
|
||||
*/
|
||||
public Path initWorkspace(String skillName, String initialContent, boolean overwrite) {
|
||||
Path workspaceDir = resolveConventionPath(skillName);
|
||||
try {
|
||||
Files.createDirectories(workspaceDir);
|
||||
@ -107,14 +120,14 @@ public class SkillWorkspaceManager {
|
||||
Files.createDirectories(workspaceDir.resolve("scripts"));
|
||||
|
||||
Path skillMd = workspaceDir.resolve("SKILL.md");
|
||||
if (!Files.exists(skillMd)) {
|
||||
if (overwrite || !Files.exists(skillMd)) {
|
||||
String content = (initialContent != null && !initialContent.isBlank())
|
||||
? initialContent
|
||||
: buildDefaultSkillMd(skillName);
|
||||
Files.writeString(skillMd, content);
|
||||
}
|
||||
|
||||
log.info("Initialized skill workspace: {}", workspaceDir);
|
||||
log.info("Initialized skill workspace: {} (overwrite={})", workspaceDir, overwrite);
|
||||
eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, workspaceDir));
|
||||
return workspaceDir;
|
||||
} catch (IOException e) {
|
||||
@ -151,18 +164,11 @@ public class SkillWorkspaceManager {
|
||||
* 将数据库 skill 内容导出到工作区目录
|
||||
*/
|
||||
public Path exportToWorkspace(String skillName, String skillContent) {
|
||||
Path workspaceDir = initWorkspace(skillName, skillContent);
|
||||
// 始终覆写 SKILL.md(initWorkspace 内部已写入),无需再做一次冗余 IO
|
||||
Path workspaceDir = initWorkspace(skillName, skillContent, true);
|
||||
if (workspaceDir != null) {
|
||||
try {
|
||||
// 覆盖写入 SKILL.md
|
||||
if (skillContent != null && !skillContent.isBlank()) {
|
||||
Files.writeString(workspaceDir.resolve("SKILL.md"), skillContent);
|
||||
}
|
||||
log.info("Exported skill '{}' to workspace: {}", skillName, workspaceDir);
|
||||
eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.EXPORTED, workspaceDir));
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to export skill '{}': {}", skillName, e.getMessage());
|
||||
}
|
||||
log.info("Exported skill '{}' to workspace: {}", skillName, workspaceDir);
|
||||
eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.EXPORTED, workspaceDir));
|
||||
}
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
@ -40,6 +40,16 @@ public class WikiProperties {
|
||||
*/
|
||||
private int maxParallelChunks = 5;
|
||||
|
||||
/**
|
||||
* 单个 chunk 内 phase B 阶段的 page 并行处理数上限。
|
||||
* <p>
|
||||
* RFC-012 follow-up #3:原实现 phase B 的 create / merge 循环逐页串行调用 LLM,
|
||||
* 单 chunk N 个 page 就要串行 N 次 LLM 调用——一个卡超时整条流水线停摆。
|
||||
* 改为受此 Semaphore 控制的并行,默认 3。结合 maxParallelRawMaterials × maxParallelChunks
|
||||
* × maxParallelPhaseBPages = 3 × 5 × 3 = 45 的理论最大并发,实际按 LLM 限流为准。
|
||||
*/
|
||||
private int maxParallelPhaseBPages = 3;
|
||||
|
||||
/** 注入 agent prompt 的最大字符数 */
|
||||
private int maxContextChars = 10000;
|
||||
|
||||
|
||||
@ -3,9 +3,12 @@ package vip.mate.wiki.controller;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
@ -19,6 +22,7 @@ import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
import vip.mate.wiki.service.WikiPageService;
|
||||
import vip.mate.wiki.service.WikiProcessingService;
|
||||
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||
import vip.mate.wiki.sse.WikiProgressBus;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@ -34,6 +38,7 @@ import java.util.Map;
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Wiki 知识库")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/wiki")
|
||||
@ -47,6 +52,7 @@ public class WikiController {
|
||||
private final WikiDirectoryScanService scanService;
|
||||
private final WikiProperties properties;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final WikiProgressBus progressBus;
|
||||
|
||||
// ==================== Knowledge Base ====================
|
||||
|
||||
@ -374,6 +380,59 @@ public class WikiController {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-012 M3:订阅指定 KB 的处理进度 SSE 流。
|
||||
* <p>
|
||||
* 客户端通过 {@code new EventSource('/api/v1/wiki/knowledge-bases/{kbId}/progress')} 订阅,
|
||||
* 然后按事件名监听:
|
||||
* <ul>
|
||||
* <li>{@code raw.started} — 某个 raw material 进入处理</li>
|
||||
* <li>{@code route.done} — phase A 完成、phase B 启动(此时 total 已确定)</li>
|
||||
* <li>{@code chunk.done} — phase B 单页落地(带 done/total)</li>
|
||||
* <li>{@code raw.completed} — raw material 处理完成(终态:completed/partial)</li>
|
||||
* <li>{@code raw.failed} — raw material 处理失败</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* SSE 是 best-effort:服务端断线、客户端断线、代理切流都可能丢事件,
|
||||
* 因此前端仍需保留 60s 兜底轮询 {@code GET .../processing-status} 作为真源。
|
||||
* <p>
|
||||
* Emitter 默认 30 分钟超时,足以覆盖最长的 raw 处理时间;超时后客户端
|
||||
* 自动重连(EventSource 默认行为)。
|
||||
*/
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "订阅处理进度 SSE")
|
||||
@GetMapping(value = "/knowledge-bases/{kbId}/progress", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter subscribeProgress(@PathVariable Long kbId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
SseEmitter emitter = new SseEmitter(30L * 60 * 1000); // 30min
|
||||
progressBus.subscribe(kbId, emitter);
|
||||
|
||||
emitter.onCompletion(() -> {
|
||||
progressBus.unsubscribe(kbId, emitter);
|
||||
log.debug("[Wiki SSE] emitter completed: kbId={}", kbId);
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
progressBus.unsubscribe(kbId, emitter);
|
||||
try { emitter.complete(); } catch (Exception ignore) { /* best-effort */ }
|
||||
log.debug("[Wiki SSE] emitter timeout: kbId={}", kbId);
|
||||
});
|
||||
emitter.onError(e -> {
|
||||
progressBus.unsubscribe(kbId, emitter);
|
||||
log.debug("[Wiki SSE] emitter error: kbId={}, cause={}", kbId, e.getMessage());
|
||||
});
|
||||
|
||||
// 立即发一个 hello 事件,确认连接已建立
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(WikiProgressBus.EVENT_HEARTBEAT)
|
||||
.data("{\"ts\":" + System.currentTimeMillis() + ",\"hello\":true}"));
|
||||
} catch (Exception e) {
|
||||
log.debug("[Wiki SSE] initial heartbeat send failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// ==================== Workspace Verification ====================
|
||||
|
||||
private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) {
|
||||
|
||||
@ -132,6 +132,38 @@ public class WikiPageService {
|
||||
.eq(WikiPageEntity::getSlug, slug));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 slug 规范化为 canonical 形式:去掉所有连字符 / 下划线 + 转小写。
|
||||
* <p>
|
||||
* 用于跨拼写匹配:{@code "shennong-bencao-jing"} 和 {@code "shen-nong-ben-cao-jing"}
|
||||
* 都规范化为 {@code "shennongbencaojing"},被视为同一概念。LLM 在并行处理大文档时
|
||||
* 经常对同一概念给出不同 slug 拼写(按词分组 vs 按字分隔),这是兜底归一逻辑的基础。
|
||||
*/
|
||||
public static String canonicalSlug(String slug) {
|
||||
if (slug == null) return "";
|
||||
return slug.toLowerCase().replace("-", "").replace("_", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 canonical slug 在指定 KB 中查找已存在的 page。
|
||||
* <p>
|
||||
* 命中条件:现有 page 的 slug 经 {@link #canonicalSlug(String)} 后与给定 slug 的
|
||||
* canonical 形式相等。复用 {@link #listSummaries(Long)} 的 5 分钟缓存,命中后再
|
||||
* {@link #getBySlug(Long, String)} 拿完整 entity,避免额外全表扫描。
|
||||
*
|
||||
* @return 第一个 canonical 匹配的 page;找不到返回 {@code null}
|
||||
*/
|
||||
public WikiPageEntity findByCanonicalSlug(Long kbId, String slug) {
|
||||
String canonical = canonicalSlug(slug);
|
||||
if (canonical.isEmpty()) return null;
|
||||
for (WikiPageEntity p : listSummaries(kbId)) {
|
||||
if (canonicalSlug(p.getSlug()).equals(canonical)) {
|
||||
return getBySlug(kbId, p.getSlug());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public WikiPageEntity getById(Long id) {
|
||||
return pageMapper.selectById(id);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
@ -18,6 +19,7 @@ import vip.mate.wiki.WikiProperties;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||
import vip.mate.wiki.sse.WikiProgressBus;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -48,6 +50,7 @@ public class WikiProcessingService {
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WikiProgressBus progressBus;
|
||||
|
||||
/** 并行 chunk / 材料处理执行器(JDK 21 虚拟线程);Listener 跨包需要引用,故 public */
|
||||
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||
@ -61,7 +64,19 @@ public class WikiProcessingService {
|
||||
private static final class ProgressCounter {
|
||||
final AtomicInteger total = new AtomicInteger(0);
|
||||
final AtomicInteger done = new AtomicInteger(0);
|
||||
/** Page-level 失败计数(chunk 内单页 create / merge 抛异常)。
|
||||
* 注:DuplicateKeyException 触发的 fallback-to-update 不算 failure,
|
||||
* 内容仍合入了同 slug page。仅 LLM 调用爆炸、JSON 解析失败、内容为空等真失败才递增。 */
|
||||
final AtomicInteger failed = new AtomicInteger(0);
|
||||
final AtomicBoolean phaseBStarted = new AtomicBoolean(false);
|
||||
/**
|
||||
* 跨 chunk slug 抢占表:canonical slug → 第一个声明该概念的实际 slug。
|
||||
* <p>
|
||||
* 解决 LLM 在并行 chunk 中给同一概念起不同 slug 拼写(按词分组 vs 按字分隔)的问题。
|
||||
* 使用 {@link ConcurrentHashMap#computeIfAbsent} 实现原子抢占:先到的 chunk 把自己的
|
||||
* slug 注册为 winner,后到的 chunk 看到 winner 后会把内容写入 winner 对应的 page。
|
||||
*/
|
||||
final ConcurrentHashMap<String, String> slugClaims = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
private final ConcurrentHashMap<Long, ProgressCounter> progressCounters = new ConcurrentHashMap<>();
|
||||
@ -80,6 +95,12 @@ public class WikiProcessingService {
|
||||
* @param force 为 true 时忽略 content_hash 短路(RFC-012 Change 5),用于模型/提示词变更后的强制重跑
|
||||
*/
|
||||
public void processRawMaterial(Long rawId, boolean force) {
|
||||
// RFC-012 follow-up #3:消费续传标志(reprocess() 把 partial 改回 pending 之前打的标)。
|
||||
// 必须在 claimForProcessing 之前读,因为 claim 会把状态再改一次。
|
||||
// flag 只在内存中,server 重启会丢 → 重启后仍按 pending 走正常流程(退化为全量重跑,
|
||||
// 功能不丢失只是性能回退)。
|
||||
boolean isPartialResume = rawService.consumePartialResumeFlag(rawId);
|
||||
|
||||
// CAS 式抢占:防止并发重复处理
|
||||
if (!rawService.claimForProcessing(rawId)) {
|
||||
log.debug("[Wiki] Raw material {} already claimed or not pending, skipping", rawId);
|
||||
@ -113,6 +134,10 @@ public class WikiProcessingService {
|
||||
progressCounters.put(rawId, new ProgressCounter());
|
||||
rawService.updateProgress(rawId, "route", 0, 0); // UI 立即看到 indeterminate 滑条
|
||||
|
||||
// RFC-012 M3:广播 raw.started(前端切到 indeterminate 进度条)
|
||||
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_STARTED,
|
||||
java.util.Map.of("rawId", rawId, "phase", "route"));
|
||||
|
||||
try {
|
||||
// Phase 1: 获取文本内容
|
||||
String textContent = rawService.getTextContent(raw);
|
||||
@ -123,9 +148,19 @@ public class WikiProcessingService {
|
||||
}
|
||||
|
||||
// Phase 2: 清除该材料之前生成的旧页面(仅独占+非手工页面)
|
||||
int cleaned = pageService.deleteExclusiveBySourceRawId(kb.getId(), rawId);
|
||||
if (cleaned > 0) {
|
||||
log.info("[Wiki] Cleaned {} exclusive old pages for raw material {} before reprocessing", cleaned, rawId);
|
||||
// RFC-012 follow-up #3:partial 状态走「续传」路径 —— 保留已生成的 page,让
|
||||
// route 阶段通过 existingPagesIndex 把它们归到 update 列表(phase B merge 覆盖
|
||||
// 当前 chunk 内容),失败的 slug 在 DB 里不存在,LLM 会放进 create 列表重跑。
|
||||
// isPartialResume 标记在 rawService.reprocess() 中写入 in-memory 集合,由
|
||||
// rawService.consumePartialResumeFlag() 在 claim 之前消费掉;无法通过
|
||||
// raw.getProcessingStatus() 判断是因为 claimForProcessing 已经把它改成 "processing"。
|
||||
if (isPartialResume) {
|
||||
log.info("[Wiki] Partial resume for raw={}: keeping existing pages, LLM will merge new content into them via existingPagesIndex", rawId);
|
||||
} else {
|
||||
int cleaned = pageService.deleteExclusiveBySourceRawId(kb.getId(), rawId);
|
||||
if (cleaned > 0) {
|
||||
log.info("[Wiki] Cleaned {} exclusive old pages for raw material {} before reprocessing", cleaned, rawId);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: 构建已有页面索引(一次构建,所有 chunk 共用)
|
||||
@ -146,16 +181,36 @@ public class WikiProcessingService {
|
||||
int totalChunks = result[2];
|
||||
|
||||
// Phase 3: 更新状态和计数
|
||||
// 读 page 级失败数(finally 之前读,finally 才 remove counter)
|
||||
ProgressCounter pcFinal = progressCounters.get(rawId);
|
||||
int failedPages = pcFinal != null ? pcFinal.failed.get() : 0;
|
||||
|
||||
String finalStatus;
|
||||
String finalDetail = null;
|
||||
if (totalPages == 0) {
|
||||
rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response");
|
||||
} else if (failedChunks > 0) {
|
||||
// 部分成功:有些 chunk 失败但有些产出了页面
|
||||
rawService.updateProcessingStatus(rawId, "partial",
|
||||
failedChunks + " of " + totalChunks + " chunks failed, " + totalPages + " pages generated");
|
||||
finalStatus = "failed";
|
||||
finalDetail = "No pages generated from LLM response";
|
||||
} else if (failedChunks > 0 || failedPages > 0) {
|
||||
// 部分成功:chunk 整体失败 或 chunk 内有 page 失败
|
||||
// (M2 v2 follow-up:page 级失败原本被计入 completed,现在正确归 partial)
|
||||
StringBuilder detail = new StringBuilder();
|
||||
if (failedChunks > 0) {
|
||||
detail.append(failedChunks).append(" of ").append(totalChunks).append(" chunks failed");
|
||||
}
|
||||
if (failedPages > 0) {
|
||||
if (detail.length() > 0) detail.append("; ");
|
||||
detail.append(failedPages).append(" page(s) failed");
|
||||
}
|
||||
detail.append(", ").append(totalPages).append(" pages generated");
|
||||
finalDetail = detail.toString();
|
||||
rawService.updateProcessingStatus(rawId, "partial", finalDetail);
|
||||
finalStatus = "partial";
|
||||
// 【Review Bug 1】partial 不写 lastProcessedHash:partial 的语义就是"还有失败、需要再跑",
|
||||
// 写了会导致下次用户点"重新处理"被 hash 短路直接跳过,永远没机会修失败的 chunk。
|
||||
} else {
|
||||
rawService.updateProcessingStatus(rawId, "completed", null);
|
||||
finalStatus = "completed";
|
||||
// RFC-012 Change 5:记录本次成功处理时的 hash,供下次短路判断
|
||||
if (raw.getContentHash() != null) {
|
||||
rawService.setLastProcessedHash(rawId, raw.getContentHash());
|
||||
@ -165,6 +220,19 @@ public class WikiProcessingService {
|
||||
kbService.setPageCount(kb.getId(), pageCount);
|
||||
kbService.updateStatus(kb.getId(), "active");
|
||||
|
||||
// RFC-012 M3:广播终态
|
||||
if ("failed".equals(finalStatus)) {
|
||||
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED,
|
||||
java.util.Map.of("rawId", rawId, "error", finalDetail == null ? "" : finalDetail));
|
||||
} else {
|
||||
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED,
|
||||
java.util.Map.of(
|
||||
"rawId", rawId,
|
||||
"status", finalStatus,
|
||||
"totalPages", totalPages,
|
||||
"kbPageCount", pageCount));
|
||||
}
|
||||
|
||||
log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}",
|
||||
rawId, kb.getId(), totalPages, pageCount);
|
||||
|
||||
@ -172,6 +240,9 @@ public class WikiProcessingService {
|
||||
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
|
||||
rawService.updateProcessingStatus(rawId, "failed", e.getMessage());
|
||||
kbService.updateStatus(kb.getId(), "active");
|
||||
// RFC-012 M3:广播异常终态
|
||||
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED,
|
||||
java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage()));
|
||||
} finally {
|
||||
// RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器
|
||||
ProgressCounter pc = progressCounters.remove(rawId);
|
||||
@ -422,8 +493,9 @@ public class WikiProcessingService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int created = 0;
|
||||
int updated = 0;
|
||||
// RFC-012 follow-up #3:phase B 现在并行执行,计数必须是 atomic
|
||||
AtomicInteger created = new AtomicInteger(0);
|
||||
AtomicInteger updated = new AtomicInteger(0);
|
||||
|
||||
// ─── 收集 route 输出(仅 metadata,无 content) ───
|
||||
List<JsonNode> createMetas = new ArrayList<>();
|
||||
@ -453,46 +525,113 @@ public class WikiProcessingService {
|
||||
pc.total.addAndGet(totalPlanned);
|
||||
if (pc.phaseBStarted.compareAndSet(false, true)) {
|
||||
log.info("[Wiki] Progress: switching to phase-b for raw={}", rawId);
|
||||
// RFC-012 M3:route 完成、phase-b 启动 → 通知前端确定进度(可显示 0/N)
|
||||
progressBus.broadcast(kbId, WikiProgressBus.EVENT_ROUTE_DONE,
|
||||
java.util.Map.of(
|
||||
"rawId", rawId,
|
||||
"phase", "phase-b",
|
||||
"done", pc.done.get(),
|
||||
"total", pc.total.get()));
|
||||
}
|
||||
rawService.updateProgress(rawId, "phase-b", pc.done.get(), pc.total.get());
|
||||
}
|
||||
|
||||
// ─── 阶段 B-1:逐页 create(每页一次单独 LLM call,输入/输出都是单页规模) ───
|
||||
// RFC-012 follow-up #3:阶段 B 页级并发。每个 page 是独立的 LLM 调用,相互无依赖,
|
||||
// 串行跑会让一个卡超时的 page 阻塞整个 chunk。受 maxParallelPhaseBPages Semaphore 约束,
|
||||
// 复用虚拟线程池 WIKI_EXECUTOR。
|
||||
int parallelPages = Math.max(1, properties.getMaxParallelPhaseBPages());
|
||||
Semaphore pageSem = new Semaphore(parallelPages);
|
||||
|
||||
// ─── 阶段 B-1:并行 create ───
|
||||
List<CompletableFuture<Void>> createFutures = new ArrayList<>(createMetas.size());
|
||||
for (JsonNode meta : createMetas) {
|
||||
try {
|
||||
if (createOnePage(kb, raw, textContent, existingPagesIndex, meta)) {
|
||||
created++;
|
||||
final JsonNode metaRef = meta;
|
||||
createFutures.add(CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
pageSem.acquire();
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[Wiki] Phase B create page slug='{}' failed: {}",
|
||||
meta.path("slug").asText(""), e.getMessage());
|
||||
}
|
||||
// 无论成功失败都推进 done 计数,避免失败页卡死 UI 进度
|
||||
if (pc != null) {
|
||||
int d = pc.done.incrementAndGet();
|
||||
rawService.updateProgress(rawId, "phase-b", d, pc.total.get());
|
||||
}
|
||||
boolean ok = false;
|
||||
try {
|
||||
try {
|
||||
if (createOnePage(kb, raw, textContent, existingPagesIndex, metaRef)) {
|
||||
created.incrementAndGet();
|
||||
}
|
||||
// createOnePage 内部的 DuplicateKey / canonical / claim fallback 不抛异常 → ok=true。
|
||||
ok = true;
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[Wiki] Phase B create page slug='{}' failed: {}",
|
||||
metaRef.path("slug").asText(""), e.getMessage());
|
||||
}
|
||||
if (pc != null) {
|
||||
int d = pc.done.incrementAndGet();
|
||||
if (!ok) pc.failed.incrementAndGet();
|
||||
rawService.updateProgress(rawId, "phase-b", d, pc.total.get());
|
||||
progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE,
|
||||
java.util.Map.of(
|
||||
"rawId", rawId,
|
||||
"kind", "create",
|
||||
"ok", ok,
|
||||
"done", d,
|
||||
"total", pc.total.get()));
|
||||
}
|
||||
} finally {
|
||||
pageSem.release();
|
||||
}
|
||||
}, WIKI_EXECUTOR));
|
||||
}
|
||||
|
||||
// ─── 阶段 B-2:逐页 merge(每页一次单独 LLM call) ───
|
||||
// ─── 阶段 B-2:并行 merge ───
|
||||
List<CompletableFuture<Void>> mergeFutures = new ArrayList<>(updateSlugs.size());
|
||||
for (String slug : updateSlugs) {
|
||||
try {
|
||||
if (mergeOnePage(kb, raw, textContent, slug)) {
|
||||
updated++;
|
||||
final String mergeSlug = slug;
|
||||
mergeFutures.add(CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
pageSem.acquire();
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[Wiki] Phase B merge page slug='{}' failed: {}", slug, e.getMessage());
|
||||
}
|
||||
if (pc != null) {
|
||||
int d = pc.done.incrementAndGet();
|
||||
rawService.updateProgress(rawId, "phase-b", d, pc.total.get());
|
||||
}
|
||||
boolean ok = false;
|
||||
try {
|
||||
try {
|
||||
if (mergeOnePage(kb, raw, textContent, mergeSlug)) {
|
||||
updated.incrementAndGet();
|
||||
}
|
||||
ok = true;
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[Wiki] Phase B merge page slug='{}' failed: {}", mergeSlug, e.getMessage());
|
||||
}
|
||||
if (pc != null) {
|
||||
int d = pc.done.incrementAndGet();
|
||||
if (!ok) pc.failed.incrementAndGet();
|
||||
rawService.updateProgress(rawId, "phase-b", d, pc.total.get());
|
||||
progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE,
|
||||
java.util.Map.of(
|
||||
"rawId", rawId,
|
||||
"kind", "merge",
|
||||
"ok", ok,
|
||||
"done", d,
|
||||
"total", pc.total.get()));
|
||||
}
|
||||
} finally {
|
||||
pageSem.release();
|
||||
}
|
||||
}, WIKI_EXECUTOR));
|
||||
}
|
||||
|
||||
// 等待本 chunk 的 create + merge 全部完成
|
||||
List<CompletableFuture<Void>> allFutures = new ArrayList<>(createFutures.size() + mergeFutures.size());
|
||||
allFutures.addAll(createFutures);
|
||||
allFutures.addAll(mergeFutures);
|
||||
CompletableFuture.allOf(allFutures.toArray(new CompletableFuture[0])).join();
|
||||
// 单 chunk 完成时不写"done"——多 chunk 还在跑;最终"done"由 processRawMaterial 的 finally 写入
|
||||
|
||||
log.info("[Wiki] Two-phase digest applied: kbId={}, rawId={}, created={}, updated={}",
|
||||
kbId, rawId, created, updated);
|
||||
return created + updated;
|
||||
kbId, rawId, created.get(), updated.get());
|
||||
return created.get() + updated.get();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -544,7 +683,44 @@ public class WikiProcessingService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 兜底:如果 slug 已存在(route 误判 / 并发),走 update 而不是 create
|
||||
// 兜底 0:跨拼写 canonical 匹配(DB 已有 page,但 slug 拼写不同)
|
||||
// 覆盖场景:之前上传的 raw 已经创建了同概念 page,本次 LLM 给了不同拼写
|
||||
WikiPageEntity existingByCanonical = pageService.findByCanonicalSlug(kbId, slug);
|
||||
if (existingByCanonical != null && !existingByCanonical.getSlug().equals(slug)) {
|
||||
String actualSlug = existingByCanonical.getSlug();
|
||||
pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId);
|
||||
log.info("[Wiki] Phase B create slug='{}' canonical-matches existing '{}', updated",
|
||||
slug, actualSlug);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 兜底 0.5:跨 chunk in-flight slug 抢占(同一 raw 的另一并发 chunk 已声明同概念)
|
||||
// computeIfAbsent 是原子操作,先到的 chunk 把自己 slug 注册为 winner
|
||||
ProgressCounter pcLocal = progressCounters.get(rawId);
|
||||
String canonical = WikiPageService.canonicalSlug(slug);
|
||||
if (pcLocal != null && !canonical.isEmpty()) {
|
||||
// lambda 要求 effectively final,用 finalSlug 副本
|
||||
final String routedSlug = slug;
|
||||
String winnerSlug = pcLocal.slugClaims.computeIfAbsent(canonical, k -> routedSlug);
|
||||
if (!winnerSlug.equals(slug)) {
|
||||
// 另一 chunk 先 claim 了同 canonical,但用了不同 slug 拼写
|
||||
WikiPageEntity winner = pageService.getBySlug(kbId, winnerSlug);
|
||||
if (winner != null) {
|
||||
// winner 已 INSERT 进 DB → 直接 update
|
||||
pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId);
|
||||
log.info("[Wiki] Phase B create slug='{}' lost slug-claim race to '{}', updated",
|
||||
slug, winnerSlug);
|
||||
return false;
|
||||
}
|
||||
// winner claim 早于 INSERT(claim 是 in-memory,INSERT 是 DB IO)
|
||||
// → 用 winnerSlug 继续走下面的 INSERT 路径,DuplicateKey fallback 会兜住实际 race
|
||||
log.info("[Wiki] Phase B create slug='{}' redirects to in-flight winner '{}'",
|
||||
slug, winnerSlug);
|
||||
slug = winnerSlug;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底 1:如果 slug 已存在(route 误判 / 上一次成功 INSERT),走 update 而不是 create
|
||||
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
|
||||
if (existing != null) {
|
||||
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
|
||||
@ -552,9 +728,19 @@ public class WikiProcessingService {
|
||||
return false; // 不计入 created
|
||||
}
|
||||
String sourceRawIds = "[" + rawId + "]";
|
||||
pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
|
||||
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
|
||||
return true;
|
||||
try {
|
||||
pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
|
||||
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
|
||||
return true;
|
||||
} catch (org.springframework.dao.DuplicateKeyException e) {
|
||||
// 兜底 2:select-then-create 在并发下不是原子操作。当 N 个 chunk 同时
|
||||
// route 出相同 slug,只有第一个 INSERT 能成功,其余都会触发 H2/MySQL
|
||||
// unique key violation。本次 chunk 的 LLM 输出仍有价值——降级为 update,
|
||||
// 把内容合并进已存在的 page,而不是丢弃。
|
||||
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
|
||||
log.info("[Wiki] Phase B create page slug='{}' lost INSERT race -> updated existing", slug);
|
||||
return false; // 不计入 created
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -700,9 +886,19 @@ public class WikiProcessingService {
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-012 follow-up #3:Wiki 调用自带重试层({@link #callLlmWithResilientRetry}),
|
||||
* 所以用 maxAttempts=1 的 RetryTemplate 关掉 Spring AI 的内层重试,避免两层重试互相抵消
|
||||
* (内层默认 2-3 次 × 180s readTimeout = 一次"外层 attempt"消耗 360-540s,
|
||||
* 让 wiki 的 maxTotalDurationMs=240s 被穿越,maxAttempts=5 永远到不了)。
|
||||
*/
|
||||
private static final RetryTemplate WIKI_NO_RETRY = RetryTemplate.builder()
|
||||
.maxAttempts(1)
|
||||
.build();
|
||||
|
||||
private ChatModel buildChatModel() {
|
||||
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
|
||||
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
|
||||
return agentGraphBuilder.buildRuntimeChatModel(defaultModel, WIKI_NO_RETRY);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -18,6 +18,8 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Wiki 原始材料服务
|
||||
@ -35,6 +37,17 @@ public class WikiRawMaterialService {
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final DocumentExtractTool documentExtractTool;
|
||||
|
||||
/**
|
||||
* RFC-012 follow-up #3:从 partial 状态触发的 reprocess 会在此 set 中打标,
|
||||
* 供 {@link vip.mate.wiki.service.WikiProcessingService#processRawMaterial(Long, boolean)}
|
||||
* 在 claim 之前消费,从而决定是否保留已生成的 exclusive page(续传语义)。
|
||||
* <p>
|
||||
* 内存态:server 重启会丢,但原 raw 的 status 已被 reprocess 改为 pending,
|
||||
* 重启后按正常 pending 流程跑(退化为「不删旧页的全量重跑」,功能不丢失只是
|
||||
* 没有走 route 的 "update" 识别路径)。
|
||||
*/
|
||||
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public List<WikiRawMaterialEntity> listByKbId(Long kbId) {
|
||||
List<WikiRawMaterialEntity> list = rawMapper.selectList(
|
||||
new LambdaQueryWrapper<WikiRawMaterialEntity>()
|
||||
@ -227,7 +240,10 @@ public class WikiRawMaterialService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新处理:重置状态为 pending 并发布事件
|
||||
* 重新处理:重置状态为 pending 并发布事件。
|
||||
* <p>
|
||||
* 如果之前状态是 {@code partial},把 rawId 加入 {@link #partialResumeIds},
|
||||
* 下游的 WikiProcessingService 会据此决定是否保留已生成的 exclusive page(续传语义)。
|
||||
*/
|
||||
@Transactional
|
||||
public void reprocess(Long id) {
|
||||
@ -235,12 +251,28 @@ public class WikiRawMaterialService {
|
||||
if (entity == null) {
|
||||
throw new IllegalArgumentException("Raw material not found: " + id);
|
||||
}
|
||||
boolean wasPartial = "partial".equals(entity.getProcessingStatus());
|
||||
entity.setProcessingStatus("pending");
|
||||
entity.setErrorMessage(null);
|
||||
rawMapper.updateById(entity);
|
||||
|
||||
if (wasPartial) {
|
||||
partialResumeIds.add(id);
|
||||
log.info("[Wiki] Raw material queued for PARTIAL RESUME: id={} (existing pages will be kept)", id);
|
||||
} else {
|
||||
log.info("[Wiki] Raw material queued for reprocessing: id={}", id);
|
||||
}
|
||||
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), entity.getKbId()));
|
||||
log.info("[Wiki] Raw material queued for reprocessing: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费 partial resume 标记:若存在则返回 true 并从 set 中移除(一次性)。
|
||||
* <p>
|
||||
* 必须在 {@link #claimForProcessing(Long)} 之前调用:claim 会把 status 改成 processing,
|
||||
* 此时已无法区分 raw 原本是从 partial 还是从 failed/pending 过来的。
|
||||
*/
|
||||
public boolean consumePartialResumeFlag(Long id) {
|
||||
return partialResumeIds.remove(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
package vip.mate.wiki.sse;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* RFC-012 M3:Wiki 处理进度事件总线。
|
||||
* <p>
|
||||
* 维护 {kbId → SseEmitter[]} 订阅表,把后端处理过程中的关键事件
|
||||
* (raw.started / chunk.done / raw.completed / raw.failed)实时推送给所有
|
||||
* 订阅者(一个 KB 可被多 Tab / 多用户同时打开)。
|
||||
* <p>
|
||||
* 设计要点:
|
||||
* <ul>
|
||||
* <li>订阅者列表用 {@link CopyOnWriteArrayList},broadcast 时无需加锁;</li>
|
||||
* <li>事件是 best-effort,不持久化、不重发——客户端断线后由前端 60s 兜底
|
||||
* 拉取({@code GET .../processing-status} 走 DB)补齐;</li>
|
||||
* <li>broadcast 时若某个 emitter send 失败,立即从订阅表中剔除并 complete。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class WikiProgressBus {
|
||||
|
||||
/** 事件名常量(前端按 EventSource.addEventListener 名称匹配) */
|
||||
public static final String EVENT_RAW_STARTED = "raw.started";
|
||||
public static final String EVENT_ROUTE_DONE = "route.done";
|
||||
public static final String EVENT_CHUNK_DONE = "chunk.done";
|
||||
public static final String EVENT_RAW_COMPLETED = "raw.completed";
|
||||
public static final String EVENT_RAW_FAILED = "raw.failed";
|
||||
public static final String EVENT_HEARTBEAT = "heartbeat";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** kbId → 订阅者列表 */
|
||||
private final ConcurrentHashMap<Long, CopyOnWriteArrayList<SseEmitter>> subscribers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 订阅指定 KB 的处理进度事件。
|
||||
*/
|
||||
public void subscribe(Long kbId, SseEmitter emitter) {
|
||||
if (kbId == null || emitter == null) return;
|
||||
subscribers.computeIfAbsent(kbId, k -> new CopyOnWriteArrayList<>()).add(emitter);
|
||||
log.debug("[WikiProgress] subscribe kbId={}, total={}", kbId, subscribers.get(kbId).size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 反订阅;通常由 emitter 的 onCompletion / onTimeout / onError 回调触发。
|
||||
*/
|
||||
public void unsubscribe(Long kbId, SseEmitter emitter) {
|
||||
if (kbId == null || emitter == null) return;
|
||||
CopyOnWriteArrayList<SseEmitter> list = subscribers.get(kbId);
|
||||
if (list != null) {
|
||||
list.remove(emitter);
|
||||
if (list.isEmpty()) subscribers.remove(kbId, list);
|
||||
}
|
||||
log.debug("[WikiProgress] unsubscribe kbId={}", kbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 kb 下所有订阅者广播一个事件。本方法对内部失败完全静默——
|
||||
* 处理管线不应被订阅端 IO 影响。
|
||||
*/
|
||||
public void broadcast(Long kbId, String eventName, Object data) {
|
||||
if (kbId == null || eventName == null) return;
|
||||
CopyOnWriteArrayList<SseEmitter> list = subscribers.get(kbId);
|
||||
if (list == null || list.isEmpty()) return;
|
||||
|
||||
String payload;
|
||||
try {
|
||||
payload = objectMapper.writeValueAsString(data);
|
||||
} catch (Exception e) {
|
||||
payload = "{\"message\":\"serialization_error\"}";
|
||||
}
|
||||
|
||||
Iterator<SseEmitter> it = list.iterator();
|
||||
while (it.hasNext()) {
|
||||
SseEmitter emitter = it.next();
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(eventName).data(payload));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// 客户端早断、emitter 已 complete 等:直接踢出,避免后续广播继续踩雷
|
||||
list.remove(emitter);
|
||||
try { emitter.complete(); } catch (Exception ignore) { /* best-effort */ }
|
||||
log.debug("[WikiProgress] dropped emitter for kbId={}: {}", kbId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供监控/测试:当前订阅者数(所有 KB 之和)。
|
||||
*/
|
||||
public int totalSubscribers() {
|
||||
int total = 0;
|
||||
for (List<SseEmitter> list : subscribers.values()) total += list.size();
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳广播:在没有真实事件流动时定期发一个 heartbeat,
|
||||
* 防止反向代理(如 nginx 默认 60s idle)切断长连接。由调用方按需触发。
|
||||
*/
|
||||
public void heartbeat(Long kbId) {
|
||||
broadcast(kbId, EVENT_HEARTBEAT, java.util.Map.of("ts", System.currentTimeMillis()));
|
||||
}
|
||||
}
|
||||
@ -113,6 +113,18 @@ mateclaw:
|
||||
plugin:
|
||||
enabled: true
|
||||
user-dir: ${user.home}/.mateclaw/plugins
|
||||
# RFC-014: Anthropic prompt cache 标记
|
||||
llm:
|
||||
cache:
|
||||
enabled: true # 总开关;false 时全部走 NoOp
|
||||
min-prompt-tokens: 1024 # 累计 prompt token 低于此值则跳过缓存(避免 cache write 倒亏)
|
||||
max-breakpoints: 4 # 单请求最多打几个 cache_control 断点(Anthropic 上限 4)
|
||||
ttl: DEFAULT # DEFAULT(5min) | EXTENDED_1H(需要 anthropic-beta header)
|
||||
include-tools-block: true # 工具 schema 段是否独立打断点(CONVERSATION_HISTORY 策略)
|
||||
adaptive:
|
||||
enabled: true # 自适应降级(连续 miss 后短路到 NoOp,冷却后恢复)
|
||||
miss-threshold: 5
|
||||
cool-down-ms: 60000
|
||||
|
||||
# MateClaw Agent 配置
|
||||
mate:
|
||||
@ -145,6 +157,7 @@ mate:
|
||||
max-chunk-size: 30000 # LLM 单次处理最大字符数(超过则分块)
|
||||
max-context-chars: 10000 # 注入 Agent prompt 的 Wiki 摘要最大字符数
|
||||
max-pages-per-raw: 15 # 单个原始材料最多生成的 Wiki 页面数
|
||||
max-parallel-phase-b-pages: 3 # RFC-012 follow-up #3:单 chunk 内 phase B 阶段并行处理的 page 数
|
||||
auto-process-on-upload: true # 上传原始材料后是否自动触发 AI 消化
|
||||
upload-dir: ./data/wiki-uploads # 上传文件存储目录
|
||||
max-scan-files: 500 # 目录扫描最大文件数
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
-- V9: Track Anthropic prompt cache token usage
|
||||
-- RFC-014 Change 4: per-call cache_creation_input_tokens / cache_read_input_tokens
|
||||
-- accumulated daily so the dashboard can show cache hit rate and cost savings.
|
||||
-- (was originally numbered V8 but collided with V8__wiki_raw_progress.sql; renumbered to V9.)
|
||||
ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_read_tokens BIGINT DEFAULT 0;
|
||||
ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_write_tokens BIGINT DEFAULT 0;
|
||||
@ -0,0 +1,6 @@
|
||||
-- V9: Track Anthropic prompt cache token usage
|
||||
-- RFC-014 Change 4: per-call cache_creation_input_tokens / cache_read_input_tokens
|
||||
-- accumulated daily so the dashboard can show cache hit rate and cost savings.
|
||||
-- (was originally numbered V8 but collided with V8__wiki_raw_progress.sql; renumbered to V9.)
|
||||
ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_read_tokens BIGINT DEFAULT 0;
|
||||
ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_write_tokens BIGINT DEFAULT 0;
|
||||
@ -616,6 +616,8 @@ CREATE TABLE IF NOT EXISTS mate_usage_daily (
|
||||
total_tokens BIGINT DEFAULT 0,
|
||||
prompt_tokens BIGINT DEFAULT 0,
|
||||
completion_tokens BIGINT DEFAULT 0,
|
||||
cache_read_tokens BIGINT DEFAULT 0, -- RFC-014: anthropic cache_read_input_tokens
|
||||
cache_write_tokens BIGINT DEFAULT 0, -- RFC-014: anthropic cache_creation_input_tokens
|
||||
tool_call_count INT DEFAULT 0,
|
||||
error_count INT DEFAULT 0,
|
||||
create_time DATETIME NOT NULL
|
||||
|
||||
@ -18,7 +18,13 @@
|
||||
## metadata 格式(仅 `create` 数组使用)
|
||||
|
||||
每条 metadata 包含三个字段:
|
||||
- `slug`:URL 安全的标识符(小写字母 + 连字符)
|
||||
- `slug`:URL 安全的标识符(小写字母 + 连字符)。
|
||||
- **多音节中文词的拼音必须按整词分组、不要按字隔开**。
|
||||
- ✅ 正确:`shennong-bencao-jing`(神农 / 本草 / 经 三个词)
|
||||
- ❌ 错误:`shen-nong-ben-cao-jing`(按字一隔,会被识别为另一概念)
|
||||
- ✅ 正确:`zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍)
|
||||
- ❌ 错误:`zhong-yao-qi-qing-pei-wu`
|
||||
- **同一概念在不同段落必须用同一 slug**:选定一个 slug 就坚持用,不要换写法。
|
||||
- `title`:人类可读的页面标题
|
||||
- `summary`:一段话简短摘要(一两句话即可,让"单页生成助手"知道这一页要写什么)
|
||||
|
||||
|
||||
144
mateclaw-server/src/test/java/vip/mate/llm/cache/PromptCacheStrategyTest.java
vendored
Normal file
144
mateclaw-server/src/test/java/vip/mate/llm/cache/PromptCacheStrategyTest.java
vendored
Normal file
@ -0,0 +1,144 @@
|
||||
package vip.mate.llm.cache;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** RFC-014 prompt cache 策略与序列化器的核心单测。 */
|
||||
class PromptCacheStrategyTest {
|
||||
|
||||
private static final SystemAndTailCacheStrategy STRATEGY = new SystemAndTailCacheStrategy();
|
||||
|
||||
private static CachePlanContext ctxFor(List<Message> msgs, ModelProtocol protocol,
|
||||
int totalTokens, int turns) {
|
||||
return new CachePlanContext(new Prompt(msgs), protocol, totalTokens, turns,
|
||||
/*minPromptTokens=*/1024, /*maxBreakpoints=*/4,
|
||||
/*includeToolsBlock=*/true, CacheTtl.DEFAULT_5M);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("短对话不缓存:低于 minPromptTokens 直接 NoOp")
|
||||
void shortConversationNotCached() {
|
||||
var ctx = ctxFor(List.of(new SystemMessage("hi"), new UserMessage("hello")),
|
||||
ModelProtocol.ANTHROPIC_MESSAGES, /*tokens=*/300, /*turns=*/2);
|
||||
assertFalse(STRATEGY.shouldCache(ctx));
|
||||
assertTrue(STRATEGY.plan(ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不支持的协议不缓存:DashScope/OpenAI 兼容直接 NoOp")
|
||||
void unsupportedProtocolNotCached() {
|
||||
var ctx = ctxFor(List.of(new SystemMessage("a".repeat(2048))),
|
||||
ModelProtocol.DASHSCOPE_NATIVE, /*tokens=*/4096, /*turns=*/3);
|
||||
assertFalse(STRATEGY.shouldCache(ctx));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长 system + 对话 → 至少一个 SYSTEM_TAIL 断点")
|
||||
void longSystemEarnsBreakpoint() {
|
||||
var msgs = List.of(
|
||||
(Message) new SystemMessage("a".repeat(1024)),
|
||||
new UserMessage("first question"),
|
||||
new AssistantMessage("first answer"),
|
||||
new UserMessage("second question"),
|
||||
new AssistantMessage("second answer"),
|
||||
new UserMessage("third question")
|
||||
);
|
||||
var ctx = ctxFor(msgs, ModelProtocol.ANTHROPIC_MESSAGES, /*tokens=*/4096, /*turns=*/3);
|
||||
var plan = STRATEGY.plan(ctx);
|
||||
assertFalse(plan.isEmpty());
|
||||
assertTrue(plan.breakpoints().stream().anyMatch(b -> b.kind() == BreakpointKind.SYSTEM_TAIL));
|
||||
// tools 段、messages 倒数第三、最后 user 都应在内
|
||||
assertTrue(plan.size() >= 2);
|
||||
assertTrue(plan.size() <= 4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("断点上限受 maxBreakpoints 约束")
|
||||
void breakpointCountCapped() {
|
||||
var msgs = List.of(
|
||||
(Message) new SystemMessage("a".repeat(1024)),
|
||||
new UserMessage("q1"), new AssistantMessage("a1"),
|
||||
new UserMessage("q2"), new AssistantMessage("a2"),
|
||||
new UserMessage("q3")
|
||||
);
|
||||
var capped = new CachePlanContext(new Prompt(msgs), ModelProtocol.ANTHROPIC_MESSAGES,
|
||||
/*tokens=*/4096, /*turns=*/3, 1024, /*maxBreakpoints=*/2, true, CacheTtl.DEFAULT_5M);
|
||||
assertEquals(2, STRATEGY.plan(capped).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AnthropicCacheOptionsFactory: enabled 状态下产出非 DISABLED 配置")
|
||||
void anthropicFactoryEmitsCacheOptions() {
|
||||
var props = new CacheProperties();
|
||||
props.setEnabled(true);
|
||||
props.setIncludeToolsBlock(true);
|
||||
props.setTtl(CacheProperties.Ttl.EXTENDED_1H);
|
||||
var factory = new AnthropicCacheOptionsFactory(props);
|
||||
var opts = factory.build();
|
||||
assertNotSame(org.springframework.ai.anthropic.api.AnthropicCacheOptions.DISABLED, opts);
|
||||
assertEquals(org.springframework.ai.anthropic.api.AnthropicCacheStrategy.CONVERSATION_HISTORY,
|
||||
opts.getStrategy());
|
||||
// ttl 映射到 SYSTEM/USER ONE_HOUR
|
||||
var ttlMap = opts.getMessageTypeTtl();
|
||||
assertEquals(org.springframework.ai.anthropic.api.AnthropicCacheTtl.ONE_HOUR,
|
||||
ttlMap.get(org.springframework.ai.chat.messages.MessageType.SYSTEM));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AnthropicCacheOptionsFactory: disabled → DISABLED 单例")
|
||||
void anthropicFactoryDisabledFastPath() {
|
||||
var props = new CacheProperties();
|
||||
props.setEnabled(false);
|
||||
var factory = new AnthropicCacheOptionsFactory(props);
|
||||
assertSame(org.springframework.ai.anthropic.api.AnthropicCacheOptions.DISABLED, factory.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AdaptiveCacheStrategy: 连续 miss 后降级,hit 后立即恢复")
|
||||
void adaptiveDegradesAndRecovers() {
|
||||
var inner = new SystemAndTailCacheStrategy();
|
||||
var adaptive = new AdaptiveCacheStrategy(inner, /*missThreshold=*/3, /*coolDownMs=*/60_000);
|
||||
var ctx = ctxFor(List.of(new SystemMessage("a".repeat(1024)),
|
||||
new UserMessage("q1"), new AssistantMessage("a1"),
|
||||
new UserMessage("q2")),
|
||||
ModelProtocol.ANTHROPIC_MESSAGES, 4096, 2);
|
||||
|
||||
assertTrue(adaptive.shouldCache(ctx));
|
||||
adaptive.recordMiss(); adaptive.recordMiss();
|
||||
assertTrue(adaptive.shouldCache(ctx), "未达阈值不应降级");
|
||||
adaptive.recordMiss();
|
||||
assertFalse(adaptive.shouldCache(ctx), "达阈值应降级");
|
||||
adaptive.recordHit();
|
||||
assertTrue(adaptive.shouldCache(ctx), "命中后应立即恢复");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CacheUsageExtractor: 从 record 风格的 native usage 抽取 cache 字段")
|
||||
void cacheUsageExtractorReflectsRecordAccessor() {
|
||||
// 用一个匿名 record 模拟 AnthropicApi.Usage 形态
|
||||
record FakeNativeUsage(Integer cacheReadInputTokens, Integer cacheCreationInputTokens) {}
|
||||
var fakeUsage = new org.springframework.ai.chat.metadata.DefaultUsage(
|
||||
100, 50, 150, new FakeNativeUsage(40, 60));
|
||||
var tokens = CacheUsageExtractor.extract(fakeUsage);
|
||||
assertEquals(40, tokens.cacheReadTokens());
|
||||
assertEquals(60, tokens.cacheWriteTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CacheUsageExtractor: 不支持的 native usage 返回 EMPTY")
|
||||
void cacheUsageExtractorEmptyForUnknownProvider() {
|
||||
var openAiLikeUsage = new org.springframework.ai.chat.metadata.DefaultUsage(100, 50, 150, "no cache here");
|
||||
var tokens = CacheUsageExtractor.extract(openAiLikeUsage);
|
||||
assertSame(CacheUsageExtractor.CacheTokens.EMPTY, tokens);
|
||||
}
|
||||
}
|
||||
56
mateclaw-ui/public/logo/shuzhi_yiliao_logo.svg
Normal file
56
mateclaw-ui/public/logo/shuzhi_yiliao_logo.svg
Normal file
@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="数智医疗">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#F6B48E"/>
|
||||
<stop offset="55%" stop-color="#E68765"/>
|
||||
<stop offset="100%" stop-color="#C4583A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="heartShade" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF"/>
|
||||
<stop offset="100%" stop-color="#FDEDE3"/>
|
||||
</linearGradient>
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2"/>
|
||||
<feOffset dx="0" dy="1.5" result="off"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.28"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded background tile -->
|
||||
<rect width="128" height="128" rx="28" fill="url(#bg)"/>
|
||||
|
||||
<!-- Subtle digital grid dots (数智 hint) -->
|
||||
<g fill="#FFFFFF" opacity="0.18">
|
||||
<circle cx="22" cy="22" r="1.6"/>
|
||||
<circle cx="34" cy="22" r="1.6"/>
|
||||
<circle cx="22" cy="34" r="1.6"/>
|
||||
<circle cx="106" cy="94" r="1.6"/>
|
||||
<circle cx="106" cy="106" r="1.6"/>
|
||||
<circle cx="94" cy="106" r="1.6"/>
|
||||
</g>
|
||||
|
||||
<!-- Heart (medical care) -->
|
||||
<g filter="url(#softShadow)">
|
||||
<path d="M64 100
|
||||
C64 100 24 74 24 50
|
||||
C24 36 35 26 47 26
|
||||
C55 26 61 30 64 36
|
||||
C67 30 73 26 81 26
|
||||
C93 26 104 36 104 50
|
||||
C104 74 64 100 64 100 Z"
|
||||
fill="url(#heartShade)"/>
|
||||
</g>
|
||||
|
||||
<!-- ECG pulse line crossing the heart -->
|
||||
<path d="M20 64 L42 64 L48 54 L56 78 L66 44 L74 72 L82 64 L108 64"
|
||||
fill="none"
|
||||
stroke="#C4583A"
|
||||
stroke-width="5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Pulse endpoint nodes (digital accent) -->
|
||||
<circle cx="20" cy="64" r="3.2" fill="#C4583A"/>
|
||||
<circle cx="108" cy="64" r="3.2" fill="#C4583A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@ -152,30 +152,114 @@ const { t } = useI18n()
|
||||
const store = useWikiStore()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// RFC-012 M2 v2 UI:当列表中存在 processing 的材料时,每 3s 轮询一次刷新进度
|
||||
// 处理完毕(无 processing 项)自动停止;组件卸载时也会清理 timer。
|
||||
let pollTimer: number | null = null
|
||||
// RFC-012 M3:当列表中存在 processing 的材料时,优先订阅后端 SSE 实时进度流,
|
||||
// 60s 兜底拉取 processingStatus / fetchRawMaterials 作为 SSE 断线降级(DB 是真源)。
|
||||
// 处理完毕(无 processing 项)自动断开 SSE + 停止兜底轮询;组件卸载时也会清理。
|
||||
let sse: EventSource | null = null
|
||||
let fallbackTimer: number | null = null
|
||||
let activeKbId: number | null = null
|
||||
|
||||
const hasProcessing = computed(() =>
|
||||
store.rawMaterials.some(r => r.processingStatus === 'processing')
|
||||
)
|
||||
|
||||
function applyProgressEvent(payload: any) {
|
||||
if (!payload || payload.rawId == null) return
|
||||
const raw = store.rawMaterials.find(r => r.id === payload.rawId)
|
||||
if (!raw) return
|
||||
if (typeof payload.done === 'number') raw.progressDone = payload.done
|
||||
if (typeof payload.total === 'number') raw.progressTotal = payload.total
|
||||
}
|
||||
|
||||
function openSse(kbId: number) {
|
||||
closeSse()
|
||||
activeKbId = kbId
|
||||
// Vite 代理 /api → :18088;EventSource 走相对路径即可
|
||||
const es = new EventSource(`/api/v1/wiki/knowledge-bases/${kbId}/progress`)
|
||||
sse = es
|
||||
|
||||
es.addEventListener('raw.started', (ev: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data)
|
||||
const raw = store.rawMaterials.find(r => r.id === data.rawId)
|
||||
if (raw) {
|
||||
raw.processingStatus = 'processing'
|
||||
raw.progressDone = 0
|
||||
raw.progressTotal = 0
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
es.addEventListener('route.done', (ev: MessageEvent) => {
|
||||
try { applyProgressEvent(JSON.parse(ev.data)) } catch { /* ignore */ }
|
||||
})
|
||||
es.addEventListener('chunk.done', (ev: MessageEvent) => {
|
||||
try { applyProgressEvent(JSON.parse(ev.data)) } catch { /* ignore */ }
|
||||
})
|
||||
es.addEventListener('raw.completed', (ev: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data)
|
||||
const raw = store.rawMaterials.find(r => r.id === data.rawId)
|
||||
if (raw) {
|
||||
raw.processingStatus = data.status === 'partial' ? 'partial' : 'completed'
|
||||
if (typeof data.totalPages === 'number') {
|
||||
raw.progressDone = data.totalPages
|
||||
raw.progressTotal = data.totalPages
|
||||
}
|
||||
}
|
||||
// 完成事件后,再做一次轻量 list 拉取确保其他字段(pageCount 等)同步
|
||||
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
es.addEventListener('raw.failed', (ev: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data)
|
||||
const raw = store.rawMaterials.find(r => r.id === data.rawId)
|
||||
if (raw) raw.processingStatus = 'failed'
|
||||
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
es.onerror = () => {
|
||||
// 浏览器 EventSource 会自动重连;这里仅 log
|
||||
// console.debug('Wiki SSE error/reconnect', kbId)
|
||||
}
|
||||
}
|
||||
|
||||
function closeSse() {
|
||||
if (sse) {
|
||||
sse.close()
|
||||
sse = null
|
||||
}
|
||||
activeKbId = null
|
||||
}
|
||||
|
||||
watch(
|
||||
hasProcessing,
|
||||
(active) => {
|
||||
if (active && pollTimer == null) {
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
|
||||
}, 3000)
|
||||
} else if (!active && pollTimer != null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
() => [hasProcessing.value, store.currentKB?.id] as const,
|
||||
([active, kbId]) => {
|
||||
if (active && kbId != null) {
|
||||
// SSE 主通道
|
||||
if (activeKbId !== kbId) openSse(kbId)
|
||||
// 60s 兜底拉取
|
||||
if (fallbackTimer == null) {
|
||||
fallbackTimer = window.setInterval(() => {
|
||||
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
|
||||
}, 60000)
|
||||
}
|
||||
} else {
|
||||
closeSse()
|
||||
if (fallbackTimer != null) {
|
||||
clearInterval(fallbackTimer)
|
||||
fallbackTimer = null
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
closeSse()
|
||||
if (fallbackTimer != null) {
|
||||
clearInterval(fallbackTimer)
|
||||
fallbackTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user