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:
matevip 2026-04-15 10:34:11 +08:00
parent 2a2f862257
commit ef8120413c
38 changed files with 1685 additions and 102 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -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 cachingspring-ai 1.1.4 一等支持
// 通过 cacheOptions 配置 system / tools / conversation history 自动打 cache_control
// 多轮对话场景可节省 5075% 输入 token 成本
builder.cacheOptions(anthropicCacheOptionsFactory.build());
return builder.internalToolExecutionEnabled(false).build();
}

View File

@ -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 {

View File

@ -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);
}
/** 是否有不可忽略的错误(无内容 + 有错误) */

View File

@ -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)

View 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;
}
}

View 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();
}
}

View 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); }
}

View 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
}

View 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();
}
}

View 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;
};
}
}

View 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; }
}
}

View 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);
}

View 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
}

View 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>对其它 providerOpenAI 兼容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;
}
}
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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 headerM2 阶段保留断点信息
* {@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);
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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;
}
// ==================== 内部方法 ====================

View File

@ -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) {

View File

@ -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.mdinitWorkspace 内部已写入无需再做一次冗余 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;
}

View File

@ -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;

View File

@ -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) {

View File

@ -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);
}

View File

@ -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 #3partial 状态走续传路径 保留已生成的 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-uppage 级失败原本被计入 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 1partial 不写 lastProcessedHashpartial 的语义就是"还有失败、需要再跑"
// 写了会导致下次用户点"重新处理" 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 #3phase 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 M3route 完成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 早于 INSERTclaim in-memoryINSERT 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) {
// 兜底 2select-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 #3Wiki 调用自带重试层{@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);
}
/**

View File

@ -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

View File

@ -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 M3Wiki 处理进度事件总线
* <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()));
}
}

View File

@ -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 # 目录扫描最大文件数

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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`:一段话简短摘要(一两句话即可,让"单页生成助手"知道这一页要写什么)

View 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);
}
}

View 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

View File

@ -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 :18088EventSource
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
}
})