chore: sync multiple commits from private dev

Covers 15 upstream commits (private mirror → public):

Multi-provider failover (RFC-009):
- PR-0: extract ChatModelBuilder strategy seam
- PR-1a: AvailableProviderPool data structure
- PR-1b: startup provider liveness probe + 4 protocol strategies
- PR-1c: wire AvailableProviderPool into runtime chat-model selection
- PR-1d: provider pool REST endpoint + UI badges
- PR-1e: manual reprobe trigger + auto-reprobe on provider config change
- PR-3: per-agent provider preferences (agents can override the
  org-wide fallback chain)

Wiki subsystem (RFC-029~033):
- Relation model, resilient background jobs, light-weight processing
  path, retrieval enhancement, frontend redesign (single landing commit)
- Follow-up fixes: null guards + stats query + i18n polish, move
  WikiProcessingJobMapper to repository/ for @MapperScan, align
  implementation with RFC-029~031 spec
- Copy pass: replace "富化 / enrich" wording with clearer "链接 / link"
- Style: switch enrich/repair buttons to @element-plus/icons-vue
This commit is contained in:
matevip 2026-04-19 18:37:44 +08:00
parent 3b11a3def6
commit 3d213eb281
101 changed files with 5452 additions and 375 deletions

View File

@ -132,6 +132,7 @@ public class AgentGraphBuilder {
private final vip.mate.i18n.I18nService i18nService;
private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker;
private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory;
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
/**
* 根据 AgentEntity 构建完整的 Agent 实例
@ -189,12 +190,12 @@ public class AgentGraphBuilder {
BaseAgent agent;
boolean toolCallingEnabled;
if ("plan_execute".equals(entity.getAgentType())) {
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter);
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId());
toolCallingEnabled = true;
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else {
agent = buildReActAgent(toolSet, runtimeModel, maxIter);
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId());
// StateGraph 路径下工具调用由 ActionNode 控制始终启用
toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
@ -236,34 +237,51 @@ public class AgentGraphBuilder {
// ==================== Agent 构建方法 ====================
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
return buildReActAgent(toolSet, runtimeModel, maxIter, null);
}
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager);
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, null);
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig);
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null);
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
@ -355,16 +373,23 @@ public class AgentGraphBuilder {
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig);
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null);
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
ActionNode actionNode = new ActionNode(executor, streamTracker);
@ -512,7 +537,7 @@ public class AgentGraphBuilder {
}
/**
* build the full multi-provider failover chain for a primary
* RFC-009: build the full multi-provider failover chain for a primary
* model. Providers are read from {@code mate_model_provider} ordered by
* {@code fallback_priority ASC} (positive values only), each resolved to
* its default {@link ModelConfigEntity} and turned into a {@link ChatModel}
@ -526,13 +551,27 @@ public class AgentGraphBuilder {
* <p>The primary model is excluded from the chain when its provider +
* model name matches a chain entry. Previously only reference equality
* was checked, which meant a DashScope-primary deployment ended up with
* {@code null} fallback the case this design targets.</p>
* {@code null} fallback exactly the case RFC-009 targets.</p>
*
* @param primaryModelConfig the {@code ModelConfigEntity} used to build
* the primary model; used to identity-filter the chain
* @return ordered, possibly-empty list of fallback {@link ChatModel}s
*/
List<vip.mate.llm.failover.FallbackEntry> buildFallbackChain(ModelConfigEntity primaryModelConfig) {
return buildFallbackChain(primaryModelConfig, null);
}
/**
* RFC-009 PR-3 overload: when {@code agentId} is non-null, the agent's
* {@code mate_agent_provider_preference} rows bias the chain order listed
* providers come first in their declared {@code sort_order}, then the
* remaining providers fall in by global {@code fallback_priority} ascending,
* tie-broken by provider id alphabetically. {@code null} agentId keeps the
* pre-PR-3 ordering (pure global priority) that's the path for legacy
* callers and tests.
*/
List<vip.mate.llm.failover.FallbackEntry> buildFallbackChain(ModelConfigEntity primaryModelConfig,
Long agentId) {
List<ModelProviderEntity> providers;
try {
providers = modelProviderService.listFallbackChain();
@ -547,8 +586,29 @@ public class AgentGraphBuilder {
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
// RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
// their declared order; everything else keeps the global priority order.
List<String> preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderIds(agentId);
if (!preferred.isEmpty()) {
providers = reorderByPreferences(providers, preferred);
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
}
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
// RFC-009 Phase 4: skip providers known-bad at build time. This is
// a perf optimization (one fewer ChatModel to construct + one
// fewer round-trip on the chain walk); the runtime walker in
// NodeStreamingChatHelper re-checks pool membership per request,
// so a provider that re-enters the pool later still gets used
// (the graph is rebuilt on ModelConfigChangedEvent).
if (providerPool != null && !providerPool.contains(p.getProviderId())) {
log.debug("[LlmFailover] skipping provider {} — not in available pool",
p.getProviderId());
continue;
}
ModelConfigEntity fallbackConfig;
try {
fallbackConfig = modelConfigService.getDefaultModelByProvider(p.getProviderId());
@ -584,6 +644,36 @@ public class AgentGraphBuilder {
return chain;
}
/**
* Reorder a provider list by an agent's preference list. Listed provider
* ids come first in their preference order; any provider not in the
* preference list keeps its original position relative to other unlisted
* providers (stable partition). Preference entries that don't match any
* actual provider are silently dropped.
*/
/** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
static List<ModelProviderEntity> reorderByPreferences(List<ModelProviderEntity> providers,
List<String> preferredOrder) {
Map<String, ModelProviderEntity> byId = new java.util.LinkedHashMap<>();
for (ModelProviderEntity p : providers) {
byId.put(p.getProviderId(), p);
}
List<ModelProviderEntity> reordered = new ArrayList<>(providers.size());
Set<String> placed = new java.util.HashSet<>();
for (String prefId : preferredOrder) {
ModelProviderEntity p = byId.get(prefId);
if (p != null && placed.add(prefId)) {
reordered.add(p);
}
}
for (ModelProviderEntity p : providers) {
if (placed.add(p.getProviderId())) {
reordered.add(p);
}
}
return reordered;
}
/**
* @deprecated use {@link #buildFallbackChain(ModelConfigEntity)} the
* single-fallback variant cannot represent an ordered chain and only

View File

@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.service.AgentBindingService;
@ -102,6 +103,33 @@ public class AgentBindingController {
return R.ok();
}
// ==================== Provider Preferences (RFC-009 PR-3) ====================
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
@GetMapping("/provider-preferences")
@RequireWorkspaceRole("viewer")
public R<List<AgentProviderPreference>> listProviderPreferences(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
return R.ok(bindingService.listProviderPreferences(agentId));
}
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R<Void> setProviderPreferences(
@PathVariable Long agentId,
@RequestBody List<String> providerIds,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setProviderPreferences(agentId, providerIds);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
"providers=" + providerIds.size(), null);
return R.ok();
}
// ==================== Workspace Verification ====================
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {

View File

@ -0,0 +1,44 @@
package vip.mate.agent.binding.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* RFC-009 Phase 4 PR-3 agent preferred provider routing hint.
*
* <p>An agent with zero rows here uses the global fallback chain order
* (no behavior change from pre-PR-3 deployments). When rows exist,
* {@code AgentGraphBuilder.buildFallbackChain} sorts those provider ids
* to the front by ascending {@code sortOrder}; non-listed providers
* follow in their global priority order.</p>
*
* <p>Pool/cooldown gating still applies: a preferred provider that is
* HARD-removed or cooling down is still skipped by the runtime walker.</p>
*/
@Data
@TableName("mate_agent_provider_preference")
public class AgentProviderPreference {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long agentId;
/** Provider id (matches {@code mate_model_provider.provider_id}). */
private String providerId;
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
private Integer sortOrder;
private Boolean enabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

View File

@ -0,0 +1,9 @@
package vip.mate.agent.binding.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.agent.binding.model.AgentProviderPreference;
@Mapper
public interface AgentProviderPreferenceMapper extends BaseMapper<AgentProviderPreference> {
}

View File

@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
@ -30,6 +32,7 @@ public class AgentBindingService {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
private final AgentProviderPreferenceMapper providerPreferenceMapper;
// ==================== Skill Bindings ====================
@ -167,4 +170,52 @@ public class AgentBindingService {
}
}
}
// ==================== Provider Preferences (RFC-009 PR-3) ====================
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */
public List<AgentProviderPreference> listProviderPreferences(Long agentId) {
return providerPreferenceMapper.selectList(
new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId)
.orderByAsc(AgentProviderPreference::getSortOrder));
}
/**
* Ordered list of provider ids the agent prefers, lowest sort_order
* first. Disabled rows are filtered out. Empty list means "no
* preference fall back to the global chain order".
*
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
* fallback chain order per agent.</p>
*/
public List<String> getPreferredProviderIds(Long agentId) {
if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream()
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
.map(AgentProviderPreference::getProviderId)
.collect(Collectors.toList());
}
/**
* Replace the full preference list for an agent. {@code providerIds}
* is the new ordered preference (index 0 = highest preference).
* Empty / null list clears all preferences for the agent.
*/
public void setProviderPreferences(Long agentId, List<String> providerIds) {
providerPreferenceMapper.delete(
new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId));
if (providerIds == null) return;
int order = 0;
for (String providerId : providerIds) {
if (providerId == null || providerId.isBlank()) continue;
AgentProviderPreference row = new AgentProviderPreference();
row.setAgentId(agentId);
row.setProviderId(providerId.trim());
row.setSortOrder(order++);
row.setEnabled(true);
providerPreferenceMapper.insert(row);
}
}
}

View File

@ -47,7 +47,7 @@ public class NodeStreamingChatHelper {
/**
* Ordered fallback chain tried after the primary model exhausts retries.
* Each entry is attempted once (no retry); the first successful response
* wins. Empty list disables fallover entirely.
* wins. Empty list disables fallover entirely. See RFC-009.
*
* <p>Stored as {@link vip.mate.llm.failover.FallbackEntry} (providerId +
* ChatModel) so the chain walker can consult {@link vip.mate.llm.failover.ProviderHealthTracker}
@ -71,17 +71,26 @@ public class NodeStreamingChatHelper {
*/
private final String primaryProviderId;
/**
* RFC-009 Phase 4: membership gate for usable providers. A provider is
* removed from the pool on HARD errors (AUTH_ERROR / BILLING /
* MODEL_NOT_FOUND) so subsequent requests skip it entirely without
* burning a round-trip. {@code null} disables the gate (legacy callers,
* tests) every provider then counts as in-pool (fail-open).
*/
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
this(streamTracker, List.of(), null, null, null);
this(streamTracker, List.of(), null, null, null, null);
}
/**
* @deprecated use the full constructor a single fallback cannot
* express the ordered multi-provider chain
* express the ordered multi-provider chain from RFC-009.
*/
@Deprecated
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
this(streamTracker, wrap(fallbackModel), null, null, null);
this(streamTracker, wrap(fallbackModel), null, null, null, null);
}
/**
@ -90,7 +99,7 @@ public class NodeStreamingChatHelper {
@Deprecated
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
this(streamTracker, wrap(fallbackModel), cacheMetrics, null, null);
this(streamTracker, wrap(fallbackModel), cacheMetrics, null, null, null);
}
/**
@ -100,7 +109,7 @@ public class NodeStreamingChatHelper {
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
this(streamTracker, fallbackChain, cacheMetrics, null, null);
this(streamTracker, fallbackChain, cacheMetrics, null, null, null);
}
/**
@ -112,19 +121,41 @@ public class NodeStreamingChatHelper {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker) {
this(streamTracker, fallbackChain, cacheMetrics, healthTracker, null);
this(streamTracker, fallbackChain, cacheMetrics, healthTracker, null, null);
}
/**
* Constructor that wires health tracker + primary provider id but leaves
* the {@link vip.mate.llm.failover.AvailableProviderPool} disabled. Kept
* so existing tests (e.g. {@code NodeStreamingChatHelperFailoverTest})
* compile unchanged they don't exercise the pool gate.
*/
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker,
String primaryProviderId) {
this(streamTracker, fallbackChain, cacheMetrics, healthTracker, primaryProviderId, null);
}
/**
* Full constructor preferred for production wiring. The
* {@link vip.mate.llm.failover.AvailableProviderPool} hookup gates both
* the primary short-circuit and the fallback walker; passing {@code null}
* runs in fail-open mode (every provider counted as in-pool).
*/
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker,
String primaryProviderId,
vip.mate.llm.failover.AvailableProviderPool providerPool) {
this.streamTracker = streamTracker;
this.fallbackChain = fallbackChain == null ? List.of() : List.copyOf(fallbackChain);
this.cacheMetrics = cacheMetrics;
this.healthTracker = healthTracker;
this.primaryProviderId = primaryProviderId;
this.providerPool = providerPool;
}
private static List<vip.mate.llm.failover.FallbackEntry> wrap(ChatModel m) {
@ -144,6 +175,47 @@ public class NodeStreamingChatHelper {
else healthTracker.recordFailure(primaryProviderId);
}
/**
* RFC-009 Phase 4 map an {@link ErrorType} to the matching pool
* {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for
* HARD failures (AUTH / BILLING / MODEL_NOT_FOUND). Returns {@code null}
* for SOFT errors and benign types those keep the provider in-pool and
* are handled by {@link vip.mate.llm.failover.ProviderHealthTracker}'s
* cooldown instead.
*/
private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) {
if (type == null) return null;
return switch (type) {
case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR;
case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING;
case MODEL_NOT_FOUND -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.MODEL_NOT_FOUND;
default -> null;
};
}
/** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */
private boolean inPool(String providerId) {
return providerPool == null || providerId == null || providerPool.contains(providerId);
}
/**
* Remove the given provider from the pool if {@code errorType} is HARD
* (AUTH_ERROR / BILLING / MODEL_NOT_FOUND). No-op when the pool is
* disabled, the provider id is unknown, or the error is SOFT.
*/
private void removeFromPool(String providerId, ErrorType errorType, String message) {
if (providerPool == null || providerId == null) return;
var source = hardRemovalSource(errorType);
if (source == null) return;
providerPool.remove(providerId, source, message != null ? message : errorType.name());
}
/** Defensively re-affirm pool membership after a successful call. Idempotent + cheap. */
private void addToPool(String providerId) {
if (providerPool == null || providerId == null) return;
providerPool.add(providerId);
}
/**
* 流式调用 LLM 并实时广播增量内容
*
@ -235,7 +307,7 @@ public class NodeStreamingChatHelper {
|| msg.contains("thinking block")) {
return ErrorType.THINKING_BLOCK_ERROR;
}
// BILLING payment / quota exhausted. Distinct from AUTH because
// RFC-009 P3.2: BILLING payment / quota exhausted. Distinct from AUTH because
// a different provider may have credits, so we should fall back instead of
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
// ("credit balance is too low") use these phrases in 402-class responses.
@ -246,7 +318,7 @@ public class NodeStreamingChatHelper {
|| msg.contains("quota exceeded") || msg.contains("Quota exceeded")) {
return ErrorType.BILLING;
}
// MODEL_NOT_FOUND provider rejects the requested model id.
// RFC-009 P3.2: MODEL_NOT_FOUND provider rejects the requested model id.
// Includes DashScope's "[InvalidParameter] url error, please check url"
// (https://help.aliyun.com/zh/model-studio/error-code#error-url) which despite
// the wording is the provider rejecting an unknown/unsupported model id on
@ -300,20 +372,23 @@ public class NodeStreamingChatHelper {
throw new CancellationException("Stream stopped by user");
}
// if the primary's provider is in cooldown (3+ recent
// consecutive failures within the cooldown window), skip the 5-retry
// primary loop entirely and head straight to the fallback chain.
// Without this short-circuit a degraded primary forces every LLM
// call in the conversation to wait through the full backoff.
boolean primarySkipped = primaryProviderId != null
// RFC-009 P3.1 + Phase 4: short-circuit the primary retry loop in two cases.
// (a) primary is in cooldown (P3.3) soft, transient
// (b) primary was HARD-removed from the pool (Phase 4) auth/billing/missing model
// Either way, retrying the same model wastes seconds; head straight to fallback.
boolean primaryInCooldown = primaryProviderId != null
&& healthTracker != null
&& healthTracker.isInCooldown(primaryProviderId);
boolean primaryOutOfPool = primaryProviderId != null && !inPool(primaryProviderId);
boolean primarySkipped = primaryInCooldown || primaryOutOfPool;
if (primarySkipped) {
log.warn("[{}] Primary provider={} is in cooldown — skipping straight to fallback chain",
phase, primaryProviderId);
String reason = primaryOutOfPool ? "removed from pool" : "in cooldown";
log.warn("[{}] Primary provider={} {} — skipping straight to fallback chain",
phase, primaryProviderId, reason);
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("主模型暂时不可用(冷却中),直接尝试备选模型..."));
buildDeltaJson("主模型暂时不可用(" + (primaryOutOfPool ? "已下线" : "冷却中")
+ "),直接尝试备选模型..."));
}
}
@ -334,9 +409,10 @@ public class NodeStreamingChatHelper {
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
log.warn("[{}] Primary auth failed — skipping same-model retries, handing off to fallback chain", phase);
recordPrimary(false);
removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage());
break;
}
// BILLING / MODEL_NOT_FOUND provider-side hard failures
// RFC-009 P3.2: BILLING / MODEL_NOT_FOUND provider-side hard failures
// that won't change on retry. Skip to fallback chain (a different
// provider may have credits, or the model name may be valid there).
if (lastResult.errorType() == ErrorType.BILLING
@ -344,6 +420,7 @@ public class NodeStreamingChatHelper {
log.warn("[{}] Primary error={} — skipping same-model retries, handing off to fallback chain",
phase, lastResult.errorType());
recordPrimary(false);
removeFromPool(primaryProviderId, lastResult.errorType(), lastResult.errorMessage());
break;
}
// CLIENT_ERROR (400 Bad Request): 不重试参数/格式错误重试也不会变
@ -359,7 +436,7 @@ public class NodeStreamingChatHelper {
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
return lastResult; // 已经重试过了
}
// EMPTY_RESPONSE break the primary-retry loop and fall through to
// RFC-009: EMPTY_RESPONSE break the primary-retry loop and fall through to
// the fallback chain. Retrying the same model that returned nothing is rarely
// productive; a different provider has a better chance of succeeding.
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
@ -370,6 +447,7 @@ public class NodeStreamingChatHelper {
// 成功
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
recordPrimary(true);
addToPool(primaryProviderId);
return lastResult;
}
// Any other non-null errored result with a classified type that doStreamCall
@ -390,12 +468,21 @@ public class NodeStreamingChatHelper {
// Each fallback gets a single shot (no retry); first successful result wins.
// Same-instance entries (e.g., primary accidentally included in the chain)
// are skipped so we don't re-try the exact model that just failed.
// Providers in cooldown are also skipped so a known-bad
// Providers in cooldown (RFC-009 P3.3) are also skipped so a known-bad
// provider doesn't add latency to every conversation turn.
for (int i = 0; i < fallbackChain.size(); i++) {
vip.mate.llm.failover.FallbackEntry entry = fallbackChain.get(i);
ChatModel fallback = entry.chatModel();
if (fallback == chatModel) continue;
// RFC-009 Phase 4 pool gate (the real runtime fence). A provider
// HARD-removed earlier (or by another conversation) must not even
// be attempted here. Build-time filtering is best-effort; this is
// the one that matters when pool state changes mid-conversation.
if (!inPool(entry.providerId())) {
log.info("[{}] Skipping fallback {}/{} provider={} — not in pool",
phase, i + 1, fallbackChain.size(), entry.providerId());
continue;
}
if (healthTracker != null && healthTracker.isInCooldown(entry.providerId())) {
log.info("[{}] Skipping fallback {}/{} provider={} — in cooldown",
phase, i + 1, fallbackChain.size(), entry.providerId());
@ -417,10 +504,15 @@ public class NodeStreamingChatHelper {
&& fallbackResult.errorType() == ErrorType.NONE
&& fallbackResult.errorMessage() == null) {
if (healthTracker != null) healthTracker.recordSuccess(entry.providerId());
addToPool(entry.providerId());
return fallbackResult;
}
if (healthTracker != null) healthTracker.recordFailure(entry.providerId());
if (fallbackResult != null) {
// RFC-009 Phase 4: HARD errors evict from the pool so later
// walks skip this provider outright. SOFT errors keep it
// in-pool and let the tracker's cooldown absorb the blip.
removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage());
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
}
}
@ -438,7 +530,7 @@ public class NodeStreamingChatHelper {
boolean broadcast, int attempt) {
if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
// 加入 jitter 防止雷群效应a comparable reference runtime 风格
// 加入 jitter 防止雷群效应Hermes 风格
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, BACKOFF_CAP_MS);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
@ -661,7 +753,7 @@ public class NodeStreamingChatHelper {
// warning 已在 dispose 时广播无需重复
}
// guard against silent empty responses. Some providers return
// RFC-009: guard against silent empty responses. Some providers return
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
// capacity, context filter, upstream overload). Treat this as a failure
// signal so streamCallInternal can hand off to the fallback chain.
@ -931,14 +1023,14 @@ public class NodeStreamingChatHelper {
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
THINKING_BLOCK_ERROR,
/**
* LLM returned no content, no thinking, and no tool calls.
* RFC-009: LLM returned no content, no thinking, and no tool calls.
* Treated as a soft failure skip same-model retries and hand off to
* the fallback chain directly. Typical cause: upstream rate-limit
* rejection that comes back as HTTP 200 with empty body.
*/
EMPTY_RESPONSE,
/**
* payment / billing failure (HTTP 402, "insufficient_quota",
* RFC-009 P3.2: payment / billing failure (HTTP 402, "insufficient_quota",
* "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR}
* because the right response is to <i>switch provider</i> (a different
* provider may have credits) rather than just terminate. Skips same-model
@ -946,7 +1038,7 @@ public class NodeStreamingChatHelper {
*/
BILLING,
/**
* requested model id not recognized by the provider
* RFC-009 P3.2: requested model id not recognized by the provider
* (HTTP 404, "Model not exist", "model_not_found", DashScope's
* "url error"). Same handling as {@link #BILLING} heads straight
* to the fallback chain instead of looping retries against a model

View File

@ -0,0 +1,120 @@
package vip.mate.llm.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.failover.AvailableProviderPool.RemovalReason;
import vip.mate.llm.failover.ProbeResult;
import vip.mate.llm.failover.ProviderHealthTracker;
import vip.mate.llm.failover.ProviderHealthTracker.ProviderHealthSnapshot;
import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.ProviderInfoDTO;
import vip.mate.llm.service.ModelProviderService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* RFC-009 Phase 4 read-only diagnostic endpoint for the provider pool.
*
* <p>Surfaces the union of three pieces of state:</p>
* <ul>
* <li>{@link AvailableProviderPool} membership + last removal reason
* (HARD failures, init probe verdicts, manual eviction).</li>
* <li>{@link ProviderHealthTracker} cooldown status (SOFT failures).</li>
* <li>{@link ModelProviderService} configuration metadata so the UI can
* skip rendering rows for providers the user has never set up.</li>
* </ul>
*
* <p>Manual reprobe + auto-reprobe-on-config-change land in PR-1e.</p>
*/
@Tag(name = "Provider 可用池")
@RestController
@RequestMapping("/api/v1/llm/provider-pool")
@RequiredArgsConstructor
public class ProviderPoolController {
private final AvailableProviderPool providerPool;
private final ProviderHealthTracker healthTracker;
private final ModelProviderService providerService;
private final ProviderInitProbe initProbe;
@Operation(summary = "查询所有 provider 的池状态 + 冷却信息")
@GetMapping
public R<List<ProviderPoolEntryDTO>> snapshot() {
Map<String, RemovalReason> poolView = providerPool.snapshot();
Map<String, ProviderHealthSnapshot> healthView = healthTracker.snapshot();
List<ProviderInfoDTO> providers = providerService.listProviders();
List<ProviderPoolEntryDTO> rows = new ArrayList<>(providers.size());
for (ProviderInfoDTO p : providers) {
String id = p.getId();
// Pool membership: snapshot() returns id->null for in-pool, id->reason for removed.
// An id missing from the snapshot has never been touched (treat as not-in-pool until first probe).
boolean tracked = poolView.containsKey(id);
RemovalReason reason = poolView.get(id);
boolean inPool = tracked && reason == null;
ProviderHealthSnapshot health = healthView.get(id);
rows.add(new ProviderPoolEntryDTO(
id,
p.getName(),
inPool,
reason == null ? null : reason.source().name(),
reason == null ? null : reason.message(),
reason == null ? null : reason.removedAtMs(),
health != null && health.cooldownRemainingMs() > 0,
health == null ? 0L : health.cooldownRemainingMs(),
health == null ? 0L : health.consecutiveFailures()
));
}
return R.ok(rows);
}
@Operation(summary = "手动重新探测某个 provider立即更新池状态")
@PostMapping("/{providerId}/reprobe")
public R<ReprobeResultDTO> reprobe(@PathVariable String providerId) {
ProbeResult result = initProbe.probeOne(providerId);
return R.ok(new ReprobeResultDTO(
providerId,
result.success(),
result.latencyMs(),
result.errorMessage(),
providerPool.contains(providerId)
));
}
/** Result of a manual reprobe. {@code inPool} reflects pool state after the probe ran. */
public record ReprobeResultDTO(
String providerId,
boolean success,
long latencyMs,
String errorMessage,
boolean inPool
) {}
/**
* Per-row payload for the {@code /provider-pool} response. Flat record
* because the UI renders each provider as one card and we want a stable
* shape that's trivial to bind in TypeScript.
*/
public record ProviderPoolEntryDTO(
String providerId,
String providerName,
boolean inPool,
String removalSource,
String removalMessage,
Long removedAtMs,
boolean inCooldown,
long cooldownRemainingMs,
long consecutiveFailures
) {}
}

View File

@ -3,7 +3,7 @@ package vip.mate.llm.failover;
import org.springframework.ai.chat.model.ChatModel;
/**
* a single rung of the multi-model failover chain. Pairs a
* RFC-009 P3.3: a single rung of the multi-model failover chain. Pairs a
* {@link ChatModel} with the {@code providerId} that built it so the
* chain walker can consult {@link ProviderHealthTracker} (which keys cooldown
* state by provider id, not by ChatModel instance).

View File

@ -3,7 +3,7 @@ package vip.mate.llm.failover;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* thresholds for the per-provider health tracker.
* RFC-009 P3.3: thresholds for the per-provider health tracker.
*
* <pre>
* mateclaw:

View File

@ -11,7 +11,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* per-provider health tracker for the multi-model failover chain.
* RFC-009 P3.3: per-provider health tracker for the multi-model failover chain.
*
* <p>Tracks consecutive failure counts per provider id. When a provider hits
* {@link ProviderHealthProperties#getFailureThreshold} consecutive failures,

View File

@ -3,8 +3,10 @@ package vip.mate.llm.failover;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.repository.ModelProviderMapper;
@ -73,6 +75,24 @@ public class ProviderInitProbe {
probeAllConfigured();
}
/**
* RFC-009 Phase 4: when the user edits a provider's API key / Base URL
* (or any other field that {@link ModelProviderService} considers
* material), re-probe the whole configured set. The event has no
* providerId payload, so we re-probe everything cheap because most
* probes use free {@code /v1/models} endpoints.
*
* <p>Async so the user's "save provider config" call returns
* immediately; the probe runs in the background and badges update on
* the next poll.</p>
*/
@Async
@EventListener(ModelConfigChangedEvent.class)
public void onModelConfigChanged(ModelConfigChangedEvent event) {
log.info("[ProviderInitProbe] re-probing after ModelConfigChangedEvent (reason={})", event.reason());
probeAllConfigured();
}
/**
* Probe every configured provider in parallel. Public so PR-1e's manual
* reprobe / config-changed listener can trigger a full refresh, not just

View File

@ -50,7 +50,7 @@ public class ModelProviderEntity {
private String oauthAccountId;
/**
* position of this provider in the fallback chain.
* RFC-009: position of this provider in the fallback chain.
* {@code 0} (default) means "not in the chain"; positive values are tried
* in ascending order after the primary model exhausts its retries.
*/

View File

@ -12,7 +12,7 @@ public class ProviderConfigRequest {
private String chatModel;
private Map<String, Object> generateKwargs;
/**
* provider's position in the multi-model failover chain.
* RFC-009 P3.5: provider's position in the multi-model failover chain.
* {@code 0} = excluded; positive ints define ascending try-order. When
* {@code null} the field is left untouched on update.
*/

View File

@ -29,6 +29,6 @@ public class ProviderInfoDTO {
private String authType;
private Boolean oauthConnected;
private Long oauthExpiresAt;
/** position in the failover chain (0 = excluded, 1..N = priority). */
/** RFC-009 P3.5: position in the failover chain (0 = excluded, 1..N = priority). */
private Integer fallbackPriority;
}

View File

@ -72,7 +72,7 @@ public class ModelProviderService {
provider.setBaseUrl(request.getBaseUrl());
provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel()));
provider.setGenerateKwargs(writeJson(request.getGenerateKwargs()));
// only update fallback priority when the caller explicitly
// RFC-009 P3.5: only update fallback priority when the caller explicitly
// sends a value. null leaves it untouched (existing chain unchanged).
if (request.getFallbackPriority() != null) {
int p = Math.max(0, request.getFallbackPriority());
@ -150,7 +150,7 @@ public class ModelProviderService {
}
/**
* ordered list of providers that participate in the multi-model
* RFC-009: ordered list of providers that participate in the multi-model
* failover chain. Filters by {@code fallback_priority > 0} and sorts
* ascending, so priority 1 is tried first after the primary model
* exhausts retries. An empty list disables fallover entirely.

View File

@ -1,14 +1,31 @@
package vip.mate.wiki;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import vip.mate.wiki.job.WikiProcessingJobService;
/**
* Wiki 知识库模块自动配置
* Wiki module auto-configuration
*
* @author MateClaw Team
*/
@Slf4j
@Configuration
@EnableConfigurationProperties(WikiProperties.class)
@RequiredArgsConstructor
public class WikiAutoConfiguration {
private final WikiProcessingJobService wikiProcessingJobService;
/**
* RFC-030: Recover stuck wiki processing jobs on startup.
*/
@EventListener(ApplicationReadyEvent.class)
public void recoverWikiJobs(ApplicationReadyEvent event) {
wikiProcessingJobService.recoverOnStartup();
}
}

View File

@ -110,4 +110,21 @@ public class WikiProperties {
/** 混合搜索默认模式keyword / semantic / hybrid */
private String searchDefaultMode = "hybrid";
// ==================== RFC-031: Light processing tiers ====================
/** Whether to auto-dispatch a LIGHT_ENRICH job after heavy ingest completes */
private boolean lightEnrichEnabled = true;
/** Delay before light enrichment starts (ms) */
private long lightEnrichDelayMs = 2000;
/**
* Minimum ratio of enriched content length to original content length.
* If the LLM returns text shorter than this ratio, the enrichment is rejected.
*/
private double wikilinkMinContentRatio = 0.5;
/** Maximum characters for local repair single-page regeneration */
private int localRepairMaxChars = 8000;
}

View File

@ -0,0 +1,164 @@
package vip.mate.wiki.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.web.bind.annotation.*;
import vip.mate.wiki.dto.*;
import vip.mate.wiki.job.WikiProcessingJobService;
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
import vip.mate.wiki.repository.WikiProcessingJobMapper;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.service.*;
import java.util.List;
import java.util.Map;
/**
* RFC-029/030/031/032/033: REST endpoints for wiki relations, jobs, enrichment, and search.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/wiki")
@RequiredArgsConstructor
public class WikiRelationController {
private final WikiRelationService relationService;
private final WikiProcessingJobService jobService;
private final WikiProcessingJobMapper jobMapper;
private final WikiPageService pageService;
private final WikiPageCitationMapper citationMapper;
private final HybridRetriever hybridRetriever;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
// ==================== RFC-029: Relations ====================
@GetMapping("/kb/{kbId}/pages/{slug}/related")
public List<RelatedPageResult> relatedPages(
@PathVariable Long kbId,
@PathVariable String slug,
@RequestParam(defaultValue = "5") int topK) {
return relationService.relatedPages(kbId, slug, Math.min(topK, 20));
}
@GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}")
public RelationExplanation explainRelation(
@PathVariable Long kbId,
@PathVariable String slugA,
@PathVariable String slugB) {
return relationService.explain(kbId, slugA, slugB);
}
@GetMapping("/raw/{rawId}/pages")
public List<WikiPageLite> pagesByRawId(@PathVariable Long rawId) {
return relationService.pagesByRawId(rawId);
}
@GetMapping("/chunks/{chunkId}/pages")
public List<WikiPageLite> pagesByChunkId(@PathVariable Long chunkId) {
return relationService.pagesByChunkId(chunkId);
}
// ==================== RFC-029: Citations ====================
@GetMapping("/kb/{kbId}/pages/{pageId}/citations")
public List<PageCitationWithRaw> pageCitations(
@PathVariable Long kbId,
@PathVariable Long pageId) {
return citationMapper.listWithRawByPageId(pageId);
}
// ==================== RFC-030: Jobs ====================
@GetMapping("/kb/{kbId}/jobs")
public List<WikiProcessingJobEntity> getJobs(
@PathVariable Long kbId,
@RequestParam(required = false) Long rawId) {
if (rawId != null) {
return jobMapper.findLatestByRawId(rawId)
.map(List::of).orElse(List.of());
}
return jobMapper.listQueued(kbId, 20);
}
// ==================== RFC-030/033: KB Stats ====================
@GetMapping("/kb/{kbId}/stats")
public Map<String, Object> kbStats(@PathVariable Long kbId) {
int pageCount = pageService.countByKbId(kbId);
// Count enriched pages (those containing [[wikilinks]])
long enrichedCount = pageService.listByKbIdWithContent(kbId).stream()
.filter(p -> p.getContent() != null && p.getContent().contains("[["))
.count();
// Use listByKbId (all statuses) instead of listQueued (queued-only)
var allJobs = jobMapper.listByKbId(kbId, 200);
int failedJobCount = (int) allJobs.stream()
.filter(j -> "failed".equals(j.getStatus()))
.count();
int runningJobCount = (int) allJobs.stream()
.filter(j -> "running".equals(j.getStatus()))
.count();
return Map.of(
"pageCount", pageCount,
"enrichedPageCount", enrichedCount,
"failedJobCount", failedJobCount,
"runningJobCount", runningJobCount
);
}
// ==================== RFC-031: Enrichment & Repair ====================
@PostMapping("/kb/{kbId}/pages/{slug}/enrich")
public Map<String, Object> enrichPage(@PathVariable Long kbId, @PathVariable String slug) {
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) return Map.of("error", "Page not found: " + slug);
Long rawId = 0L;
try {
List<Long> rawIds = objectMapper.readValue(
page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]",
new TypeReference<List<Long>>() {});
if (!rawIds.isEmpty()) rawId = rawIds.get(0);
} catch (Exception ignored) {}
WikiProcessingJobEntity job = jobService.createLightEnrich(kbId, rawId);
eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId()));
return Map.of("jobId", job.getId());
}
@PostMapping("/kb/{kbId}/pages/{slug}/repair")
public Map<String, Object> repairPage(@PathVariable Long kbId, @PathVariable String slug) {
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) return Map.of("error", "Page not found: " + slug);
Long rawId = 0L;
try {
List<Long> rawIds = objectMapper.readValue(
page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]",
new TypeReference<List<Long>>() {});
if (!rawIds.isEmpty()) rawId = rawIds.get(0);
} catch (Exception ignored) {}
WikiProcessingJobEntity job = jobService.createLocalRepair(kbId, rawId, page.getId());
eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId()));
return Map.of("jobId", job.getId());
}
// ==================== RFC-032: Search preview ====================
@PostMapping("/kb/{kbId}/search-preview")
public List<PageSearchResult> searchPreview(
@PathVariable Long kbId,
@RequestBody Map<String, Object> body) {
String query = (String) body.getOrDefault("query", "");
String mode = (String) body.getOrDefault("mode", "hybrid");
int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5;
return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20));
}
}

View File

@ -0,0 +1,6 @@
package vip.mate.wiki.dto;
/**
* RFC-029: Lightweight chunk-to-page reference for shared-chunk signal computation.
*/
public record ChunkPageRef(Long chunkId, Long pageId) {}

View File

@ -0,0 +1,9 @@
package vip.mate.wiki.dto;
import java.math.BigDecimal;
/**
* RFC-029: Citation record joined with the chunk's raw material ID.
*/
public record PageCitationWithRaw(Long id, Long pageId, Long chunkId, Long rawId,
Integer paragraphIdx, String anchorText, BigDecimal confidence) {}

View File

@ -0,0 +1,16 @@
package vip.mate.wiki.dto;
import java.util.List;
/**
* RFC-032: Enhanced search result with snippet, match metadata, and relevance reason.
*/
public record PageSearchResult(
String slug,
String title,
String summary,
String snippet,
List<String> matchedBy,
String reason,
double score
) {}

View File

@ -0,0 +1,6 @@
package vip.mate.wiki.dto;
/**
* RFC-032: Lightweight raw material ID-to-title projection for batch lookups.
*/
public record RawTitleRef(Long id, String title) {}

View File

@ -0,0 +1,9 @@
package vip.mate.wiki.dto;
import java.util.List;
/**
* RFC-029: A related page with aggregated relation score and contributing signals.
*/
public record RelatedPageResult(String slug, String title, String summary,
double score, List<String> signals) {}

View File

@ -0,0 +1,13 @@
package vip.mate.wiki.dto;
import java.util.List;
/**
* RFC-029: Detailed breakdown of the relation between two pages.
*/
public record RelationExplanation(String slugA, String slugB, double totalScore,
List<SignalScore> breakdown) {
public static RelationExplanation notFound() {
return new RelationExplanation(null, null, 0, List.of());
}
}

View File

@ -0,0 +1,6 @@
package vip.mate.wiki.dto;
/**
* RFC-029: Individual signal contribution to a relation score.
*/
public record SignalScore(String signal, double weight, double score) {}

View File

@ -0,0 +1,6 @@
package vip.mate.wiki.dto;
/**
* RFC-029: Lightweight page projection without content.
*/
public record WikiPageLite(Long id, String slug, String title, String summary) {}

View File

@ -0,0 +1,17 @@
package vip.mate.wiki.job;
/**
* RFC-031: Thrown when a wiki processing step encounters a hard/fatal
* model error that warrants fallback to a different model.
*/
public class WikiHardModelException extends RuntimeException {
private final String errorCode;
public WikiHardModelException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}

View File

@ -0,0 +1,72 @@
package vip.mate.wiki.job;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
import vip.mate.wiki.repository.WikiProcessingJobMapper;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.job.template.WikiProcessingTemplate;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* RFC-031: Listens for WikiJobCreatedEvent and dispatches jobs
* to the appropriate processing template.
*/
@Slf4j
@Component
public class WikiJobDispatcher {
private final Map<String, WikiProcessingTemplate> templateMap;
private final WikiProcessingJobMapper jobMapper;
private static final ExecutorService DISPATCH_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
public WikiJobDispatcher(List<WikiProcessingTemplate> templates,
WikiProcessingJobMapper jobMapper) {
this.jobMapper = jobMapper;
this.templateMap = templates.stream().collect(
Collectors.toMap(t -> t.getClass().getSimpleName(), t -> t));
log.info("[WikiDispatcher] Registered templates: {}", templateMap.keySet());
}
@EventListener(WikiJobCreatedEvent.class)
public void onJobCreated(WikiJobCreatedEvent event) {
DISPATCH_EXECUTOR.submit(() -> dispatch(event.jobId()));
}
public void dispatch(Long jobId) {
WikiProcessingJobEntity job = jobMapper.selectById(jobId);
if (job == null) return;
try {
WikiJobStage stage = WikiJobStage.valueOf(job.getStage().toUpperCase());
if (stage.isTerminal()) return;
} catch (IllegalArgumentException e) {
log.warn("[WikiDispatch] Unknown stage '{}' for job {}", job.getStage(), jobId);
return;
}
WikiProcessingTemplate template = resolveTemplate(job.getJobType());
if (template == null) {
log.error("[WikiDispatch] No template for job type: {}", job.getJobType());
return;
}
template.execute(job);
}
private WikiProcessingTemplate resolveTemplate(String jobType) {
return switch (jobType) {
case "heavy_ingest" -> templateMap.get("HeavyIngestTemplate");
case "light_enrich" -> templateMap.get("LightEnrichTemplate");
case "local_repair" -> templateMap.get("LocalRepairTemplate");
default -> null;
};
}
}

View File

@ -0,0 +1,16 @@
package vip.mate.wiki.job;
/**
* RFC-030: Stage state machine for wiki processing jobs.
*/
public enum WikiJobStage {
QUEUED, ROUTING,
PHASE_A_RUNNING, PHASE_A_DONE,
PHASE_B_RUNNING,
ENRICHING, EMBEDDING,
COMPLETED, FAILED, PARTIAL, CANCELLED;
public boolean isTerminal() {
return this == COMPLETED || this == FAILED || this == PARTIAL || this == CANCELLED;
}
}

View File

@ -0,0 +1,8 @@
package vip.mate.wiki.job;
/**
* RFC-030: Top-level status of a wiki processing job.
*/
public enum WikiJobStatus {
QUEUED, RUNNING, COMPLETED, FAILED, PARTIAL, CANCELLED
}

View File

@ -0,0 +1,9 @@
package vip.mate.wiki.job;
/**
* RFC-030: Logical steps within a wiki processing job,
* used for per-step model routing.
*/
public enum WikiJobStep {
ROUTE, CREATE_PAGE, MERGE_PAGE, ENRICH, SUMMARY
}

View File

@ -0,0 +1,10 @@
package vip.mate.wiki.job;
/**
* RFC-030: Types of wiki processing jobs.
*/
public enum WikiJobType {
HEAVY_INGEST,
LIGHT_ENRICH,
LOCAL_REPAIR
}

View File

@ -0,0 +1,20 @@
package vip.mate.wiki.job;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* RFC-030: Per-KB processing configuration POJO, deserialized from
* {@link vip.mate.wiki.model.WikiKnowledgeBaseEntity#getConfigContent()}.
*/
@Data
public class WikiKbConfig {
/** Per-step model overrides: "heavy_ingest.create_page" → modelId */
private Map<String, Long> stepModels;
/** Global fallback model chain for all steps in this KB */
private List<Long> fallbackModelIds;
}

View File

@ -0,0 +1,138 @@
package vip.mate.wiki.job;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import vip.mate.llm.chatmodel.ProviderChatModelFactory;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.job.fallback.ModelFallbackHandler;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.job.strategy.WikiStepModelStrategy;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import java.util.List;
import java.util.stream.Collectors;
/**
* RFC-030: Model routing service selects the right model for each
* wiki processing step, with fallback chain support.
*/
@Slf4j
@Service
public class WikiModelRoutingService {
private final List<WikiStepModelStrategy> strategies;
private final ModelFallbackHandler fallbackChainHead;
private final ProviderChatModelFactory chatModelFactory;
private final ModelConfigService modelConfigService;
private final WikiKnowledgeBaseService kbService;
private final ObjectMapper objectMapper;
@Autowired(required = false)
private AvailableProviderPool providerPool;
private static final RetryTemplate WIKI_NO_RETRY = RetryTemplate.builder()
.maxAttempts(1).build();
public WikiModelRoutingService(List<WikiStepModelStrategy> strategies,
List<ModelFallbackHandler> fallbackHandlers,
ProviderChatModelFactory chatModelFactory,
ModelConfigService modelConfigService,
WikiKnowledgeBaseService kbService,
ObjectMapper objectMapper) {
this.strategies = strategies;
this.chatModelFactory = chatModelFactory;
this.modelConfigService = modelConfigService;
this.kbService = kbService;
this.objectMapper = objectMapper;
this.fallbackChainHead = buildChain(fallbackHandlers);
}
/**
* Select the best model for a given job and step, respecting strategy
* priority and provider pool availability.
*/
public Long selectModelId(WikiProcessingJobEntity job, WikiJobStep step) {
WikiKnowledgeBaseEntity kb = (job != null) ? kbService.getById(job.getKbId()) : null;
for (WikiStepModelStrategy strategy : strategies) {
if (strategy.supports(step)) {
Long modelId = strategy.selectModelId(job, kb, step);
if (modelId != null && isAvailable(modelId)) return modelId;
}
}
throw new WikiModelUnavailableException("No available model for step: " + step);
}
/**
* Select a fallback model after a failure.
*/
public Long selectFallbackModel(WikiProcessingJobEntity job, WikiJobStep step, String errorCode) {
return fallbackChainHead.handle(job, step, errorCode)
.orElseThrow(() -> new WikiModelUnavailableException(
"Exhausted all fallback models for step: " + step));
}
/**
* Build a ChatModel instance for a given model ID.
*/
public ChatModel buildChatModel(Long modelId) {
ModelConfigEntity model = modelConfigService.getModel(modelId);
return chatModelFactory.buildFor(model, WIKI_NO_RETRY);
}
/**
* Build the fallback chain JSON for a job (called once during routing stage).
*/
public String buildFallbackChainJson(Long kbId) {
WikiKbConfig config = kbConfigOf(kbId);
List<Long> chain;
if (config != null && config.getFallbackModelIds() != null && !config.getFallbackModelIds().isEmpty()) {
chain = config.getFallbackModelIds();
} else {
chain = List.of(modelConfigService.getDefaultModel().getId());
}
if (providerPool != null) {
chain = chain.stream().filter(this::isAvailable).collect(Collectors.toList());
}
try {
return objectMapper.writeValueAsString(chain);
} catch (Exception e) {
return "[]";
}
}
private boolean isAvailable(Long modelId) {
if (providerPool == null) return true;
try {
ModelConfigEntity m = modelConfigService.getModel(modelId);
return providerPool.contains(m.getProvider());
} catch (Exception e) {
return false;
}
}
private WikiKbConfig kbConfigOf(Long kbId) {
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null || kb.getConfigContent() == null) return null;
try {
return objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
} catch (Exception e) {
return null;
}
}
private static ModelFallbackHandler buildChain(List<ModelFallbackHandler> handlers) {
if (handlers.isEmpty()) throw new IllegalStateException("No ModelFallbackHandler registered");
for (int i = 0; i < handlers.size() - 1; i++) {
handlers.get(i).setNext(handlers.get(i + 1));
}
return handlers.get(0);
}
}

View File

@ -0,0 +1,10 @@
package vip.mate.wiki.job;
/**
* RFC-030: Thrown when no model is available for a wiki processing step.
*/
public class WikiModelUnavailableException extends RuntimeException {
public WikiModelUnavailableException(String message) {
super(message);
}
}

View File

@ -0,0 +1,185 @@
package vip.mate.wiki.job;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.repository.WikiProcessingJobMapper;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import java.time.LocalDateTime;
import java.util.Map;
/**
* RFC-030: Wiki processing job lifecycle service creates, transitions,
* and records errors for processing jobs.
*/
@Slf4j
@Service
public class WikiProcessingJobService {
private final WikiProcessingJobMapper jobMapper;
private final WikiModelRoutingService routingService;
private final ObjectMapper objectMapper;
@Autowired(required = false)
private AvailableProviderPool providerPool;
@Autowired
private ModelConfigService modelConfigService;
public WikiProcessingJobService(WikiProcessingJobMapper jobMapper,
WikiModelRoutingService routingService,
ObjectMapper objectMapper) {
this.jobMapper = jobMapper;
this.routingService = routingService;
this.objectMapper = objectMapper;
}
public WikiProcessingJobEntity createHeavyIngest(Long kbId, Long rawId) {
WikiProcessingJobEntity job = new WikiProcessingJobEntity();
job.setKbId(kbId);
job.setRawId(rawId);
job.setJobType(WikiJobType.HEAVY_INGEST.name().toLowerCase());
job.setStage(WikiJobStage.QUEUED.name().toLowerCase());
job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase());
job.setMaxRetries(3);
job.setRetryCount(0);
jobMapper.insert(job);
return job;
}
public WikiProcessingJobEntity createLightEnrich(Long kbId, Long rawId) {
WikiProcessingJobEntity job = new WikiProcessingJobEntity();
job.setKbId(kbId);
job.setRawId(rawId);
job.setJobType(WikiJobType.LIGHT_ENRICH.name().toLowerCase());
job.setStage(WikiJobStage.QUEUED.name().toLowerCase());
job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase());
job.setMaxRetries(2);
job.setRetryCount(0);
jobMapper.insert(job);
return job;
}
public WikiProcessingJobEntity createLocalRepair(Long kbId, Long rawId, Long targetPageId) {
WikiProcessingJobEntity job = new WikiProcessingJobEntity();
job.setKbId(kbId);
job.setRawId(rawId);
job.setJobType(WikiJobType.LOCAL_REPAIR.name().toLowerCase());
job.setStage(WikiJobStage.QUEUED.name().toLowerCase());
job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase());
job.setMaxRetries(2);
job.setRetryCount(0);
try {
job.setMetaJson(objectMapper.writeValueAsString(Map.of("targetPageId", targetPageId)));
} catch (Exception e) {
job.setMetaJson("{}");
}
jobMapper.insert(job);
return job;
}
public WikiProcessingJobEntity transition(Long jobId, WikiJobStage newStage) {
WikiProcessingJobEntity job = jobMapper.selectById(jobId);
if (job == null) return null;
job.setStage(newStage.name().toLowerCase());
if (newStage == WikiJobStage.ROUTING) {
job.setStartedAt(LocalDateTime.now());
job.setStatus(WikiJobStatus.RUNNING.name().toLowerCase());
job.setFallbackChainJson(routingService.buildFallbackChainJson(job.getKbId()));
} else if (newStage == WikiJobStage.COMPLETED) {
job.setFinishedAt(LocalDateTime.now());
job.setStatus(WikiJobStatus.COMPLETED.name().toLowerCase());
}
jobMapper.updateById(job);
return job;
}
public void recordHardError(Long jobId, String errorCode, String errorMessage) {
WikiProcessingJobEntity job = jobMapper.selectById(jobId);
if (job == null) return;
job.setErrorCode(errorCode);
job.setErrorMessage(truncate(errorMessage, 2000));
job.setFinishedAt(LocalDateTime.now());
if (job.getRetryCount() < job.getMaxRetries()) {
job.setRetryCount(job.getRetryCount() + 1);
job.setResumeFromStage(job.getStage());
job.setStage(WikiJobStage.QUEUED.name().toLowerCase());
job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase());
} else {
job.setStatus(WikiJobStatus.FAILED.name().toLowerCase());
job.setStage(WikiJobStage.FAILED.name().toLowerCase());
}
jobMapper.updateById(job);
if (providerPool != null && isHardError(errorCode) && job.getCurrentModelId() != null) {
notifyPoolHardError(job.getCurrentModelId(), errorCode);
}
}
public void recordSoftError(Long jobId, String errorCode, String errorMessage) {
WikiProcessingJobEntity job = jobMapper.selectById(jobId);
if (job == null) return;
job.setErrorCode(errorCode);
job.setErrorMessage(truncate(errorMessage, 2000));
job.setFinishedAt(LocalDateTime.now());
if (job.getRetryCount() < job.getMaxRetries()) {
job.setRetryCount(job.getRetryCount() + 1);
job.setResumeFromStage(job.getStage());
job.setStage(WikiJobStage.QUEUED.name().toLowerCase());
job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase());
} else {
job.setStatus(WikiJobStatus.FAILED.name().toLowerCase());
job.setStage(WikiJobStage.FAILED.name().toLowerCase());
}
jobMapper.updateById(job);
}
/**
* Recover stuck jobs on startup (routing or *_running queued).
*/
public void recoverOnStartup() {
int recovered = jobMapper.recoverStuckJobs();
if (recovered > 0) {
log.info("[WikiJob] Recovered {} stuck jobs on startup", recovered);
}
}
private static boolean isHardError(String errorCode) {
return errorCode != null && (
errorCode.equals("AUTH_ERROR") ||
errorCode.equals("BILLING") ||
errorCode.equals("MODEL_NOT_FOUND"));
}
private void notifyPoolHardError(Long modelId, String errorCode) {
try {
ModelConfigEntity model = modelConfigService.getModel(modelId);
if (model != null) {
AvailableProviderPool.RemovalSource source;
try {
source = AvailableProviderPool.RemovalSource.valueOf(errorCode);
} catch (IllegalArgumentException e) {
source = AvailableProviderPool.RemovalSource.AUTH_ERROR;
}
providerPool.remove(model.getProvider(), source, "Wiki job hard error: " + errorCode);
}
} catch (Exception e) {
log.warn("[WikiJob] Failed to notify provider pool: {}", e.getMessage());
}
}
private static String truncate(String s, int maxLen) {
if (s == null) return null;
return s.length() <= maxLen ? s : s.substring(0, maxLen);
}
}

View File

@ -0,0 +1,17 @@
package vip.mate.wiki.job;
/**
* RFC-031: Thrown when a wiki processing step encounters a transient
* model error that can be retried.
*/
public class WikiSoftModelException extends RuntimeException {
private final String errorCode;
public WikiSoftModelException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}

View File

@ -0,0 +1,7 @@
package vip.mate.wiki.job.event;
/**
* RFC-031: Published when a new wiki processing job is created,
* triggering the dispatcher to execute it.
*/
public record WikiJobCreatedEvent(Long jobId) {}

View File

@ -0,0 +1,35 @@
package vip.mate.wiki.job.fallback;
import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import java.util.Optional;
/**
* RFC-030: Final fallback returns the system default model.
*/
@Component
@Order(2)
@RequiredArgsConstructor
public class GlobalFallbackModelHandler implements ModelFallbackHandler {
private final ModelConfigService modelConfigService;
private ModelFallbackHandler next;
@Override
public Optional<Long> handle(WikiProcessingJobEntity job, WikiJobStep step, String errorCode) {
try {
return Optional.of(modelConfigService.getDefaultModel().getId());
} catch (Exception e) {
return Optional.empty();
}
}
@Override
public void setNext(ModelFallbackHandler next) { this.next = next; }
}

View File

@ -0,0 +1,16 @@
package vip.mate.wiki.job.fallback;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import java.util.Optional;
/**
* RFC-030: Chain of responsibility for model fallback selection.
*/
public interface ModelFallbackHandler {
Optional<Long> handle(WikiProcessingJobEntity job, WikiJobStep step, String errorCode);
void setNext(ModelFallbackHandler next);
}

View File

@ -0,0 +1,70 @@
package vip.mate.wiki.job.fallback;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import java.util.List;
import java.util.Optional;
/**
* RFC-030: Pool-aware fallback walks the job's fallback chain,
* skipping models whose provider is no longer in the AvailableProviderPool.
*/
@Slf4j
@Component
@Order(1)
public class PoolAwareModelFallbackHandler implements ModelFallbackHandler {
@Autowired(required = false)
private AvailableProviderPool providerPool;
@Autowired
private ModelConfigService modelConfigService;
@Autowired
private ObjectMapper objectMapper;
private ModelFallbackHandler next;
@Override
public Optional<Long> handle(WikiProcessingJobEntity job, WikiJobStep step, String errorCode) {
List<Long> fallbackChain = parseFallbackChain(job.getFallbackChainJson());
for (Long modelId : fallbackChain) {
if (!modelId.equals(job.getCurrentModelId()) && isAvailable(modelId)) {
return Optional.of(modelId);
}
}
return next != null ? next.handle(job, step, errorCode) : Optional.empty();
}
private boolean isAvailable(Long modelId) {
if (providerPool == null) return true;
try {
ModelConfigEntity model = modelConfigService.getModel(modelId);
return providerPool.contains(model.getProvider());
} catch (Exception e) {
return false;
}
}
private List<Long> parseFallbackChain(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return List.of();
}
}
@Override
public void setNext(ModelFallbackHandler next) { this.next = next; }
}

View File

@ -0,0 +1,60 @@
package vip.mate.wiki.job.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* RFC-030: Wiki processing job entity tracks the lifecycle of a single
* raw material processing run with per-stage state and model routing info.
*/
@Data
@TableName("mate_wiki_processing_job")
public class WikiProcessingJobEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long kbId;
private Long rawId;
private String jobType;
private String stage;
private String status;
private Long primaryModelId;
private Long currentModelId;
private String fallbackChainJson;
private Integer retryCount;
private Integer maxRetries;
private String errorCode;
private String errorMessage;
private String resumeFromStage;
/** Generic JSON metadata (e.g. targetPageId for LOCAL_REPAIR) */
private String metaJson;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,33 @@
package vip.mate.wiki.job.strategy;
import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
/**
* RFC-030: Final fallback strategy uses the system default model.
* Cheap steps (ROUTE, ENRICH, SUMMARY) prefer a lighter/cheaper model;
* strong steps (CREATE_PAGE, MERGE_PAGE) use the default model.
*/
@Component
@Order(2)
@RequiredArgsConstructor
public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy {
private final ModelConfigService modelConfigService;
@Override
public boolean supports(WikiJobStep step) { return true; }
@Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
// RFC-030: cheap steps (ROUTE, ENRICH, SUMMARY) should ideally use a lighter model,
// but ModelConfigService has no "cheapest chat model" concept yet.
// When per-step pricing metadata is added, this switch can differentiate.
return modelConfigService.getDefaultModel().getId();
}
}

View File

@ -0,0 +1,44 @@
package vip.mate.wiki.job.strategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import java.util.Map;
/**
* RFC-030: Highest-priority strategy uses per-KB step model overrides
* from the KB's configContent JSON.
*/
@Slf4j
@Component
@Order(1)
@RequiredArgsConstructor
public class KbConfigStepModelStrategy implements WikiStepModelStrategy {
private final ObjectMapper objectMapper;
@Override
public boolean supports(WikiJobStep step) { return true; }
@Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
if (kb == null || kb.getConfigContent() == null) return null;
try {
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
Map<String, Long> stepModels = config.getStepModels();
if (stepModels == null) return null;
String key = job.getJobType() + "." + step.name().toLowerCase();
return stepModels.get(key);
} catch (Exception e) {
log.debug("[KbConfigStrategy] Failed to parse KB config: {}", e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,21 @@
package vip.mate.wiki.job.strategy;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
/**
* RFC-030: Strategy for selecting a model for a specific processing step.
* Implementations are evaluated in @Order priority; first non-null result wins.
*/
public interface WikiStepModelStrategy {
boolean supports(WikiJobStep step);
/**
* Select a model ID for the given job and step.
*
* @return model ID, or null to defer to the next strategy
*/
Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step);
}

View File

@ -0,0 +1,53 @@
package vip.mate.wiki.job.template;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.job.*;
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.service.WikiProcessingService;
/**
* RFC-031: Heavy ingest template delegates to the legacy processing
* service for the two-phase digest pipeline, then optionally dispatches
* a light enrichment job.
*/
@Component
public class HeavyIngestTemplate extends WikiProcessingTemplate {
private final WikiProcessingService legacyProcessingService;
public HeavyIngestTemplate(WikiModelRoutingService routingService,
WikiProcessingJobService jobService,
ApplicationEventPublisher eventPublisher,
WikiProperties wikiProperties,
WikiProcessingService legacyProcessingService) {
super(routingService, jobService, eventPublisher, wikiProperties);
this.legacyProcessingService = legacyProcessingService;
}
@Override
protected WikiJobStep routingStep() { return WikiJobStep.CREATE_PAGE; }
@Override
protected WikiJobStage mainStage() { return WikiJobStage.PHASE_A_RUNNING; }
@Override
protected void doProcess(WikiProcessingJobEntity job, Long modelId) {
// Transition period: delegate to existing processing pipeline as a whole.
// TODO: RFC-031 future split into processInChunksForJob(job, modelId),
// processChunkTwoPhaseForJob(job, modelId), scheduleEmbeddingAsync(rawId)
// for per-stage job transitions and per-step model routing.
legacyProcessingService.processRawMaterial(job.getRawId());
}
@Override
protected void onSuccess(WikiProcessingJobEntity job) {
if (wikiProperties.isLightEnrichEnabled()) {
WikiProcessingJobEntity enrichJob =
jobService.createLightEnrich(job.getKbId(), job.getRawId());
eventPublisher.publishEvent(new WikiJobCreatedEvent(enrichJob.getId()));
}
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.wiki.job.template;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.WikiPageLite;
import vip.mate.wiki.job.*;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.service.WikiCitationService;
import vip.mate.wiki.service.WikiLinkEnrichmentService;
import vip.mate.wiki.service.WikiRelationService;
import java.util.List;
/**
* RFC-031: Light enrichment template adds [[wikilinks]] and rebuilds
* citations without regenerating page content.
*/
@Slf4j
@Component
public class LightEnrichTemplate extends WikiProcessingTemplate {
private final WikiLinkEnrichmentService enrichmentService;
private final WikiCitationService citationService;
private final WikiRelationService relationService;
public LightEnrichTemplate(WikiModelRoutingService routingService,
WikiProcessingJobService jobService,
ApplicationEventPublisher eventPublisher,
WikiProperties wikiProperties,
WikiLinkEnrichmentService enrichmentService,
WikiCitationService citationService,
WikiRelationService relationService) {
super(routingService, jobService, eventPublisher, wikiProperties);
this.enrichmentService = enrichmentService;
this.citationService = citationService;
this.relationService = relationService;
}
@Override
protected WikiJobStep routingStep() { return WikiJobStep.ENRICH; }
@Override
protected WikiJobStage mainStage() { return WikiJobStage.ENRICHING; }
@Override
protected void doProcess(WikiProcessingJobEntity job, Long modelId) {
List<WikiPageLite> pages = relationService.pagesByRawId(job.getRawId());
for (WikiPageLite page : pages) {
try {
enrichmentService.enrichPage(page.id(), modelId);
citationService.buildCitations(page.id(), job.getKbId());
} catch (Exception e) {
log.warn("[LightEnrich] Failed to enrich page {}: {}", page.slug(), e.getMessage());
}
}
}
}

View File

@ -0,0 +1,69 @@
package vip.mate.wiki.job.template;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.job.*;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.service.WikiCitationService;
import vip.mate.wiki.service.WikiProcessingService;
import java.util.Map;
/**
* RFC-031: Local repair template regenerates a single target page
* without affecting other pages derived from the same raw material.
*/
@Slf4j
@Component
public class LocalRepairTemplate extends WikiProcessingTemplate {
private final WikiProcessingService legacyProcessingService;
private final WikiCitationService citationService;
private final ObjectMapper objectMapper;
public LocalRepairTemplate(WikiModelRoutingService routingService,
WikiProcessingJobService jobService,
ApplicationEventPublisher eventPublisher,
WikiProperties wikiProperties,
WikiProcessingService legacyProcessingService,
WikiCitationService citationService,
ObjectMapper objectMapper) {
super(routingService, jobService, eventPublisher, wikiProperties);
this.legacyProcessingService = legacyProcessingService;
this.citationService = citationService;
this.objectMapper = objectMapper;
}
@Override
protected WikiJobStep routingStep() { return WikiJobStep.CREATE_PAGE; }
@Override
protected WikiJobStage mainStage() { return WikiJobStage.PHASE_B_RUNNING; }
@Override
protected void doProcess(WikiProcessingJobEntity job, Long modelId) {
Map<String, Object> meta = parseMetaJson(job.getMetaJson());
Object pageIdObj = meta.get("targetPageId");
if (pageIdObj == null) {
throw new IllegalArgumentException("targetPageId missing from job metaJson, jobId=" + job.getId());
}
Long targetPageId = Long.valueOf(pageIdObj.toString());
legacyProcessingService.repairSinglePage(targetPageId, modelId);
citationService.buildCitations(targetPageId, job.getKbId());
}
private Map<String, Object> parseMetaJson(String json) {
if (json == null || json.isBlank()) return Map.of();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[LocalRepair] Failed to parse metaJson: {}", e.getMessage());
return Map.of();
}
}
}

View File

@ -0,0 +1,87 @@
package vip.mate.wiki.job.template;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.job.*;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
/**
* RFC-031: Template method base class for all wiki processing job types.
* The {@link #execute} skeleton handles routing, error classification,
* and fallback; subclasses implement the actual processing in {@link #doProcess}.
*/
@Slf4j
public abstract class WikiProcessingTemplate {
protected final WikiModelRoutingService routingService;
protected final WikiProcessingJobService jobService;
protected final ApplicationEventPublisher eventPublisher;
protected final WikiProperties wikiProperties;
protected WikiProcessingTemplate(WikiModelRoutingService routingService,
WikiProcessingJobService jobService,
ApplicationEventPublisher eventPublisher,
WikiProperties wikiProperties) {
this.routingService = routingService;
this.jobService = jobService;
this.eventPublisher = eventPublisher;
this.wikiProperties = wikiProperties;
}
/**
* Non-overridable skeleton: route process handle errors.
*/
public final void execute(WikiProcessingJobEntity job) {
try {
job = jobService.transition(job.getId(), WikiJobStage.ROUTING);
Long modelId = routingService.selectModelId(job, routingStep());
job.setCurrentModelId(modelId);
job = jobService.transition(job.getId(), mainStage());
try {
doProcess(job, modelId);
jobService.transition(job.getId(), WikiJobStage.COMPLETED);
onSuccess(job);
} catch (WikiHardModelException e) {
handleHardError(job, e);
} catch (WikiSoftModelException e) {
handleSoftError(job, e);
} catch (Exception e) {
jobService.recordSoftError(job.getId(), "UNKNOWN", e.getMessage());
}
} catch (WikiModelUnavailableException e) {
log.error("[WikiTemplate] No model available for job {}: {}", job.getId(), e.getMessage());
jobService.recordHardError(job.getId(), "MODEL_NOT_FOUND", e.getMessage());
}
}
/** The logical step used for model selection during routing. */
protected abstract WikiJobStep routingStep();
/** The primary stage to transition to before processing starts. */
protected abstract WikiJobStage mainStage();
/** Subclass implements the actual processing logic. */
protected abstract void doProcess(WikiProcessingJobEntity job, Long modelId);
/** Hook called after successful completion (optional override). */
protected void onSuccess(WikiProcessingJobEntity job) {}
private void handleHardError(WikiProcessingJobEntity job, WikiHardModelException e) {
try {
Long fallbackModelId = routingService.selectFallbackModel(
job, routingStep(), e.getErrorCode());
job.setCurrentModelId(fallbackModelId);
doProcess(job, fallbackModelId);
jobService.transition(job.getId(), WikiJobStage.COMPLETED);
onSuccess(job);
} catch (Exception fallbackEx) {
jobService.recordHardError(job.getId(), e.getErrorCode(), e.getMessage());
}
}
private void handleSoftError(WikiProcessingJobEntity job, WikiSoftModelException e) {
jobService.recordSoftError(job.getId(), e.getErrorCode(), e.getMessage());
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.wiki.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* RFC-029: Wiki page citation entity links a page to the chunks it was derived from.
*/
@Data
@TableName("mate_wiki_page_citation")
public class WikiPageCitationEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long pageId;
private Long chunkId;
private Integer paragraphIdx;
private String anchorText;
private BigDecimal confidence;
private String createdBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableLogic
private Integer deleted;
}

View File

@ -42,7 +42,13 @@ public class WikiPageEntity {
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String sourceRawIds;
/** 版本号(每次 AI 更新递增) */
/** Page type: entity / concept / source / synthesis */
private String pageType;
/** Purpose hint for LLM ingest routing */
private String purposeHint;
/** Version number (incremented on each AI update) */
private Integer version;
/** 最后更新者ai / manual */

View File

@ -0,0 +1,70 @@
package vip.mate.wiki.relation;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
import vip.mate.wiki.service.WikiPageService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-029: Direct-link signal pages connected via [[wikilinks]]
* are related (weight = 2.0). Checks both outgoing and incoming links.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DirectLinkSignal implements RelationSignalStrategy {
private final WikiPageMapper pageMapper;
private final WikiPageService pageService;
private final ObjectMapper objectMapper;
@Override
public String signalName() { return "direct_link"; }
@Override
public double weight() { return 2.0; }
@Override
public Map<Long, Double> score(Long seedPageId, Long kbId) {
WikiPageEntity seed = pageMapper.selectById(seedPageId);
if (seed == null) return Map.of();
Map<Long, Double> scores = new HashMap<>();
// Outgoing links: slugs this page links to
List<String> outgoing = parseStringList(seed.getOutgoingLinks());
for (String slug : outgoing) {
WikiPageEntity target = pageService.getBySlug(kbId, slug);
if (target != null) {
scores.put(target.getId(), weight());
}
}
// Incoming links: pages whose outgoingLinks contain seed's slug
List<WikiPageEntity> inbound = pageService.getBacklinks(kbId, seed.getSlug());
for (WikiPageEntity p : inbound) {
scores.merge(p.getId(), weight(), Double::sum);
}
scores.remove(seedPageId);
return scores;
}
private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[DirectLinkSignal] Failed to parse outgoingLinks: {}", e.getMessage());
return List.of();
}
}
}

View File

@ -0,0 +1,27 @@
package vip.mate.wiki.relation;
import java.util.Map;
/**
* RFC-029: Strategy interface for computing a single relation signal
* between a seed page and other pages in the knowledge base.
* <p>
* Each implementation returns a map of pageId raw score for pages
* that have a positive signal. Pages not in the map score 0.
* The seed page itself is filtered out by {@link vip.mate.wiki.service.WikiRelationService}.
*/
public interface RelationSignalStrategy {
String signalName();
double weight();
/**
* Compute scores for pages related to the given seed page.
*
* @param seedPageId the seed page ID
* @param kbId the knowledge base ID
* @return pageId weighted score (only positive entries)
*/
Map<Long, Double> score(Long seedPageId, Long kbId);
}

View File

@ -0,0 +1,109 @@
package vip.mate.wiki.relation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.service.WikiChunkService;
import vip.mate.wiki.service.WikiEmbeddingService;
import vip.mate.wiki.service.WikiPageService;
import java.util.*;
/**
* RFC-029: Semantic-near signal (optional, weight = 1.0).
* Uses chunk embeddings to find semantically similar pages.
* Silently returns empty when embedding service is unavailable.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SemanticNearSignal implements RelationSignalStrategy {
private static final double THRESHOLD = 0.85;
private final WikiEmbeddingService embeddingService;
private final WikiChunkService chunkService;
private final WikiPageCitationMapper citationMapper;
private final WikiPageService pageService;
@Override
public String signalName() { return "semantic_near"; }
@Override
public double weight() { return 1.0; }
@Override
public Map<Long, Double> score(Long seedPageId, Long kbId) {
if (!embeddingService.isAvailable()) return Map.of();
// Get seed page's chunks and their embeddings
WikiPageEntity seed = pageService.getById(seedPageId);
if (seed == null) return Map.of();
// Collect seed chunk embeddings via citation mapper
List<Long> seedChunkIds = citationMapper.listWithRawByPageId(seedPageId)
.stream().map(c -> c.chunkId()).distinct().toList();
if (seedChunkIds.isEmpty()) return Map.of();
// Get all chunks in the KB
List<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
Set<Long> seedChunkSet = new HashSet<>(seedChunkIds);
// Get seed chunk vectors
List<float[]> seedVectors = new ArrayList<>();
for (WikiChunkEntity chunk : allChunks) {
if (seedChunkSet.contains(chunk.getId()) && chunk.getEmbedding() != null) {
seedVectors.add(WikiEmbeddingService.bytesToFloats(chunk.getEmbedding()));
}
}
if (seedVectors.isEmpty()) return Map.of();
// Average seed vectors into a single representative vector
float[] seedVec = averageVectors(seedVectors);
// Score all non-seed chunks by cosine similarity, aggregate to page level
Map<Long, Double> chunkScores = new HashMap<>();
for (WikiChunkEntity chunk : allChunks) {
if (seedChunkSet.contains(chunk.getId()) || chunk.getEmbedding() == null) continue;
float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding());
double sim = WikiEmbeddingService.cosine(seedVec, vec);
if (sim >= THRESHOLD) {
chunkScores.merge(chunk.getId(), sim, Math::max);
}
}
// Map chunk scores to page scores via citation
Map<Long, Double> pageScores = new HashMap<>();
for (var entry : chunkScores.entrySet()) {
List<Long> pageIds = citationMapper.listPageIdsByChunkId(entry.getKey());
for (Long pid : pageIds) {
if (!pid.equals(seedPageId)) {
pageScores.merge(pid, entry.getValue() * weight(), Math::max);
}
}
}
return pageScores;
}
private float[] averageVectors(List<float[]> vectors) {
if (vectors.size() == 1) return vectors.get(0);
int dim = vectors.get(0).length;
float[] avg = new float[dim];
for (float[] v : vectors) {
for (int i = 0; i < dim; i++) avg[i] += v[i];
}
float norm = 0;
for (int i = 0; i < dim; i++) {
avg[i] /= vectors.size();
norm += avg[i] * avg[i];
}
norm = (float) Math.sqrt(norm);
if (norm > 0) {
for (int i = 0; i < dim; i++) avg[i] /= norm;
}
return avg;
}
}

View File

@ -0,0 +1,44 @@
package vip.mate.wiki.relation;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import vip.mate.wiki.dto.PageCitationWithRaw;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* RFC-029: Shared-chunk signal pages that share the same source chunk
* are strongly related (weight = 5.0).
*/
@Component
@RequiredArgsConstructor
public class SharedChunkSignal implements RelationSignalStrategy {
private final WikiPageCitationMapper citationMapper;
@Override
public String signalName() { return "shared_chunk"; }
@Override
public double weight() { return 5.0; }
@Override
public Map<Long, Double> score(Long seedPageId, Long kbId) {
List<Long> seedChunkIds = citationMapper.listWithRawByPageId(seedPageId)
.stream().map(PageCitationWithRaw::chunkId).distinct().toList();
if (seedChunkIds.isEmpty()) return Map.of();
Map<Long, Long> pageCount = new HashMap<>();
citationMapper.listByChunkIds(seedChunkIds).stream()
.filter(ref -> !ref.pageId().equals(seedPageId))
.forEach(ref -> pageCount.merge(ref.pageId(), 1L, Long::sum));
return pageCount.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey,
e -> e.getValue() * weight()));
}
}

View File

@ -0,0 +1,61 @@
package vip.mate.wiki.relation;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.repository.WikiPageMapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-029: Shared-raw signal pages derived from the same raw material
* are related (weight = 3.0).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SharedRawSignal implements RelationSignalStrategy {
private final WikiPageCitationMapper citationMapper;
private final WikiPageMapper pageMapper;
private final ObjectMapper objectMapper;
@Override
public String signalName() { return "shared_raw"; }
@Override
public double weight() { return 3.0; }
@Override
public Map<Long, Double> score(Long seedPageId, Long kbId) {
WikiPageEntity seed = pageMapper.selectById(seedPageId);
if (seed == null) return Map.of();
List<Long> rawIds = parseRawIds(seed.getSourceRawIds());
if (rawIds.isEmpty()) return Map.of();
Map<Long, Double> scores = new HashMap<>();
for (Long rawId : rawIds) {
citationMapper.listPageIdsByRawId(rawId).stream()
.filter(pid -> !pid.equals(seedPageId))
.forEach(pid -> scores.merge(pid, weight(), Double::sum));
}
return scores;
}
private List<Long> parseRawIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[SharedRawSignal] Failed to parse sourceRawIds: {}", e.getMessage());
return List.of();
}
}
}

View File

@ -0,0 +1,41 @@
package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import vip.mate.wiki.dto.ChunkPageRef;
import vip.mate.wiki.dto.PageCitationWithRaw;
import vip.mate.wiki.model.WikiPageCitationEntity;
import java.util.Collection;
import java.util.List;
/**
* RFC-029: Wiki page citation mapper bidirectional pagechunk queries.
*/
@Mapper
public interface WikiPageCitationMapper extends BaseMapper<WikiPageCitationEntity> {
@Select("SELECT c.id, c.page_id, c.chunk_id, wc.raw_id, c.paragraph_idx, c.anchor_text, c.confidence " +
"FROM mate_wiki_page_citation c " +
"JOIN mate_wiki_chunk wc ON c.chunk_id = wc.id " +
"WHERE c.page_id = #{pageId} AND c.deleted = 0")
List<PageCitationWithRaw> listWithRawByPageId(@Param("pageId") Long pageId);
@Select("SELECT page_id FROM mate_wiki_page_citation " +
"WHERE chunk_id = #{chunkId} AND deleted = 0")
List<Long> listPageIdsByChunkId(@Param("chunkId") Long chunkId);
@Select("SELECT DISTINCT c.page_id FROM mate_wiki_page_citation c " +
"JOIN mate_wiki_chunk wc ON c.chunk_id = wc.id " +
"WHERE wc.raw_id = #{rawId} AND c.deleted = 0 AND wc.deleted = 0")
List<Long> listPageIdsByRawId(@Param("rawId") Long rawId);
@Update("UPDATE mate_wiki_page_citation SET deleted = 1 WHERE page_id = #{pageId}")
void softDeleteByPageId(@Param("pageId") Long pageId);
@Select("<script>SELECT chunk_id, page_id FROM mate_wiki_page_citation " +
"WHERE chunk_id IN " +
"<foreach collection='chunkIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND deleted = 0</script>")
List<ChunkPageRef> listByChunkIds(@Param("chunkIds") Collection<Long> chunkIds);
}

View File

@ -4,12 +4,14 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import vip.mate.wiki.dto.WikiPageLite;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.Collection;
import java.util.List;
/**
* Wiki 页面 Mapper
* Wiki page mapper
*
* @author MateClaw Team
*/
@ -17,8 +19,8 @@ import java.util.List;
public interface WikiPageMapper extends BaseMapper<WikiPageEntity> {
/**
* DB 级别关键词搜索H2 + MySQL 通用 LIKE
* SELECT content CLOB避免全量加载到 Java 内存
* DB keyword search (H2 + MySQL compatible LIKE).
* Does not SELECT content CLOB to avoid loading large blobs into Java heap.
*/
@Select("SELECT id, kb_id, slug, title, summary, source_raw_ids, last_updated_by " +
"FROM mate_wiki_page " +
@ -27,4 +29,55 @@ public interface WikiPageMapper extends BaseMapper<WikiPageEntity> {
" OR LOWER(content) LIKE #{pattern}) " +
"ORDER BY title LIMIT 20")
List<WikiPageEntity> searchByKeyword(@Param("kbId") Long kbId, @Param("pattern") String pattern);
// ==================== RFC-029: Relation model ====================
/**
* Batch-fetch lightweight page projections by IDs (no content).
*/
@Select("<script>SELECT id, slug, title, summary FROM mate_wiki_page " +
"WHERE id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND deleted = 0</script>")
List<WikiPageLite> selectBatchLite(@Param("ids") Collection<Long> ids);
/**
* List all pages as lightweight projections (no content).
*/
@Select("SELECT id, slug, title, summary FROM mate_wiki_page " +
"WHERE kb_id = #{kbId} AND deleted = 0 ORDER BY update_time DESC")
List<WikiPageLite> selectAllLite(@Param("kbId") Long kbId);
/**
* Fetch only the content column for a single page (lazy-load for snippet extraction).
*/
@Select("SELECT content FROM mate_wiki_page WHERE id = #{id} AND deleted = 0")
String selectContentById(@Param("id") Long id);
// ==================== RFC-032: Two-phase keyword search ====================
/**
* Phase 1 (fast): search only title + summary columns.
*/
@Select("SELECT id FROM mate_wiki_page " +
"WHERE kb_id = #{kbId} AND deleted = 0 " +
"AND (LOWER(title) LIKE #{kw} OR LOWER(summary) LIKE #{kw}) " +
"LIMIT #{limit}")
List<Long> searchFastIds(@Param("kbId") Long kbId,
@Param("kw") String kw,
@Param("limit") int limit);
/**
* Phase 2 (slow): search full content, excluding already-found IDs.
*/
@Select("<script>SELECT id FROM mate_wiki_page " +
"WHERE kb_id = #{kbId} AND deleted = 0 " +
"AND LOWER(content) LIKE #{kw} " +
"<if test='excludeIds != null and !excludeIds.isEmpty()'>" +
"AND id NOT IN <foreach collection='excludeIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>" +
"</if> " +
"LIMIT #{limit}</script>")
List<Long> searchContentIds(@Param("kbId") Long kbId,
@Param("kw") String kw,
@Param("excludeIds") List<Long> excludeIds,
@Param("limit") int limit);
}

View File

@ -0,0 +1,42 @@
package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import java.util.List;
import java.util.Optional;
/**
* RFC-030: Wiki processing job mapper.
*/
@Mapper
public interface WikiProcessingJobMapper extends BaseMapper<WikiProcessingJobEntity> {
@Select("SELECT * FROM mate_wiki_processing_job " +
"WHERE kb_id = #{kbId} AND status = 'queued' AND deleted = 0 " +
"ORDER BY create_time ASC LIMIT #{limit}")
List<WikiProcessingJobEntity> listQueued(@Param("kbId") Long kbId, @Param("limit") int limit);
@Select("SELECT * FROM mate_wiki_processing_job " +
"WHERE raw_id = #{rawId} AND deleted = 0 ORDER BY create_time DESC LIMIT 1")
Optional<WikiProcessingJobEntity> findLatestByRawId(@Param("rawId") Long rawId);
/**
* List all non-deleted jobs for a KB (for stats/dashboard queries).
*/
@Select("SELECT * FROM mate_wiki_processing_job " +
"WHERE kb_id = #{kbId} AND deleted = 0 " +
"ORDER BY create_time DESC LIMIT #{limit}")
List<WikiProcessingJobEntity> listByKbId(@Param("kbId") Long kbId, @Param("limit") int limit);
/**
* Recover stuck jobs on startup: reset routing/*_running stages back to queued.
*/
@Update("UPDATE mate_wiki_processing_job " +
"SET status = 'queued', " +
" stage = COALESCE(resume_from_stage, 'queued'), " +
" update_time = CURRENT_TIMESTAMP(3) " +
"WHERE (stage = 'routing' OR stage LIKE '%_running') AND deleted = 0")
int recoverStuckJobs();
}

View File

@ -2,13 +2,27 @@ package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import vip.mate.wiki.dto.RawTitleRef;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import java.util.Collection;
import java.util.List;
/**
* Wiki 原始材料 Mapper
* Wiki raw material mapper
*
* @author MateClaw Team
*/
@Mapper
public interface WikiRawMaterialMapper extends BaseMapper<WikiRawMaterialEntity> {
/**
* RFC-032: Batch-fetch raw material titles by IDs (fixes N+1 in wiki_semantic_search).
*/
@Select("<script>SELECT id, title FROM mate_wiki_raw_material " +
"WHERE id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND deleted = 0</script>")
List<RawTitleRef> selectBatchTitles(@Param("ids") Collection<Long> ids);
}

View File

@ -0,0 +1,30 @@
package vip.mate.wiki.retrieval;
/**
* RFC-032: Extracts a context-aware snippet from page content
* around a query match point.
*/
public class SnippetExtractor {
private static final int CONTEXT_CHARS = 150;
/**
* Extract a snippet from content centered on the first occurrence of the query.
* If no exact match is found, returns the first ~300 characters.
*/
public static String extract(String content, String query) {
if (content == null || query == null) return null;
int idx = content.toLowerCase().indexOf(query.toLowerCase());
if (idx < 0) {
return content.length() <= CONTEXT_CHARS * 2
? content
: content.substring(0, CONTEXT_CHARS * 2) + "...";
}
int start = Math.max(0, idx - CONTEXT_CHARS);
int end = Math.min(content.length(), idx + query.length() + CONTEXT_CHARS);
String snippet = content.substring(start, end);
if (start > 0) snippet = "..." + snippet;
if (end < content.length()) snippet = snippet + "...";
return snippet;
}
}

View File

@ -2,52 +2,70 @@ package vip.mate.wiki.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.PageSearchResult;
import vip.mate.wiki.dto.RelatedPageResult;
import vip.mate.wiki.dto.WikiPageLite;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
import vip.mate.wiki.retrieval.SnippetExtractor;
import java.util.*;
import java.util.stream.Collectors;
/**
* RFC-011: 混合检索服务
* RFC-011 + RFC-032: Hybrid retrieval service.
* <p>
* 支持三种模式
* <ul>
* <li>{@code keyword} DB LIKE 搜索现有 WikiPageService.searchPages</li>
* <li>{@code semantic} chunk 向量 cosine 相似度 回溯到 page</li>
* <li>{@code hybrid} 两者融合RRF (Reciprocal Rank Fusion) 排名</li>
* </ul>
*
* @author MateClaw Team
* Three search modes: keyword (DB LIKE), semantic (chunk vectors),
* hybrid (RRF fusion). RFC-032 adds: N+1 fix, two-phase keyword search,
* relation boost, snippet extraction, and PageSearchResult DTO.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HybridRetriever {
private final WikiPageService pageService;
private final WikiChunkService chunkService;
private final WikiEmbeddingService embeddingService;
private final WikiProperties properties;
private final WikiPageMapper pageMapper;
@Autowired(required = false)
private WikiRelationService relationService;
private static final double RELATION_BOOST = 0.15;
public HybridRetriever(WikiPageService pageService,
WikiChunkService chunkService,
WikiEmbeddingService embeddingService,
WikiProperties properties,
WikiPageMapper pageMapper) {
this.pageService = pageService;
this.chunkService = chunkService;
this.embeddingService = embeddingService;
this.properties = properties;
this.pageMapper = pageMapper;
}
public enum Mode { KEYWORD, SEMANTIC, HYBRID }
/**
* 搜索结果页面级
* Legacy page hit record (kept for backward compatibility).
*/
public record PageHit(Long pageId, String slug, String title, String summary, double score) {}
/**
* 搜索结果chunk 语义搜索专用
* Chunk-level search result (semantic search).
*/
public record ChunkHit(Long chunkId, Long rawId, String snippet, float score) {}
/**
* 执行混合搜索返回页面级结果
* RFC-032: Enhanced search returning PageSearchResult with snippet and matchedBy metadata.
*/
public List<PageHit> searchPages(Long kbId, String query, String modeStr, int topK) {
public List<PageSearchResult> search(Long kbId, String query, String modeStr, int topK) {
Mode mode = parseMode(modeStr);
List<RankedItem> semantic = List.of();
@ -60,9 +78,7 @@ public class HybridRetriever {
keyword = keywordSearch(kbId, query, topK * 3);
}
// 如果 semantic 不可用no embedding model回退到 keyword
if (mode == Mode.SEMANTIC && semantic.isEmpty()) {
log.debug("[HybridRetriever] Semantic unavailable, falling back to keyword");
keyword = keywordSearch(kbId, query, topK * 3);
}
@ -75,21 +91,50 @@ public class HybridRetriever {
fused = rrfFuse(semantic, keyword, 60);
}
// topK装配 PageHit
return fused.stream()
.limit(topK)
.map(ri -> {
WikiPageEntity page = pageService.getById(ri.pageId);
if (page == null) return null;
return new PageHit(ri.pageId, page.getSlug(), page.getTitle(),
page.getSummary(), ri.score);
})
.filter(Objects::nonNull)
.toList();
// RFC-032: Relation boost (1-hop expansion on top-3 seeds)
fused = applyRelationBoost(fused, kbId, topK);
// Batch-fetch page info (N+1 fix)
List<Long> topIds = fused.stream().limit(topK).map(ri -> ri.pageId).toList();
if (topIds.isEmpty()) return List.of();
Map<Long, WikiPageLite> liteMap = pageMapper.selectBatchLite(topIds)
.stream().collect(Collectors.toMap(WikiPageLite::id, p -> p));
// Build result with snippets
List<PageSearchResult> results = new ArrayList<>();
for (RankedItem ri : fused.stream().limit(topK).toList()) {
WikiPageLite lite = liteMap.get(ri.pageId);
if (lite == null) continue;
String snippet = null;
if (!ri.matchedBy.contains("relation_boost")) {
String content = pageMapper.selectContentById(ri.pageId);
if (content != null) {
snippet = SnippetExtractor.extract(content, query);
}
}
String reason = buildReason(lite, ri.matchedBy, query);
results.add(new PageSearchResult(
lite.slug(), lite.title(), lite.summary(),
snippet != null ? snippet : lite.summary(),
ri.matchedBy, reason, ri.score));
}
return results;
}
/**
* chunk 级语义搜索Agent 直接拿 chunk 片段作为证据
* Legacy searchPages returns PageHit for backward compatibility.
*/
public List<PageHit> searchPages(Long kbId, String query, String modeStr, int topK) {
return search(kbId, query, modeStr, topK).stream()
.map(r -> new PageHit(null, r.slug(), r.title(), r.summary(), r.score()))
.toList();
}
/**
* Chunk-level semantic search.
*/
public List<ChunkHit> searchChunks(Long kbId, String query, int topK) {
if (!embeddingService.isAvailable()) return List.of();
@ -114,9 +159,9 @@ public class HybridRetriever {
.toList();
}
// ==================== 内部方法 ====================
// ==================== Internal methods ====================
/** 语义搜索chunk cosine → 聚合到 page同页多 chunk 取最高分) */
/** Semantic search: chunk cosine → aggregate to page level */
private List<RankedItem> semanticSearch(Long kbId, String query, int limit) {
float[] queryVec = embeddingService.embedQuery(kbId, query);
if (queryVec == null) return List.of();
@ -124,25 +169,19 @@ public class HybridRetriever {
List<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
if (allChunks.isEmpty()) return List.of();
// chunk score, 然后 需要映射到 page
// 当前没有 chunk page 的直接关联chunk 只有 rawId
// rawId 找该 rawId 对应的所有 pagesource_raw_ids 含该 rawId
// 这是个近似一个 rawId 可能产出多个 page都算命中
Map<Long, Float> chunkScores = new HashMap<>();
for (WikiChunkEntity chunk : allChunks) {
if (chunk.getEmbedding() == null) continue;
if (chunk.getEmbedding() == null || chunk.getRawId() == null) continue;
float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding());
float score = WikiEmbeddingService.cosine(queryVec, vec);
chunkScores.merge(chunk.getRawId(), score, Math::max); // rawId 级聚合
chunkScores.merge(chunk.getRawId(), score, Math::max);
}
// rawId page IDs
List<WikiPageEntity> allPages = pageService.listByKbId(kbId);
Map<Long, Double> pageScores = new HashMap<>();
for (WikiPageEntity page : allPages) {
String rawIds = page.getSourceRawIds();
if (rawIds == null) continue;
// 解析 "[1,2,3]" 格式
for (String rawIdStr : rawIds.replaceAll("[\\[\\]\\s]", "").split(",")) {
try {
long rawId = Long.parseLong(rawIdStr.trim());
@ -157,32 +196,108 @@ public class HybridRetriever {
return pageScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(limit)
.map(e -> new RankedItem(e.getKey(), e.getValue()))
.map(e -> new RankedItem(e.getKey(), e.getValue(), List.of("semantic")))
.toList();
}
/** 关键词搜索:走现有 DB LIKE */
/**
* RFC-032: Two-phase keyword search fast path (title+summary) first,
* full content search only if needed to fill topK.
*/
private List<RankedItem> keywordSearch(Long kbId, String query, int limit) {
List<WikiPageEntity> results = pageService.searchPages(kbId, query);
String kw = "%" + query.toLowerCase()
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_") + "%";
// Phase 1: fast path (title + summary only)
List<Long> fastIds = pageMapper.searchFastIds(kbId, kw, limit);
List<RankedItem> ranked = new ArrayList<>();
for (int i = 0; i < Math.min(results.size(), limit); i++) {
// LIKE 无分数用倒序排名作为伪分数
ranked.add(new RankedItem(results.get(i).getId(), 1.0 / (i + 1)));
for (int i = 0; i < fastIds.size(); i++) {
ranked.add(new RankedItem(fastIds.get(i), 1.0 / (i + 1), List.of("title")));
}
if (fastIds.size() >= limit) return ranked;
// Phase 2: full content search (supplement)
List<Long> contentIds = pageMapper.searchContentIds(kbId, kw, fastIds, limit - fastIds.size());
for (int i = 0; i < contentIds.size(); i++) {
ranked.add(new RankedItem(contentIds.get(i),
1.0 / (fastIds.size() + i + 1), List.of("content")));
}
return ranked;
}
/** RRF 融合score = Σ 1/(k + rank_i) */
/** RRF fusion: score = Σ 1/(k + rank_i) */
private List<RankedItem> rrfFuse(List<RankedItem> a, List<RankedItem> b, int k) {
Map<Long, Double> fused = new HashMap<>();
for (int i = 0; i < a.size(); i++) fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
for (int i = 0; i < b.size(); i++) fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
Map<Long, List<String>> matchedByMap = new HashMap<>();
for (int i = 0; i < a.size(); i++) {
fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
matchedByMap.computeIfAbsent(a.get(i).pageId, x -> new ArrayList<>()).addAll(a.get(i).matchedBy);
}
for (int i = 0; i < b.size(); i++) {
fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
matchedByMap.computeIfAbsent(b.get(i).pageId, x -> new ArrayList<>()).addAll(b.get(i).matchedBy);
}
return fused.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.map(e -> new RankedItem(e.getKey(), e.getValue()))
.map(e -> new RankedItem(e.getKey(), e.getValue(),
matchedByMap.getOrDefault(e.getKey(), List.of()).stream().distinct().toList()))
.toList();
}
/**
* RFC-032: 1-hop relation boost on top-3 seed pages.
*/
private List<RankedItem> applyRelationBoost(List<RankedItem> hits, Long kbId, int topK) {
if (relationService == null || hits.isEmpty()) return hits;
List<Long> seedIds = hits.stream().limit(3).map(h -> h.pageId).toList();
Map<Long, Double> boostMap = new HashMap<>();
for (Long seedId : seedIds) {
List<WikiPageLite> seedLites = pageMapper.selectBatchLite(List.of(seedId));
if (seedLites.isEmpty()) continue;
WikiPageLite seed = seedLites.get(0);
try {
relationService.relatedPages(kbId, seed.slug(), 3)
.forEach(r -> {
// Find the page ID from slug
WikiPageEntity relPage = pageService.getBySlug(kbId, r.slug());
if (relPage != null) {
boostMap.merge(relPage.getId(), RELATION_BOOST, Double::sum);
}
});
} catch (Exception e) {
log.debug("[HybridRetriever] Relation boost failed for seed {}: {}", seed.slug(), e.getMessage());
}
}
Set<Long> existingIds = hits.stream().map(h -> h.pageId).collect(Collectors.toSet());
boostMap.keySet().removeAll(existingIds);
if (boostMap.isEmpty()) return hits;
List<RankedItem> expanded = new ArrayList<>(hits);
boostMap.forEach((pid, score) -> expanded.add(
new RankedItem(pid, score, List.of("relation_boost"))));
return expanded;
}
private String buildReason(WikiPageLite lite, List<String> matchedBy, String query) {
if (matchedBy.contains("relation_boost")) return "Structurally related to top search results";
if (matchedBy.contains("title") && matchedBy.contains("semantic")) return "Title and semantic match";
if (matchedBy.contains("title")) return "Title match";
if (matchedBy.contains("semantic")) return "Semantic similarity";
if (matchedBy.contains("content")) return "Content match";
return "Keyword match";
}
private Mode parseMode(String mode) {
if (mode == null || mode.isBlank()) {
String defaultMode = properties.getSearchDefaultMode();
@ -199,5 +314,5 @@ public class HybridRetriever {
};
}
private record RankedItem(Long pageId, double score) {}
private record RankedItem(Long pageId, double score, List<String> matchedBy) {}
}

View File

@ -0,0 +1,74 @@
package vip.mate.wiki.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiPageCitationEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.repository.WikiPageMapper;
import java.math.BigDecimal;
import java.util.List;
/**
* RFC-029: Builds citation records linking pages to their source chunks.
* Called asynchronously after page creation or update to avoid blocking
* the main processing pipeline.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiCitationService {
private final WikiPageMapper pageMapper;
private final WikiChunkService chunkService;
private final WikiPageCitationMapper citationMapper;
private final ObjectMapper objectMapper;
@Async
public void buildCitationsAsync(Long pageId, Long kbId) {
buildCitations(pageId, kbId);
}
/**
* Rebuild all citation records for a page based on its sourceRawIds.
* Soft-deletes existing citations first, then creates new ones for
* all chunks belonging to the page's source raw materials.
*/
public void buildCitations(Long pageId, Long kbId) {
WikiPageEntity page = pageMapper.selectById(pageId);
if (page == null) return;
List<Long> rawIds = parseRawIds(page.getSourceRawIds());
citationMapper.softDeleteByPageId(pageId);
for (Long rawId : rawIds) {
List<WikiChunkEntity> chunks = chunkService.listByRawId(rawId);
for (WikiChunkEntity chunk : chunks) {
WikiPageCitationEntity citation = new WikiPageCitationEntity();
citation.setPageId(pageId);
citation.setChunkId(chunk.getId());
citation.setConfidence(BigDecimal.ONE);
citation.setCreatedBy("system");
citationMapper.insert(citation);
}
}
log.debug("[WikiCitation] Built citations for pageId={}, kbId={}", pageId, kbId);
}
private List<Long> parseRawIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[WikiCitation] Failed to parse sourceRawIds: {}", e.getMessage());
return List.of();
}
}
}

View File

@ -4,18 +4,17 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.PageSearchResult;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.ArrayList;
import java.util.List;
/**
* Wiki 上下文服务
* Wiki context service builds context for agent conversation injection.
* <p>
* Agent 对话构建 Wiki 知识库上下文注入到系统提示词中
*
* @author MateClaw Team
* RFC-032: buildRelevantContext now delegates to HybridRetriever instead
* of using a custom keyword matching algorithm.
*/
@Slf4j
@Service
@ -24,17 +23,14 @@ public class WikiContextService {
private final WikiKnowledgeBaseService kbService;
private final WikiPageService pageService;
private final HybridRetriever hybridRetriever;
private final WikiProperties properties;
/**
* 构建与用户消息相关的 Wiki 上下文任务前知识注入
* Build relevant wiki context for the current user message.
* <p>
* 从用户消息中提取关键词匹配 Wiki 页面的标题和摘要
* 注入 top-3 相关页面的完整内容到 system prompt
*
* @param agentId Agent ID
* @param userMessage 用户当前消息
* @return 相关 Wiki 页面内容如果没有匹配则返回空字符串
* RFC-032: Uses HybridRetriever for consistent search quality,
* returns snippet + reason instead of just summary.
*/
public String buildRelevantContext(Long agentId, String userMessage) {
if (!properties.isEnabled() || userMessage == null || userMessage.isBlank()) {
@ -46,72 +42,46 @@ public class WikiContextService {
return "";
}
// 从用户消息中提取关键词简单分词按非字母数字中文分割过滤短词
String[] keywords = userMessage.toLowerCase()
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", " ")
.trim()
.split("\\s+");
if (keywords.length == 0 || (keywords.length == 1 && keywords[0].isBlank())) {
Long kbId = kbs.get(0).getId();
List<PageSearchResult> hits = hybridRetriever.search(kbId, userMessage, "hybrid", 5);
if (hits.isEmpty()) {
return "";
}
// 使用缓存的 listSummaries不加载 content按关键词评分
record ScoredPage(WikiPageEntity page, int score) {}
List<ScoredPage> scored = new ArrayList<>();
for (WikiKnowledgeBaseEntity kb : kbs) {
List<WikiPageEntity> pages = pageService.listSummaries(kb.getId()); // 走缓存
for (WikiPageEntity page : pages) {
String titleLower = page.getTitle() != null ? page.getTitle().toLowerCase() : "";
String summaryLower = page.getSummary() != null ? page.getSummary().toLowerCase() : "";
int score = 0;
for (String kw : keywords) {
if (kw.length() < 2) continue;
if (titleLower.contains(kw)) score += 3;
if (summaryLower.contains(kw)) score += 1;
}
if (score > 0) {
scored.add(new ScoredPage(page, score));
}
}
}
if (scored.isEmpty()) {
return "";
}
// top-5 最相关页面只注入摘要不注入全文 token 预算限制
scored.sort((a, b) -> Integer.compare(b.score, a.score));
int topN = Math.min(5, scored.size());
int maxChars = properties.getMaxContextChars();
int totalChars = 0;
StringBuilder sb = new StringBuilder();
sb.append("<wiki-relevant>\n");
StringBuilder sb = new StringBuilder("<wiki-relevant>\n");
sb.append("[Relevant wiki pages for this query. Use wiki_read_page(slug) for full content.]\n\n");
for (int i = 0; i < topN; i++) {
WikiPageEntity page = scored.get(i).page;
String line = "- **" + page.getTitle() + "** (`" + page.getSlug() + "`)";
if (page.getSummary() != null && !page.getSummary().isBlank()) {
line += "" + page.getSummary();
}
line += "\n";
if (totalChars + line.length() > maxChars) {
int totalChars = 0;
int maxChars = properties.getMaxContextChars();
for (PageSearchResult hit : hits) {
String entry = buildContextEntry(hit);
if (totalChars + entry.length() > maxChars) {
sb.append("- ... (use wiki_search_pages for more)\n");
break;
}
sb.append(line);
totalChars += line.length();
sb.append(entry);
totalChars += entry.length();
}
sb.append("</wiki-relevant>");
return sb.toString();
}
private String buildContextEntry(PageSearchResult hit) {
StringBuilder entry = new StringBuilder();
entry.append("- **[[").append(hit.slug()).append("]]** ").append(hit.title()).append("\n");
String excerpt = hit.snippet() != null ? hit.snippet() : hit.summary();
if (excerpt != null) {
entry.append(" ").append(excerpt).append("\n");
}
if (hit.reason() != null && !hit.reason().isBlank()) {
entry.append(" Relevance: ").append(hit.reason()).append("\n");
}
entry.append("\n");
return entry.toString();
}
/**
* 构建指定 Agent 关联的 Wiki 上下文
*
* @param agentId Agent ID
* @return Wiki 上下文字符串如果没有关联知识库或页面则返回空字符串
* Build full wiki context for agent system prompt.
*/
public String buildWikiContext(Long agentId) {
if (!properties.isEnabled()) {
@ -140,8 +110,6 @@ public class WikiContextService {
}
sb.append(" (").append(pages.size()).append(" pages)\n\n");
// KB20 保留 summary成本低且是唯一的语义线索
// KB>20 紧凑模式slug + title
boolean compact = pages.size() > 20;
for (WikiPageEntity page : pages) {

View File

@ -0,0 +1,120 @@
package vip.mate.wiki.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
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.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.job.WikiModelRoutingService;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.stream.Collectors;
/**
* RFC-031: Lightweight wikilink enrichment service.
* Adds [[slug]] cross-references to page content without modifying
* the actual text. Corresponds to llm_wiki's enrich-wikilinks.ts.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiLinkEnrichmentService {
private final WikiPageService pageService;
private final WikiModelRoutingService routingService;
private final WikiProperties wikiProperties;
private static final ExecutorService WIKI_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
/**
* Enrich a single page with [[wikilinks]].
*/
public void enrichPage(Long pageId, Long modelId) {
WikiPageEntity page = pageService.getById(pageId);
if (page == null || page.getContent() == null) return;
String index = buildIndexPrompt(page.getKbId());
ChatModel chatModel = routingService.buildChatModel(modelId);
String enriched = callEnrichLlm(chatModel, page.getContent(), index);
if (enriched != null
&& enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
page.setContent(enriched);
page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
pageService.updateById(page);
}
}
/**
* Batch-enrich all pages in a KB (e.g. after initial ingest).
*/
public void enrichAllPages(Long kbId, Long modelId) {
List<WikiPageEntity> pages = pageService.listByKbIdWithContent(kbId);
String index = buildIndexPrompt(kbId);
Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages());
for (WikiPageEntity page : pages) {
sem.acquireUninterruptibly();
WIKI_EXECUTOR.submit(() -> {
try {
enrichPageWithIndex(page, modelId, index);
} finally {
sem.release();
}
});
}
}
private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) {
ChatModel chatModel = routingService.buildChatModel(modelId);
String enriched = callEnrichLlm(chatModel, page.getContent(), index);
if (enriched != null
&& enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
page.setContent(enriched);
page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
pageService.updateById(page);
}
}
private String callEnrichLlm(ChatModel model, String content, String index) {
String systemPrompt = """
You are a wiki cross-referencing assistant.
Your ONLY job: add [[slug]] markers around entity and concept names that appear in the wiki index.
Rules:
- Do NOT change any content, rewrite sentences, or add new text.
- Do NOT modify YAML frontmatter.
- Only wrap existing words/phrases with [[ and ]].
- Use the exact slug from the wiki index (not the title).
- Return the COMPLETE page text with [[wikilinks]] added.
""";
String userPrompt = "Wiki Index (slug → title):\n" + index + "\n\nPage content:\n" + content;
try {
ChatResponse response = model.call(
new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
))
);
return response.getResult().getOutput().getText();
} catch (Exception e) {
log.warn("[WikiEnrich] Failed to enrich page: {}", e.getMessage());
return null;
}
}
private String buildIndexPrompt(Long kbId) {
return pageService.listSummaries(kbId).stream()
.map(p -> p.getSlug() + "" + p.getTitle())
.collect(Collectors.joining("\n"));
}
}

View File

@ -168,6 +168,17 @@ public class WikiPageService {
return pageMapper.selectById(id);
}
/**
* Direct update by entity (used by enrichment service).
*/
@Transactional
public void updateById(WikiPageEntity entity) {
pageMapper.updateById(entity);
if (entity.getKbId() != null) {
evictSummaryCache(entity.getKbId());
}
}
/**
* 创建新 Wiki 页面
*/

View File

@ -53,8 +53,13 @@ public class WikiProcessingService {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectMapper objectMapper;
private final WikiProgressBus progressBus;
private final WikiCitationService citationService;
/** 并行 chunk / 材料处理执行器JDK 21 虚拟线程Listener 跨包需要引用,故 public */
@org.springframework.beans.factory.annotation.Autowired(required = false)
@org.springframework.context.annotation.Lazy
private vip.mate.wiki.job.WikiProcessingJobService wikiJobService;
/** Parallel chunk / material processing executor (JDK 21 virtual threads) */
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
/**
@ -132,6 +137,15 @@ public class WikiProcessingService {
kbService.updateStatus(kb.getId(), "processing");
// RFC-030 §9.1: create a processing job record before starting
if (wikiJobService != null) {
try {
wikiJobService.createHeavyIngest(kb.getId(), rawId);
} catch (Exception e) {
log.warn("[Wiki] Failed to create heavy ingest job record for raw={}: {}", rawId, e.getMessage());
}
}
// RFC-012 M2 v2 UI v2为本次 raw 处理创建共享进度计数器 chunk 共享避免 race
progressCounters.put(rawId, new ProgressCounter());
rawService.updateProgress(rawId, "route", 0, 0); // UI 立即看到 indeterminate 滑条
@ -777,8 +791,10 @@ public class WikiProcessingService {
}
String sourceRawIds = "[" + rawId + "]";
try {
pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
// RFC-029: async citation build
citationService.buildCitationsAsync(created.getId(), kbId);
return true;
} catch (org.springframework.dao.DuplicateKeyException e) {
// 兜底 2select-then-create 在并发下不是原子操作 N chunk 同时
@ -846,8 +862,12 @@ public class WikiProcessingService {
log.warn("[Wiki] Phase B merge page slug='{}' returned blank content, skipping", slug);
return false;
}
pageService.updatePageByAi(kbId, slug, content, summary, rawId);
WikiPageEntity updated = pageService.updatePageByAi(kbId, slug, content, summary, rawId);
log.info("[Wiki] Phase B merge page slug='{}' done", slug);
// RFC-029: async citation rebuild
if (updated != null) {
citationService.buildCitationsAsync(updated.getId(), kbId);
}
return true;
}
@ -1129,11 +1149,129 @@ public class WikiProcessingService {
return cls + ": " + trimmed;
}
/** 瞬时错误的内部标记异常,确保空响应也能走重试路径 */
/** Transient error marker to route empty responses through the retry path */
private static class TransientLlmException extends RuntimeException {
TransientLlmException(String msg) { super(msg); }
}
// ==================== RFC-030: Error classification ====================
/**
* Classify an exception into an error code aligned with RFC-009 ErrorType.
*
* @return error code string: AUTH_ERROR, BILLING, MODEL_NOT_FOUND,
* RATE_LIMIT, SERVER_ERROR, TIMEOUT, CONTENT_FILTER, UNKNOWN
*/
public String classifyErrorCode(Throwable t) {
Throwable cur = t;
int depth = 0;
while (cur != null && depth < 8) {
String className = cur.getClass().getSimpleName();
if ("UnknownHostException".equals(className)
|| "SSLHandshakeException".equals(className)) {
return "AUTH_ERROR";
}
String msg = cur.getMessage();
if (msg != null) {
String m = msg.toLowerCase();
if (m.contains("401") || m.contains("unauthorized") || m.contains("403")
|| m.contains("forbidden") || m.contains("invalid api key")
|| m.contains("invalid_api_key") || m.contains("authentication")) {
return "AUTH_ERROR";
}
if (m.contains("quota") || m.contains("insufficient_quota") || m.contains("billing")) {
return "BILLING";
}
if (m.contains("model not found") || m.contains("model_not_found")) {
return "MODEL_NOT_FOUND";
}
if (m.contains("429") || m.contains("rate_limit") || m.contains("too many requests")) {
return "RATE_LIMIT";
}
if (m.contains("content_filter") || m.contains("content filter")
|| m.contains("data_inspection_failed")) {
return "CONTENT_FILTER";
}
if (m.contains("timeout") || m.contains("timed out")) {
return "TIMEOUT";
}
if (m.contains("500") || m.contains("502") || m.contains("503") || m.contains("504")) {
return "SERVER_ERROR";
}
}
cur = cur.getCause();
depth++;
}
return "UNKNOWN";
}
// ==================== RFC-031: Methods for template delegation ====================
/**
* Repair a single page by regenerating its content.
* Used by LocalRepairTemplate.
*/
public void repairSinglePage(Long targetPageId, Long modelId) {
WikiPageEntity page = pageService.getById(targetPageId);
if (page == null) {
log.warn("[Wiki] repairSinglePage: page not found: {}", targetPageId);
return;
}
WikiKnowledgeBaseEntity kb = kbService.getById(page.getKbId());
if (kb == null) return;
// Find source raw material
List<Long> rawIds = parseSourceRawIds(page.getSourceRawIds());
if (rawIds.isEmpty()) {
log.warn("[Wiki] repairSinglePage: no source raw IDs for page {}", targetPageId);
return;
}
WikiRawMaterialEntity raw = rawService.getById(rawIds.get(0));
if (raw == null) return;
String textContent = rawService.getTextContent(raw);
if (textContent == null || textContent.isBlank()) return;
// Use existing two-phase single-page create logic
String existingPagesIndex = buildExistingPagesIndex(kb.getId());
String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : "";
String createSystem = PromptLoader.loadPrompt("wiki/create-page-system");
String createUserTemplate = PromptLoader.loadPrompt("wiki/create-page-user");
String createUser = createUserTemplate
.replace("{config}", configContent)
.replace("{existing_pages}", existingPagesIndex)
.replace("{page_slug}", page.getSlug())
.replace("{page_title}", page.getTitle())
.replace("{page_summary}", page.getSummary() != null ? page.getSummary() : "")
.replace("{raw_title}", raw.getTitle())
.replace("{raw_content}", textContent);
Prompt prompt = new Prompt(List.of(
new SystemMessage(createSystem),
new UserMessage(createUser)
));
String response = callLlmWithResilientRetry(prompt, "repair page=" + page.getSlug());
com.fasterxml.jackson.databind.JsonNode pageJson = parseJsonResponse(response);
if (pageJson == null) return;
String content = pageJson.path("content").asText("");
String summary = pageJson.path("summary").asText("");
if (!content.isBlank()) {
pageService.updatePageByAi(kb.getId(), page.getSlug(), content, summary, rawIds.get(0));
log.info("[Wiki] Repaired page: {} (kbId={})", page.getSlug(), kb.getId());
}
}
private List<Long> parseSourceRawIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference<List<Long>>() {});
} catch (Exception e) {
return List.of();
}
}
private JsonNode parseJsonResponse(String response) {
if (response == null || response.isBlank()) return null;

View File

@ -0,0 +1,122 @@
package vip.mate.wiki.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.dto.*;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.relation.RelationSignalStrategy;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.repository.WikiPageMapper;
import java.util.*;
import java.util.stream.Collectors;
/**
* RFC-029: Wiki relation service computes multi-signal structural
* relevance between pages using all registered {@link RelationSignalStrategy} beans.
*/
@Slf4j
@Service
public class WikiRelationService {
private final List<RelationSignalStrategy> signals;
private final WikiPageMapper pageMapper;
private final WikiPageService pageService;
private final WikiPageCitationMapper citationMapper;
public WikiRelationService(List<RelationSignalStrategy> signals,
WikiPageMapper pageMapper,
WikiPageService pageService,
WikiPageCitationMapper citationMapper) {
this.signals = signals;
this.pageMapper = pageMapper;
this.pageService = pageService;
this.citationMapper = citationMapper;
}
/**
* Find pages related to a seed page, ranked by multi-signal score.
*/
public List<RelatedPageResult> relatedPages(Long kbId, String seedSlug, int topK) {
WikiPageEntity seed = pageService.getBySlug(kbId, seedSlug);
if (seed == null) return List.of();
Map<Long, Double> totalScores = new HashMap<>();
Map<Long, List<String>> signalHits = new HashMap<>();
for (RelationSignalStrategy signal : signals) {
try {
signal.score(seed.getId(), kbId).forEach((pid, s) -> {
totalScores.merge(pid, s, Double::sum);
signalHits.computeIfAbsent(pid, k -> new ArrayList<>()).add(signal.signalName());
});
} catch (Exception e) {
log.warn("[WikiRelation] Signal '{}' failed for seed={}: {}",
signal.signalName(), seedSlug, e.getMessage());
}
}
List<Long> topIds = totalScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(topK)
.map(Map.Entry::getKey)
.toList();
if (topIds.isEmpty()) return List.of();
Map<Long, WikiPageLite> liteMap = pageMapper.selectBatchLite(topIds)
.stream().collect(Collectors.toMap(WikiPageLite::id, l -> l));
return topIds.stream()
.filter(liteMap::containsKey)
.map(pid -> new RelatedPageResult(
liteMap.get(pid).slug(),
liteMap.get(pid).title(),
liteMap.get(pid).summary(),
totalScores.get(pid),
signalHits.getOrDefault(pid, List.of())))
.toList();
}
/**
* Explain the relation between two pages with a per-signal breakdown.
*/
public RelationExplanation explain(Long kbId, String slugA, String slugB) {
WikiPageEntity a = pageService.getBySlug(kbId, slugA);
WikiPageEntity b = pageService.getBySlug(kbId, slugB);
if (a == null || b == null) return RelationExplanation.notFound();
List<SignalScore> breakdown = new ArrayList<>();
double total = 0;
for (RelationSignalStrategy signal : signals) {
try {
Double score = signal.score(a.getId(), kbId).get(b.getId());
if (score != null && score > 0) {
breakdown.add(new SignalScore(signal.signalName(), signal.weight(), score));
total += score;
}
} catch (Exception e) {
log.warn("[WikiRelation] Signal '{}' failed for explain {}<->{}: {}",
signal.signalName(), slugA, slugB, e.getMessage());
}
}
return new RelationExplanation(slugA, slugB, total, breakdown);
}
/**
* Find all pages derived from a given raw material.
*/
public List<WikiPageLite> pagesByRawId(Long rawId) {
List<Long> pageIds = citationMapper.listPageIdsByRawId(rawId);
if (pageIds.isEmpty()) return List.of();
return pageMapper.selectBatchLite(pageIds);
}
/**
* Find all pages that cite a given chunk.
*/
public List<WikiPageLite> pagesByChunkId(Long chunkId) {
List<Long> pageIds = citationMapper.listPageIdsByChunkId(chunkId);
if (pageIds.isEmpty()) return List.of();
return pageMapper.selectBatchLite(pageIds);
}
}

View File

@ -5,47 +5,79 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.wiki.dto.*;
import vip.mate.wiki.job.WikiProcessingJobService;
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.service.HybridRetriever;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import vip.mate.wiki.service.WikiRawMaterialService;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import vip.mate.wiki.service.*;
import java.util.List;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Wiki 知识库工具
* Wiki knowledge base tools for agent conversations.
* <p>
* Agent 在对话中按需读取 Wiki 页面内容
* kbId 通过 agentId 自动解析LLM 无需传递
* All tools auto-resolve kbId from agentId; LLM never needs to pass it directly.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WikiTool {
private final WikiPageService pageService;
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final HybridRetriever hybridRetriever;
private final ObjectMapper objectMapper;
@Autowired(required = false)
private WikiRelationService relationService;
@Autowired(required = false)
private WikiProcessingJobService jobService;
@Autowired(required = false)
private ApplicationEventPublisher eventPublisher;
@Autowired(required = false)
private WikiRawMaterialMapper rawMaterialMapper;
public WikiTool(WikiPageService pageService,
WikiKnowledgeBaseService kbService,
WikiRawMaterialService rawService,
HybridRetriever hybridRetriever,
ObjectMapper objectMapper) {
this.pageService = pageService;
this.kbService = kbService;
this.rawService = rawService;
this.hybridRetriever = hybridRetriever;
this.objectMapper = objectMapper;
}
// ==================== RFC-032: Enhanced wiki_read_page ====================
@Tool(description = """
读取 Wiki 知识库中指定页面的完整内容
当系统提示词中的 Wiki 页面摘要不够详细时使用此工具获取完整内容
返回 Markdown 格式的页面内容包含 [[双向链接]] 和来源原始文件信息
Read a wiki page. Use maxChars to limit size (recommended: 3000-6000 for most tasks).
Use sectionHeading to read only one section by its heading text.
""")
public String wiki_read_page(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "页面标识符 (slug)") String slug) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug,
@ToolParam(description = "Max characters to return (null = full page)", required = false) Integer maxChars,
@ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
@ -61,38 +93,66 @@ public class WikiTool {
return error("Page not found: " + slug);
}
// Agent 引用追踪
pageService.trackReference(kbId, slug);
String content = page.getContent();
if (sectionHeading != null && !sectionHeading.isBlank()) {
content = extractSection(content, sectionHeading);
}
if (maxChars != null && maxChars > 0) {
content = applyMaxChars(content, maxChars);
}
JSONObject result = JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("version", page.getVersion())
.set("lastUpdatedBy", page.getLastUpdatedBy())
.set("content", page.getContent())
.set("content", content)
.set("sourceFiles", resolveSourceFiles(page.getSourceRawIds()));
return result.toString();
}
// ==================== RFC-032: Enhanced wiki_list_pages ====================
@Tool(description = """
列出 Wiki 知识库中的所有页面
返回页面列表包含标题slug 和摘要
List wiki pages. Add query to filter by title keyword (max 30 results).
Without query returns all pages (use only for small KBs).
""")
public String wiki_list_pages(
@ToolParam(description = "当前 Agent 的 ID") Long agentId) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Title keyword filter (optional)", required = false) String query) {
Long kbId = resolveKbId(agentId);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
}
List<WikiPageEntity> pages = pageService.listSummaries(kbId);
List<WikiPageLite> pages;
if (query != null && !query.isBlank()) {
List<Long> ids = pageService.searchPages(kbId, query).stream()
.map(WikiPageEntity::getId).limit(30).toList();
if (ids.isEmpty()) {
pages = List.of();
} else {
pages = pageService.listSummaries(kbId).stream()
.filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId())))
.map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary()))
.toList();
}
} else {
pages = pageService.listSummaries(kbId).stream()
.map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary()))
.toList();
}
JSONArray arr = new JSONArray();
for (WikiPageEntity page : pages) {
for (WikiPageLite page : pages) {
arr.add(JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("summary", page.getSummary()));
.set("slug", page.slug())
.set("title", page.title())
.set("summary", page.summary()));
}
return JSONUtil.createObj()
@ -102,15 +162,17 @@ public class WikiTool {
.toString();
}
// ==================== RFC-032: Enhanced wiki_search_pages ====================
@Tool(description = """
Wiki 知识库中搜索页面
支持三种模式keyword关键词匹配semantic语义向量相似度hybrid两者融合默认
返回匹配的页面列表及其来源文件
Search wiki pages. Returns snippet so you can judge relevance without reading the full page.
Default topK=5 is sufficient for most queries.
""")
public String wiki_search_pages(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "搜索关键词或自然语言问题") String query,
@ToolParam(description = "搜索模式keyword | semantic | hybrid默认 hybrid", required = false) String mode) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Search query") String query,
@ToolParam(description = "Mode: keyword|semantic|hybrid (default: hybrid)", required = false) String mode,
@ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK) {
if (query == null || query.isBlank()) {
return error("query is required");
@ -121,41 +183,44 @@ public class WikiTool {
return error("No wiki knowledge base found for this agent");
}
// RFC-011走混合检索
List<HybridRetriever.PageHit> hits = hybridRetriever.searchPages(kbId, query, mode, 20);
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
List<PageSearchResult> results = hybridRetriever.search(kbId, query, mode, k);
// Agent 引用追踪
for (HybridRetriever.PageHit h : hits) {
pageService.trackReference(kbId, h.slug());
for (PageSearchResult r : results) {
pageService.trackReference(kbId, r.slug());
}
JSONArray arr = new JSONArray();
for (HybridRetriever.PageHit hit : hits) {
for (PageSearchResult r : results) {
arr.add(JSONUtil.createObj()
.set("title", hit.title())
.set("slug", hit.slug())
.set("summary", hit.summary())
.set("score", String.format("%.4f", hit.score())));
.set("slug", r.slug())
.set("title", r.title())
.set("snippet", r.snippet() != null ? r.snippet() : r.summary())
.set("matchedBy", r.matchedBy())
.set("reason", r.reason() != null ? r.reason() : "")
.set("score", String.format("%.4f", r.score())));
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("query", query)
.set("mode", mode != null ? mode : "hybrid")
.set("matchCount", hits.size())
.set("matchCount", results.size())
.set("pages", arr)
.toString();
}
// ==================== RFC-032: N+1 fixed wiki_semantic_search ====================
@Tool(description = """
Wiki 知识库中进行 chunk 级语义搜索
返回与查询语义最接近的原始文本片段chunk包含相似度分数
wiki_search_pages 返回的页面摘要不够具体时使用此工具获取精确的源文本证据
Chunk-level semantic search in the wiki knowledge base.
Returns raw text fragments closest to the query with similarity scores.
Use when wiki_search_pages results are not specific enough.
""")
public String wiki_semantic_search(
@ToolParam(description = "当前 Agent ID") Long agentId,
@ToolParam(description = "自然语言查询") String query,
@ToolParam(description = "返回条数(默认 5", required = false) Integer topK) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Natural language query") String query,
@ToolParam(description = "Max results (default 5)", required = false) Integer topK) {
if (query == null || query.isBlank()) {
return error("query is required");
@ -178,13 +243,21 @@ public class WikiTool {
.toString();
}
// RFC-032: Batch-fetch raw titles (N+1 fix)
Set<Long> rawIds = hits.stream().map(HybridRetriever.ChunkHit::rawId).collect(Collectors.toSet());
Map<Long, String> rawTitles;
if (rawMaterialMapper != null && !rawIds.isEmpty()) {
rawTitles = rawMaterialMapper.selectBatchTitles(rawIds)
.stream().collect(Collectors.toMap(RawTitleRef::id, RawTitleRef::title));
} else {
rawTitles = Map.of();
}
JSONArray arr = new JSONArray();
for (HybridRetriever.ChunkHit hit : hits) {
// 解析 raw material 标题
WikiRawMaterialEntity raw = rawService.getById(hit.rawId());
arr.add(JSONUtil.createObj()
.set("chunkId", hit.chunkId())
.set("rawTitle", raw != null ? raw.getTitle() : "unknown")
.set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown"))
.set("snippet", hit.snippet())
.set("score", String.format("%.4f", hit.score())));
}
@ -198,13 +271,12 @@ public class WikiTool {
}
@Tool(description = """
追溯 Wiki 页面的来源原始文件
查询指定页面是由哪些原始文档生成的返回文件名类型路径等信息
用于回答"这个内容出自哪篇文档"类的问题
Trace the source raw materials for a wiki page.
Returns file names, types, and paths of the original documents.
""")
public String wiki_trace_source(
@ToolParam(description = "当前 Agent ID") Long agentId,
@ToolParam(description = "页面标识符 (slug)") String slug) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
@ -228,14 +300,13 @@ public class WikiTool {
}
@Tool(description = """
Wiki 知识库中创建新页面
用于保存任务执行结果分析报告会议纪要等有价值的信息
内容使用 Markdown 格式页面标识符 (slug) 会从标题自动生成
Create a new wiki page. Used to save task results, analysis reports, etc.
Content should be Markdown. Slug is auto-generated from title.
""")
public String wiki_create_page(
@ToolParam(description = "当前 Agent ID") Long agentId,
@ToolParam(description = "页面标题") String title,
@ToolParam(description = "页面内容 (Markdown 格式)") String content) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page title") String title,
@ToolParam(description = "Page content (Markdown)") String content) {
if (title == null || title.isBlank()) {
return error("title is required");
@ -249,7 +320,6 @@ public class WikiTool {
return error("No wiki knowledge base found for this agent. Create one first.");
}
// 从标题生成 slug
String slug = title.toLowerCase()
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-")
.replaceAll("^-|-$", "");
@ -257,17 +327,13 @@ public class WikiTool {
slug = "page-" + System.currentTimeMillis();
}
// 检查 slug 是否已存在
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing != null) {
slug = slug + "-" + System.currentTimeMillis() % 10000;
}
// 生成摘要取前 200 字符
String summary = content.length() > 200 ? content.substring(0, 200) + "..." : content;
WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null);
log.info("[WikiTool] Created page: {} (slug={}, kbId={})", title, slug, kbId);
return JSONUtil.createObj()
@ -280,12 +346,11 @@ public class WikiTool {
}
@Tool(description = """
删除一个 AI 生成的 Wiki 页面无法删除人工维护的页面lastUpdatedBy = 'manual'
用于清理过时冗余或不准确的 Wiki 页面
Delete an AI-generated wiki page. Cannot delete manually curated pages.
""")
public String wiki_delete_page(
@ToolParam(description = "当前 Agent ID") Long agentId,
@ToolParam(description = "要删除的页面 slug") String slug) {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug to delete") String slug) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
@ -301,7 +366,6 @@ public class WikiTool {
return error("Page not found: " + slug);
}
// 安全保护禁止删除人工维护的页面
if ("manual".equals(page.getLastUpdatedBy())) {
return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI.");
}
@ -317,24 +381,104 @@ public class WikiTool {
.toString();
}
/**
* 通过 agentId 自动解析关联的知识库 ID
* <p>
* 查找逻辑Agent 专属 KB + 公共 KBagent_id IS NULL取第一个
*/
// ==================== RFC-029: Relation tools ====================
@Tool(description = """
Find pages structurally related to a given page (shared sources, links,
semantic similarity). More reliable than keyword search for discovering connected knowledge.
""")
public String wiki_related_pages(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug,
@ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
if (relationService == null) return error("Relation service not available");
int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
List<RelatedPageResult> results = relationService.relatedPages(kbId, slug, k);
JSONArray arr = new JSONArray();
for (RelatedPageResult r : results) {
arr.add(JSONUtil.createObj()
.set("slug", r.slug())
.set("title", r.title())
.set("score", String.format("%.2f", r.score()))
.set("signals", r.signals()));
}
return JSONUtil.createObj()
.set("slug", slug)
.set("relatedCount", results.size())
.set("pages", arr)
.toString();
}
@Tool(description = """
Explain why two wiki pages are related. Returns signal breakdown with scores.
""")
public String wiki_explain_relation(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "First page slug") String slugA,
@ToolParam(description = "Second page slug") String slugB) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
if (relationService == null) return error("Relation service not available");
RelationExplanation ex = relationService.explain(kbId, slugA, slugB);
if (ex.breakdown().isEmpty()) return slugA + " and " + slugB + " have no detected relation.";
StringBuilder sb = new StringBuilder("Relation score: ")
.append(String.format("%.2f", ex.totalScore())).append("\n");
ex.breakdown().forEach(s -> sb.append(" ").append(s.signal())
.append(": ").append(String.format("%.2f", s.score())).append("\n"));
return sb.toString();
}
// ==================== RFC-031: Enrichment tool ====================
@Tool(description = """
Trigger lightweight wikilink enrichment for a specific page.
Does NOT regenerate content only adds [[wikilink]] cross-references.
""")
public String wiki_enrich_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
if (jobService == null || eventPublisher == null) return error("Job service not available");
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) return error("Page not found: " + slug);
Long rawId = 0L;
try {
List<Long> rawIds = objectMapper.readValue(
page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]",
new TypeReference<List<Long>>() {});
if (!rawIds.isEmpty()) rawId = rawIds.get(0);
} catch (Exception ignored) {}
WikiProcessingJobEntity job = jobService.createLightEnrich(kbId, rawId);
eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId()));
return "Wikilink enrichment queued for: " + slug;
}
// ==================== Helpers ====================
private Long resolveKbId(Long agentId) {
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
return kbs.isEmpty() ? null : kbs.get(0).getId();
}
/**
* sourceRawIds JSON 数组解析为原始文件信息列表
*/
private JSONArray resolveSourceFiles(String sourceRawIdsJson) {
JSONArray result = new JSONArray();
if (sourceRawIdsJson == null || sourceRawIdsJson.isBlank()) return result;
try {
List<Long> rawIds = new ObjectMapper().readValue(sourceRawIdsJson, new TypeReference<List<Long>>() {});
List<Long> rawIds = objectMapper.readValue(sourceRawIdsJson, new TypeReference<List<Long>>() {});
for (Long rawId : rawIds) {
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw != null) {
@ -351,6 +495,30 @@ public class WikiTool {
return result;
}
/**
* RFC-032: Extract a section from markdown content by heading text.
*/
private String extractSection(String content, String heading) {
if (content == null) return "";
String escaped = Pattern.quote(heading.trim());
Pattern p = Pattern.compile(
"(?m)^(#{1,3})\\s+" + escaped + "\\b.*?(?=^#{1,3}\\s|\\Z)",
Pattern.DOTALL | Pattern.MULTILINE
);
Matcher m = p.matcher(content);
return m.find() ? m.group().strip() : content;
}
/**
* RFC-032: Truncate content with a helpful message.
*/
private String applyMaxChars(String text, int maxChars) {
if (text == null || text.length() <= maxChars) return text;
return text.substring(0, maxChars)
+ "\n\n[Content truncated at " + maxChars + " chars. "
+ "Use sectionHeading param to read a specific section.]";
}
private String error(String message) {
return JSONUtil.createObj().set("error", message).toString();
}

View File

@ -133,7 +133,7 @@ mateclaw:
enabled: true # 自适应降级(连续 miss 后短路到 NoOp冷却后恢复
miss-threshold: 5
cool-down-ms: 60000
# per-provider health tracker for the multi-model failover chain.
# RFC-009 P3.3: per-provider health tracker for the multi-model failover chain.
# When a provider hits failure-threshold consecutive failures it enters a cooldown
# window during which the chain walker skips it, avoiding repeated 5-retry stalls
# against a known-broken provider on every conversation turn.
@ -160,7 +160,7 @@ mate:
per-category:
shell: 120
web: 30
# RFC-008 tool-result three-layer budget (per-result spill + per-turn aggregate budget).
# RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget).
# Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized
# single results to disk; Layer 3 enforces an aggregate cap on the combined
# response size of one tool turn. The full output is preserved on disk and

View File

@ -1,4 +1,4 @@
-- ordered multi-provider fallback chain
-- RFC-009 Phase 1: ordered multi-provider fallback chain
--
-- `fallback_priority` defines the order in which a provider is tried after the
-- primary model exhausts retries:

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS mate_wiki_page_citation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
page_id BIGINT NOT NULL,
chunk_id BIGINT NOT NULL,
paragraph_idx INT NOT NULL DEFAULT 0,
anchor_text VARCHAR(512),
confidence DECIMAL(4,3) NOT NULL DEFAULT 1.000,
created_by VARCHAR(32) NOT NULL DEFAULT 'system',
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_wpc_page ON mate_wiki_page_citation (page_id);
CREATE INDEX IF NOT EXISTS idx_wpc_chunk ON mate_wiki_page_citation (chunk_id);
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS page_type VARCHAR(32) NOT NULL DEFAULT 'concept';
ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS purpose_hint TEXT;

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS mate_wiki_processing_job (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
kb_id BIGINT NOT NULL,
raw_id BIGINT NOT NULL,
job_type VARCHAR(32) NOT NULL DEFAULT 'heavy_ingest',
stage VARCHAR(64) NOT NULL DEFAULT 'queued',
status VARCHAR(32) NOT NULL DEFAULT 'queued',
primary_model_id BIGINT,
current_model_id BIGINT,
fallback_chain_json TEXT,
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
error_code VARCHAR(64),
error_message TEXT,
resume_from_stage VARCHAR(64),
meta_json TEXT,
started_at DATETIME(3),
finished_at DATETIME(3),
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_wpj_raw ON mate_wiki_processing_job (raw_id);
CREATE INDEX IF NOT EXISTS idx_wpj_status ON mate_wiki_processing_job (status);
CREATE INDEX IF NOT EXISTS idx_wpj_kb ON mate_wiki_processing_job (kb_id, status);

View File

@ -0,0 +1,26 @@
-- RFC-009 Phase 4 PR-3: per-agent provider preferences
--
-- Lets each agent declare an ordered list of preferred provider ids. Empty
-- table for an agent (no rows) means "use the global fallback chain order"
-- — fully backwards compatible with pre-PR-3 behavior. When rows exist,
-- listed providers are tried in ascending sort_order before any non-listed
-- provider is considered.
--
-- This is purely a routing hint. The runtime walker still gates each entry
-- through AvailableProviderPool / ProviderHealthTracker — a preferred
-- provider that is HARD-removed or in cooldown is still skipped.
CREATE TABLE IF NOT EXISTS mate_agent_provider_preference (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
provider_id VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider
ON mate_agent_provider_preference(agent_id, provider_id);
CREATE INDEX IF NOT EXISTS idx_agent_provider_order
ON mate_agent_provider_preference(agent_id, sort_order);

View File

@ -1,4 +1,4 @@
-- ordered multi-provider fallback chain
-- RFC-009 Phase 1: ordered multi-provider fallback chain
--
-- `fallback_priority` defines the order in which a provider is tried after the
-- primary model exhausts retries:

View File

@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS mate_wiki_page_citation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
page_id BIGINT NOT NULL,
chunk_id BIGINT NOT NULL,
paragraph_idx INT NOT NULL DEFAULT 0,
anchor_text VARCHAR(512),
confidence DECIMAL(4,3) NOT NULL DEFAULT 1.000,
created_by VARCHAR(32) NOT NULL DEFAULT 'system',
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_wpc_page (page_id),
INDEX idx_wpc_chunk (chunk_id)
);
SET @c1 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
AND COLUMN_NAME = 'page_type');
SET @s1 = IF(@c1 = 0,
'ALTER TABLE mate_wiki_page ADD COLUMN page_type VARCHAR(32) NOT NULL DEFAULT ''concept''',
'SELECT 1');
PREPARE p1 FROM @s1; EXECUTE p1; DEALLOCATE PREPARE p1;
SET @c2 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
AND COLUMN_NAME = 'purpose_hint');
SET @s2 = IF(@c2 = 0,
'ALTER TABLE mate_wiki_page ADD COLUMN purpose_hint TEXT',
'SELECT 1');
PREPARE p2 FROM @s2; EXECUTE p2; DEALLOCATE PREPARE p2;

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS mate_wiki_processing_job (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
kb_id BIGINT NOT NULL,
raw_id BIGINT NOT NULL,
job_type VARCHAR(32) NOT NULL DEFAULT 'heavy_ingest',
stage VARCHAR(64) NOT NULL DEFAULT 'queued',
status VARCHAR(32) NOT NULL DEFAULT 'queued',
primary_model_id BIGINT,
current_model_id BIGINT,
fallback_chain_json TEXT,
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
error_code VARCHAR(64),
error_message TEXT,
resume_from_stage VARCHAR(64),
meta_json TEXT,
started_at DATETIME(3),
finished_at DATETIME(3),
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_wpj_raw (raw_id),
INDEX idx_wpj_status (status),
INDEX idx_wpj_kb (kb_id, status)
);

View File

@ -0,0 +1,24 @@
-- RFC-009 Phase 4 PR-3: per-agent provider preferences
--
-- Lets each agent declare an ordered list of preferred provider ids. Empty
-- table for an agent (no rows) means "use the global fallback chain order"
-- — fully backwards compatible with pre-PR-3 behavior. When rows exist,
-- listed providers are tried in ascending sort_order before any non-listed
-- provider is considered.
--
-- This is purely a routing hint. The runtime walker still gates each entry
-- through AvailableProviderPool / ProviderHealthTracker — a preferred
-- provider that is HARD-removed or in cooldown is still skipped.
CREATE TABLE IF NOT EXISTS mate_agent_provider_preference (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
provider_id VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT(1) NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_agent_provider (agent_id, provider_id),
KEY idx_agent_provider_order (agent_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,78 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.llm.model.ModelProviderEntity;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* RFC-009 PR-3 verifies the agent-preference reorder used by
* {@link AgentGraphBuilder#buildFallbackChain}: listed providers move to the
* front in their declared order; unlisted providers keep their original
* relative order; missing/duplicate preferences are ignored gracefully.
*/
class AgentGraphBuilderPreferenceTest {
private static ModelProviderEntity p(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
return p;
}
private static List<String> ids(List<ModelProviderEntity> ps) {
return ps.stream().map(ModelProviderEntity::getProviderId).toList();
}
@Test
@DisplayName("Empty preferences: original order preserved")
void noPreferences() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of());
assertEquals(List.of("openai", "anthropic", "dashscope"), ids(out));
}
@Test
@DisplayName("Single preference: preferred provider moves to front, rest follow original order")
void singlePreferenceFront() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("dashscope"));
assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out));
}
@Test
@DisplayName("Multiple preferences: preferred order matches declaration, rest stable")
void multiplePreferencesOrder() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"), p("kimi"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("kimi", "anthropic"));
// kimi anthropic (rest in original order: openai, dashscope)
assertEquals(List.of("kimi", "anthropic", "openai", "dashscope"), ids(out));
}
@Test
@DisplayName("Preference references unknown provider: silently skipped")
void preferenceReferencesUnknown() {
var input = List.of(p("openai"), p("anthropic"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("ghost", "anthropic"));
assertEquals(List.of("anthropic", "openai"), ids(out));
}
@Test
@DisplayName("Duplicate preferences: each provider appears at most once")
void duplicatePreferencesDeduped() {
var input = List.of(p("openai"), p("anthropic"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("openai", "openai", "anthropic"));
assertEquals(List.of("openai", "anthropic"), ids(out));
}
@Test
@DisplayName("All providers preferred: input pure-reordered, no drops")
void allProvidersPreferred() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
var out = AgentGraphBuilder.reorderByPreferences(input,
List.of("dashscope", "openai", "anthropic"));
assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out));
}
}

View File

@ -8,7 +8,7 @@ import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* classification tests for the new error types
* RFC-009 P3.2: classification tests for the new error types
* ({@link NodeStreamingChatHelper.ErrorType#BILLING},
* {@link NodeStreamingChatHelper.ErrorType#MODEL_NOT_FOUND}).
*

View File

@ -153,7 +153,7 @@ class NodeStreamingChatHelperFailoverTest {
// ============================================================
@Test
@DisplayName("C4 (regression): primary BILLING still triggers fallback (unchanged P3.2)")
@DisplayName("C4 (regression): primary BILLING still triggers fallback (unchanged from RFC-009 P3.2)")
void billingStillFallsBack() {
ChatModel primary = errorModel(new RuntimeException("402 Payment Required: insufficient_quota"));
ChatModel fallback = successModel("recovered via fallback");

View File

@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
/**
* smoke tests for the multi-model fallback chain wiring on
* RFC-009: smoke tests for the multi-model fallback chain wiring on
* {@link NodeStreamingChatHelper}.
*
* <p>Full streaming-flow integration (ChatModel.stream / Flux mocking) is left
@ -88,7 +88,7 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
@DisplayName("EMPTY_RESPONSE / BILLING / MODEL_NOT_FOUND error types exist ")
@DisplayName("EMPTY_RESPONSE / BILLING / MODEL_NOT_FOUND error types exist (RFC-009 fallback triggers)")
void fallbackTriggerErrorTypesExist() {
// Compile-time safety net: these enum constants the streaming pipeline relies on
// must not be renamed or removed without breaking the fallback contract.
@ -98,7 +98,7 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
@DisplayName("primary providerId is stored when supplied via the full constructor")
@DisplayName("RFC-009 P3.1: primary providerId is stored when supplied via the full constructor")
void primaryProviderIdStored() throws Exception {
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(
streamTracker, List.of(), null, null, "openai");
@ -110,7 +110,7 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
@DisplayName("legacy constructors leave primaryProviderId null (tracking disabled)")
@DisplayName("RFC-009 P3.1: legacy constructors leave primaryProviderId null (tracking disabled)")
void primaryProviderIdNullForLegacyConstructors() throws Exception {
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker);
Field f = NodeStreamingChatHelper.class.getDeclaredField("primaryProviderId");

View File

@ -0,0 +1,269 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
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.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.failover.AvailableProviderPool.RemovalSource;
import vip.mate.llm.failover.FallbackEntry;
import vip.mate.llm.failover.ProviderHealthProperties;
import vip.mate.llm.failover.ProviderHealthTracker;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* RFC-009 Phase 4 verifies the three pool hooks wired into
* {@link NodeStreamingChatHelper}:
* <ol>
* <li>Primary short-circuit when its provider id is not in the pool
* primary is never even called, fallback runs first.</li>
* <li>Walker head filter out-of-pool fallback entries are skipped.</li>
* <li>HARD error {@code pool.remove}; SOFT error pool unchanged.</li>
* </ol>
*
* <p>Pool state must remain consistent across these three behaviors so a
* single misconfigured provider can't pollute every conversation turn.</p>
*/
class NodeStreamingChatHelperPoolTest {
private ChatStreamTracker streamTracker;
private ProviderHealthTracker healthTracker;
private AvailableProviderPool pool;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
when(streamTracker.isStopRequested(any())).thenReturn(false);
healthTracker = new ProviderHealthTracker(new ProviderHealthProperties());
pool = new AvailableProviderPool();
}
private static ChatModel successModel(String text) {
ChatModel m = mock(ChatModel.class);
Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
private static ChatModel errorModel(Throwable err) {
ChatModel m = mock(ChatModel.class);
when(m.stream(any(Prompt.class))).thenReturn(Flux.error(err));
return m;
}
/** Stream a single chunk with empty text and no tool calls — triggers EMPTY_RESPONSE (SOFT). */
private static ChatModel emptyResponseModel() {
ChatModel m = mock(ChatModel.class);
Generation gen = new Generation(new AssistantMessage(""), ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
private NodeStreamingChatHelper helper(List<FallbackEntry> chain, String primary) {
return new NodeStreamingChatHelper(streamTracker, chain, null, healthTracker, primary, pool);
}
private static Prompt smallPrompt() {
return new Prompt(List.of(new UserMessage("hi")));
}
// ============================================================
// Hook 1: primary out-of-pool short-circuits the retry loop
// ============================================================
@Test
@DisplayName("Primary not in pool: skipped without being called, fallback wins")
void primaryOutOfPoolShortCircuits() {
// openai is HARD-removed from pool before the call
pool.add("dashscope");
pool.remove("openai", RemovalSource.AUTH_ERROR, "stale 401");
ChatModel primary = successModel("primary should never be called");
ChatModel fallback = successModel("fallback wins");
var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
var result = helper.streamCall(primary, smallPrompt(), "conv-h1", "reasoning");
assertEquals("fallback wins", result.text());
verify(primary, never()).stream(any(Prompt.class));
verify(fallback, times(1)).stream(any(Prompt.class));
}
// ============================================================
// Hook 2: walker skips out-of-pool fallback entries
// ============================================================
@Test
@DisplayName("Walker skips out-of-pool fallback and lands on the next eligible one")
void walkerSkipsOutOfPoolFallback() {
pool.add("openai"); // primary
pool.remove("anthropic", RemovalSource.BILLING, "402"); // first fallback dead
pool.add("dashscope"); // second fallback alive
// Use AUTH_ERROR (HARD) on primary triggers the immediate break-to-walker
// path. Picking SERVER_ERROR would burn 5 retries (~110s) and then exit
// without ever hitting the walker, which is unrelated to the property
// under test here.
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized"));
ChatModel fbAnthropic = successModel("should be skipped");
ChatModel fbDashscope = successModel("dashscope wins");
var helper = helper(List.of(
new FallbackEntry("anthropic", fbAnthropic),
new FallbackEntry("dashscope", fbDashscope)), "openai");
var result = helper.streamCall(primary, smallPrompt(), "conv-h2", "reasoning");
assertEquals("dashscope wins", result.text());
verify(fbAnthropic, never()).stream(any(Prompt.class));
verify(fbDashscope, times(1)).stream(any(Prompt.class));
}
// ============================================================
// Hook 3a: primary HARD error evicts from pool
// ============================================================
@Test
@DisplayName("Primary AUTH_ERROR HARD-removes openai from pool with AUTH_ERROR source")
void primaryAuthErrorEvictsFromPool() {
pool.add("openai");
pool.add("dashscope");
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized: bad key"));
ChatModel fallback = successModel("recovered");
var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
helper.streamCall(primary, smallPrompt(), "conv-h3a", "reasoning");
assertFalse(pool.contains("openai"), "openai must be removed from pool after AUTH_ERROR");
var reason = pool.snapshot().get("openai");
assertNotNull(reason);
assertEquals(RemovalSource.AUTH_ERROR, reason.source());
assertTrue(pool.contains("dashscope"), "successful fallback stays in pool");
}
@Test
@DisplayName("Primary BILLING HARD-removes with BILLING source (distinct from AUTH)")
void primaryBillingEvictsWithBillingSource() {
pool.add("openai");
pool.add("dashscope");
ChatModel primary = errorModel(new RuntimeException("402 Payment Required: insufficient_quota"));
ChatModel fallback = successModel("ok");
var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
helper.streamCall(primary, smallPrompt(), "conv-h3b", "reasoning");
assertFalse(pool.contains("openai"));
assertEquals(RemovalSource.BILLING, pool.snapshot().get("openai").source());
}
@Test
@DisplayName("Primary MODEL_NOT_FOUND HARD-removes with MODEL_NOT_FOUND source")
void primaryModelNotFoundEvictsWithCorrectSource() {
pool.add("openai");
pool.add("dashscope");
ChatModel primary = errorModel(new RuntimeException("404 model_not_found: gpt-99"));
ChatModel fallback = successModel("ok");
var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
helper.streamCall(primary, smallPrompt(), "conv-h3c", "reasoning");
assertFalse(pool.contains("openai"));
assertEquals(RemovalSource.MODEL_NOT_FOUND, pool.snapshot().get("openai").source());
}
// ============================================================
// Hook 3b: SOFT errors do NOT evict from pool
// ============================================================
@Test
@DisplayName("Primary EMPTY_RESPONSE (SOFT) keeps provider in pool, only records failure")
void primarySoftErrorKeepsInPool() {
pool.add("openai");
pool.add("dashscope");
// EMPTY_RESPONSE is SOFT and breaks straight to fallback (no 5x retry)
// keeps the test fast while still exercising the SOFT path.
ChatModel primary = emptyResponseModel();
ChatModel fallback = successModel("ok");
var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
helper.streamCall(primary, smallPrompt(), "conv-h3d", "reasoning");
assertTrue(pool.contains("openai"),
"SOFT errors must NOT evict — health tracker cooldown handles transient blips");
assertTrue(healthTracker.snapshot().get("openai").consecutiveFailures() > 0,
"SOFT failure must still be recorded by the health tracker");
}
// ============================================================
// Hook 3c: fallback HARD errors also evict
// ============================================================
@Test
@DisplayName("Fallback AUTH_ERROR evicts the fallback provider and walker continues")
void fallbackHardErrorEvictsFallback() {
pool.add("openai");
pool.add("anthropic");
pool.add("dashscope");
// Use AUTH on primary so we reach the walker without burning 5 retries.
// The behavior under test is fallback eviction, not the primary path.
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized: openai key"));
ChatModel fbBad = errorModel(new RuntimeException("401 Unauthorized: anthropic key"));
ChatModel fbGood = successModel("dashscope ok");
var helper = helper(List.of(
new FallbackEntry("anthropic", fbBad),
new FallbackEntry("dashscope", fbGood)), "openai");
var result = helper.streamCall(primary, smallPrompt(), "conv-h3e", "reasoning");
assertEquals("dashscope ok", result.text());
assertFalse(pool.contains("anthropic"), "fallback that failed AUTH must be evicted");
assertEquals(RemovalSource.AUTH_ERROR, pool.snapshot().get("anthropic").source());
assertTrue(pool.contains("dashscope"));
}
// ============================================================
// Sanity: fail-open mode (null pool) old call sites unchanged
// ============================================================
@Test
@DisplayName("Null pool: helper behaves as before (no NPE, no skipping)")
void nullPoolFailOpen() {
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized"));
ChatModel fallback = successModel("ok");
// 5-arg constructor no pool wired
var helper = new NodeStreamingChatHelper(streamTracker,
List.of(new FallbackEntry("dashscope", fallback)), null, healthTracker, "openai");
var result = helper.streamCall(primary, smallPrompt(), "conv-failopen", "reasoning");
assertEquals("ok", result.text());
// No pool to inspect just confirm we didn't crash and fallback ran.
verify(primary, times(1)).stream(any(Prompt.class));
verify(fallback, times(1)).stream(any(Prompt.class));
}
}

View File

@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* per-provider failure-count + cooldown logic.
* RFC-009 P3.3: per-provider failure-count + cooldown logic.
*/
class ProviderHealthTrackerTest {

View File

@ -282,6 +282,33 @@ export const modelApi = {
http.post('/models/embedding/default', { modelId }),
}
// ==================== Provider Pool (RFC-009 Phase 4) ====================
export interface ProviderPoolEntry {
providerId: string
providerName: string
inPool: boolean
removalSource: string | null
removalMessage: string | null
removedAtMs: number | null
inCooldown: boolean
cooldownRemainingMs: number
consecutiveFailures: number
}
export interface ReprobeResult {
providerId: string
success: boolean
latencyMs: number
errorMessage: string | null
inPool: boolean
}
export const providerPoolApi = {
snapshot: () => http.get<ProviderPoolEntry[]>('/llm/provider-pool'),
reprobe: (providerId: string) =>
http.post<ReprobeResult>(`/llm/provider-pool/${encodeURIComponent(providerId)}/reprobe`),
}
// ==================== OAuth ====================
export const oauthApi = {
authorize: () => http.get('/oauth/openai/authorize'),
@ -407,6 +434,30 @@ export const wikiApi = {
// Processing
processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`),
getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`),
// RFC-029: Relations
getRelatedPages: (kbId: number, slug: string, topK = 5) =>
http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/related`, { params: { topK } }),
explainRelation: (kbId: number, slugA: string, slugB: string) =>
http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slugA)}/relation/${encodeURIComponent(slugB)}`),
getPageCitations: (kbId: number, pageId: number) =>
http.get(`/wiki/kb/${kbId}/pages/${pageId}/citations`),
// RFC-030: Jobs
getWikiJobs: (kbId: number, rawId: number) =>
http.get(`/wiki/kb/${kbId}/jobs`, { params: { rawId } }),
getKBStats: (kbId: number) =>
http.get(`/wiki/kb/${kbId}/stats`),
// RFC-031: Enrichment & Repair
enrichPage: (kbId: number, slug: string) =>
http.post(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/enrich`),
repairPage: (kbId: number, slug: string) =>
http.post(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/repair`),
// RFC-032: Search preview
searchPreview: (kbId: number, data: { query: string; mode?: string; topK?: number }) =>
http.post(`/wiki/kb/${kbId}/search-preview`, data),
}
// ==================== Workspace (Team) ====================
@ -433,6 +484,11 @@ export const agentBindingApi = {
unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`),
listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`),
setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames),
// RFC-009 PR-3: per-agent provider preference order. Empty list = use global chain order.
listProviderPreferences: (agentId: string | number) =>
http.get(`/agents/${agentId}/provider-preferences`),
setProviderPreferences: (agentId: string | number, providerIds: string[]) =>
http.put(`/agents/${agentId}/provider-preferences`, providerIds),
}
// ==================== Dashboard ====================

View File

@ -0,0 +1,51 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
import { wikiApi } from '@/api/index'
export interface WikiProcessingJob {
id: number
kbId: number
rawId: number
jobType: string
stage: string
status: string
primaryModelId: number | null
currentModelId: number | null
currentModelName?: string
errorCode: string | null
errorMessage: string | null
retryCount: number
startedAt: string | null
finishedAt: string | null
done?: number
total?: number
}
/**
* RFC-033: Polls the latest processing job for a given raw material.
* Stops polling when the job reaches a terminal status.
*/
export function useWikiJobPoller(kbId: Ref<number | null>, rawId: Ref<number | null>) {
const job = ref<WikiProcessingJob | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
const poll = async () => {
if (!kbId.value || !rawId.value) return
try {
const jobs: any = await wikiApi.getWikiJobs(kbId.value, rawId.value)
const list = jobs.data || jobs || []
job.value = list[0] ?? null
if (job.value && (job.value.status === 'running' || job.value.status === 'queued')) {
timer = setTimeout(poll, 3000)
}
} catch {
job.value = null
}
}
onMounted(poll)
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
return { job, refresh: poll }
}

View File

@ -332,9 +332,25 @@ export default {
protocolGemini: 'Gemini Native',
protocolDashScope: 'DashScope Native',
advancedHint: 'Use this for generation options such as temperature, max_tokens, and top_p.',
fallbackPriorityHint: 'Failover order after the primary model fails: 0 = excluded, 1 = first in line, 2 = second, and so on. Providers sharing the same value are tried in alphabetical order of their ID.',
fallbackBadge: 'Fallback #{priority}',
fallbackBadgeTitle: 'Position in the multi-model failover chain — lower numbers are tried first',
fallbackPriorityHint: 'Pool try-order (lower wins): 0 = excluded, 1 = first in line, 2 = second, and so on. Providers sharing the same value are tried in alphabetical order of their ID.',
fallbackBadge: 'Preferred #{priority}',
fallbackBadgeTitle: 'Position in the available pool — lower numbers are tried first',
// RFC-009 Phase 4: provider pool status badges
poolBadgeInPool: 'In Pool',
poolBadgeInPoolTitle: 'Passed startup probe; participates in failover',
poolBadgeRemoved: 'Removed',
poolBadgeRemovedTitle: 'Removed from pool ({source}): {message}',
poolBadgeCooldown: 'Cooling Down',
poolBadgeCooldownTitle: 'Recent failures; recovers automatically in {seconds}s',
poolSourceAuthError: 'API key invalid',
poolSourceBilling: 'Billing/quota issue',
poolSourceModelNotFound: 'Model not found',
poolSourceInitProbe: 'Startup probe failed',
poolSourceManual: 'Manually removed',
poolReprobe: 'Reprobe',
poolReprobing: 'Probing...',
poolReprobeOk: 'Probe passed; back in the available pool',
poolReprobeFail: 'Probe failed: {error}',
searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).',
searchStrategyDefault: 'Default',
oauthTitle: 'OpenAI OAuth Login',
@ -352,7 +368,7 @@ export default {
apiKeyPrefix: 'API Key Prefix',
protocol: 'Protocol',
generateKwargs: 'Generate Kwargs (JSON)',
fallbackPriority: 'Failover Priority',
fallbackPriority: 'Pool Try-Order',
enableSearch: 'Built-in Search',
searchStrategy: 'Search Strategy',
modelId: 'Model ID',
@ -611,6 +627,7 @@ export default {
basic: 'Basic',
skills: 'Skills',
tools: 'Tools',
providers: 'Providers',
context: 'Context',
},
columns: {
@ -683,8 +700,11 @@ export default {
binding: {
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
providersHint: 'Preferred provider order for this agent (lower index tried first). Leave empty to use the global available-pool order. Cooling-down or pool-removed providers are still skipped automatically.',
providersAddHint: 'Click a provider below to add it to the preference list:',
noSkills: 'No skills available',
noTools: 'No tools available',
noProviderPreferences: 'No preferences set — the agent uses the global fallback chain order.',
contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
goToContext: 'Edit Context Files',
},
@ -1233,6 +1253,64 @@ export default {
progress: {
preparing: 'Preparing…',
},
jobStage: {
queued: 'Queued',
routing: 'Routing',
phase_a_running: 'Analyzing',
phase_a_done: 'Analyzed',
phase_b_running: 'Generating',
enriching: 'Linking',
embedding: 'Embedding',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
},
jobInfo: {
currentModel: 'Model',
fallbackActive: 'Fallback active',
elapsedTime: 'Elapsed',
pagesProgress: '{done}/{total} pages',
},
page: {
type: {
entity: 'Entity',
concept: 'Concept',
source: 'Source',
synthesis: 'Synthesis',
},
enriched: 'Linked',
notEnriched: 'Not linked',
enrich: 'Add cross-links',
repair: 'Repair page',
citations: '{count} source chunks',
noCitations: 'No source citations found',
},
relation: {
shared_chunk: 'Shared chunk',
shared_raw: 'Shared source',
direct_link: 'Direct link',
semantic_near: 'Semantic',
relatedPages: 'Related Pages',
explainRelation: 'Explain relation',
},
configPanel: {
modelStrategy: 'Model Strategy',
stepModel: {
route: 'Analysis / Routing',
create_page: 'Page Creation',
merge_page: 'Page Merge',
enrich: 'Link Annotation',
summary: 'Summary Update',
},
fallbackModels: 'Fallback Models',
searchPreview: 'Search Preview',
searchPreviewPlaceholder: 'Enter a user message to preview agent search results…',
searchPreviewRun: 'Test',
},
stats: {
enrichedRatio: '{enriched}/{total} linked',
failedJobs: '{count} failed',
},
},
cronJobs: {
kicker: 'Automation',

View File

@ -322,9 +322,25 @@ export default {
protocolGemini: 'Gemini 原生',
protocolDashScope: 'DashScope 原生',
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
fallbackPriorityHint: '主模型失败后的失败切换顺序0 = 不参与1 = 第一顺位2 = 第二顺位,依此类推。多个 provider 共用同一数字时按 ID 字典序。',
fallbackBadge: '兜底 #{priority}',
fallbackBadgeTitle: '该 provider 在多模型失败切换链中,数字越小越先尝试',
fallbackPriorityHint: '池内尝试顺序数字越小越先0 = 不参与1 = 第一顺位2 = 第二顺位,依此类推。多个 provider 共用同一数字时按 ID 字典序。',
fallbackBadge: '偏好 #{priority}',
fallbackBadgeTitle: '可用池内的尝试顺序,数字越小越先尝试',
// RFC-009 Phase 4: provider pool status badges
poolBadgeInPool: '可用',
poolBadgeInPoolTitle: '已通过启动体检,参与失败切换',
poolBadgeRemoved: '已下线',
poolBadgeRemovedTitle: '已从可用池移除({source}{message}',
poolBadgeCooldown: '冷却中',
poolBadgeCooldownTitle: '近期连续失败,{seconds} 秒后自动恢复',
poolSourceAuthError: 'API Key 失效',
poolSourceBilling: '余额/计费问题',
poolSourceModelNotFound: '模型不存在',
poolSourceInitProbe: '启动体检失败',
poolSourceManual: '手动移除',
poolReprobe: '重新检测',
poolReprobing: '检测中...',
poolReprobeOk: '检测通过,已重新加入可用池',
poolReprobeFail: '检测失败:{error}',
searchHint: '开启后大模型将在回答时自动调用内置搜索引擎获取实时信息DashScope/Kimi/OpenAI 支持)。',
searchStrategyDefault: '默认',
oauthTitle: 'OpenAI OAuth 登录',
@ -342,7 +358,7 @@ export default {
apiKeyPrefix: 'API Key 前缀',
protocol: '协议',
generateKwargs: 'Generate Kwargs (JSON)',
fallbackPriority: '失败切换优先级',
fallbackPriority: '池内尝试顺序',
enableSearch: '内置搜索',
searchStrategy: '搜索策略',
modelId: '模型 ID',
@ -611,6 +627,7 @@ export default {
basic: '基本信息',
skills: '技能',
tools: '工具',
providers: '偏好 Provider',
context: '上下文',
},
columns: {
@ -683,8 +700,11 @@ export default {
binding: {
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
providersHint: '此智能体优先使用的 Provider 顺序数字越小越先尝试。留空则按全局可用池顺序回退。Provider 进入冷却或被移出池时仍会被自动跳过。',
providersAddHint: '点击下方 Provider 加入偏好列表:',
noSkills: '暂无可用技能',
noTools: '暂无可用工具',
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
contextHint: '管理此智能体的上下文文件(如 AGENT.md定义智能体的行为、知识和指令。',
goToContext: '前往编辑上下文',
},
@ -1243,6 +1263,64 @@ export default {
progress: {
preparing: '准备中…',
},
jobStage: {
queued: '排队中',
routing: '选择模型',
phase_a_running: '分析中',
phase_a_done: '分析完成',
phase_b_running: '生成页面',
enriching: '补充链接',
embedding: '向量化',
completed: '已完成',
failed: '处理失败',
cancelled: '已取消',
},
jobInfo: {
currentModel: '当前模型',
fallbackActive: '已启用备用模型',
elapsedTime: '已用时',
pagesProgress: '已生成 {done}/{total} 页',
},
page: {
type: {
entity: '实体',
concept: '概念',
source: '来源',
synthesis: '综合',
},
enriched: '已链接',
notEnriched: '未链接',
enrich: '添加交叉链接',
repair: '修复页面',
citations: '来源 {count} 个 chunk',
noCitations: '未找到来源引用',
},
relation: {
shared_chunk: '共享片段',
shared_raw: '共享来源',
direct_link: '直接引用',
semantic_near: '语义相近',
relatedPages: '相关页面',
explainRelation: '查看关联原因',
},
configPanel: {
modelStrategy: '模型策略',
stepModel: {
route: '分析/路由',
create_page: '页面生成',
merge_page: '页面合并',
enrich: '链接标注',
summary: '摘要更新',
},
fallbackModels: '备选模型',
searchPreview: '检索测试',
searchPreviewPlaceholder: '输入用户消息,预览 Agent 检索结果…',
searchPreviewRun: '测试',
},
stats: {
enrichedRatio: '{enriched}/{total} 已链接',
failedJobs: '{count} 个失败',
},
},
cronJobs: {
kicker: '自动执行',

View File

@ -162,6 +162,10 @@
{{ t('agents.tabs.tools', 'Tools') }}
<span v-if="selectedToolNames.length" class="tab-badge">{{ selectedToolNames.length }}</span>
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'providers' }" @click="modalTab = 'providers'">
{{ t('agents.tabs.providers', 'Providers') }}
<span v-if="selectedProviderIds.length" class="tab-badge">{{ selectedProviderIds.length }}</span>
</button>
</div>
<!-- Basic Tab -->
@ -260,6 +264,38 @@
</label>
</div>
</div>
<!-- Providers Tab (RFC-009 PR-3) -->
<div v-if="modalTab === 'providers'" class="binding-tab">
<p class="binding-hint">{{ t('agents.binding.providersHint') }}</p>
<!-- Picked: ordered list with up/down/remove controls -->
<div v-if="selectedProviderIds.length" class="provider-pref-list">
<div
v-for="(pid, idx) in selectedProviderIds"
:key="pid"
class="provider-pref-item"
>
<span class="provider-pref-rank">{{ idx + 1 }}</span>
<span class="provider-pref-name">{{ providerNameById(pid) }}</span>
<span class="provider-pref-id">{{ pid }}</span>
<button class="provider-pref-btn" :disabled="idx === 0" @click="moveProvider(idx, -1)"></button>
<button class="provider-pref-btn" :disabled="idx === selectedProviderIds.length - 1" @click="moveProvider(idx, 1)"></button>
<button class="provider-pref-btn danger" @click="removeProvider(idx)"></button>
</div>
</div>
<div v-else class="binding-empty">{{ t('agents.binding.noProviderPreferences') }}</div>
<!-- Unpicked: click to append -->
<div v-if="unpickedProviders.length" class="provider-pref-pool">
<p class="binding-hint" style="margin-top: 14px">{{ t('agents.binding.providersAddHint') }}</p>
<button
v-for="p in unpickedProviders"
:key="p.id"
class="provider-pref-add-btn"
@click="addProvider(p.id)"
>+ {{ p.name }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
@ -277,7 +313,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi, agentBindingApi, skillApi, toolApi, templateApi } from '@/api/index'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi } from '@/api/index'
import type { Agent } from '@/types/index'
const router = useRouter()
@ -287,13 +323,16 @@ const searchText = ref('')
const activeFilter = ref('all')
const showModal = ref(false)
const editingAgent = ref<Agent | null>(null)
const modalTab = ref<'basic' | 'skills' | 'tools'>('basic')
const modalTab = ref<'basic' | 'skills' | 'tools' | 'providers'>('basic')
// Binding state
const availableSkills = ref<any[]>([])
const availableTools = ref<any[]>([])
const selectedSkillIds = ref<number[]>([])
const selectedToolNames = ref<string[]>([])
// RFC-009 PR-3: per-agent provider preference order
const availableProviders = ref<{ id: string; name: string }[]>([])
const selectedProviderIds = ref<string[]>([])
// Template selector state
const showTemplateSelector = ref(false)
@ -376,9 +415,36 @@ function openBlankCreateModal() {
modalTab.value = 'basic'
selectedSkillIds.value = []
selectedToolNames.value = []
selectedProviderIds.value = []
showModal.value = true
}
// RFC-009 PR-3: provider preference helpers
const unpickedProviders = computed(() =>
availableProviders.value.filter(p => !selectedProviderIds.value.includes(p.id))
)
function providerNameById(id: string): string {
return availableProviders.value.find(p => p.id === id)?.name || id
}
function addProvider(id: string) {
if (!selectedProviderIds.value.includes(id)) {
selectedProviderIds.value.push(id)
}
}
function removeProvider(idx: number) {
selectedProviderIds.value.splice(idx, 1)
}
function moveProvider(idx: number, dir: -1 | 1) {
const next = idx + dir
if (next < 0 || next >= selectedProviderIds.value.length) return
const arr = selectedProviderIds.value
;[arr[idx], arr[next]] = [arr[next], arr[idx]]
}
async function loadTemplates() {
try {
const res: any = await templateApi.list()
@ -419,22 +485,32 @@ async function openEditModal(agent: Agent) {
modalTab.value = 'basic'
showModal.value = true
// Load available skills/tools and current bindings in parallel
// Load available skills/tools/providers and current bindings in parallel
try {
const [skillsRes, toolsRes, boundSkillsRes, boundToolsRes] = await Promise.all([
const [skillsRes, toolsRes, providersRes, boundSkillsRes, boundToolsRes, providerPrefsRes] = await Promise.all([
skillApi.list(),
toolApi.list(),
modelApi.listProviders(),
agentBindingApi.listSkills(agent.id),
agentBindingApi.listTools(agent.id),
agentBindingApi.listProviderPreferences(agent.id),
])
availableSkills.value = (skillsRes as any).data || []
availableTools.value = (toolsRes as any).data || []
// Pool of providers the user has actually configured no point letting an
// agent prefer a provider that doesn't exist on this deployment.
availableProviders.value = ((providersRes as any).data || [])
.filter((p: any) => p.configured)
.map((p: any) => ({ id: p.id, name: p.name }))
selectedSkillIds.value = ((boundSkillsRes as any).data || [])
.filter((b: any) => b.enabled)
.map((b: any) => b.skillId)
selectedToolNames.value = ((boundToolsRes as any).data || [])
.filter((b: any) => b.enabled)
.map((b: any) => b.toolName)
selectedProviderIds.value = ((providerPrefsRes as any).data || [])
.filter((b: any) => b.enabled)
.map((b: any) => b.providerId)
} catch {
// Non-blocking: binding data load failure doesn't prevent editing basic info
}
@ -461,6 +537,7 @@ async function saveAgent() {
await Promise.all([
agentBindingApi.setSkills(agentId, selectedSkillIds.value),
agentBindingApi.setTools(agentId, selectedToolNames.value),
agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value),
])
}
@ -718,6 +795,36 @@ async function toggleAgent(agent: Agent) {
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); text-transform: uppercase;
}
/* Provider preference list (RFC-009 PR-3) */
.provider-pref-list { display: flex; flex-direction: column; gap: 6px; }
.provider-pref-item {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
border: 1px solid var(--mc-border-light); border-radius: 8px; background: var(--mc-bg-elevated);
}
.provider-pref-rank {
width: 22px; height: 22px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: var(--mc-primary); color: white; font-size: 11px; font-weight: 700; flex-shrink: 0;
}
.provider-pref-name { font-size: 14px; color: var(--mc-text-primary); flex: 1; }
.provider-pref-id { font-size: 12px; color: var(--mc-text-tertiary); font-family: ui-monospace, monospace; }
.provider-pref-btn {
border: 1px solid var(--mc-border-light); background: var(--mc-bg);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer;
font-size: 12px; color: var(--mc-text-secondary);
}
.provider-pref-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.provider-pref-btn:not(:disabled):hover { border-color: var(--mc-primary); color: var(--mc-primary); }
.provider-pref-btn.danger:not(:disabled):hover { border-color: var(--mc-danger); color: var(--mc-danger); }
.provider-pref-pool { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.provider-pref-pool .binding-hint { width: 100%; }
.provider-pref-add-btn {
border: 1px dashed var(--mc-border); background: transparent;
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
color: var(--mc-text-secondary);
}
.provider-pref-add-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); border-style: solid; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full-width { grid-column: 1 / -1; }

View File

@ -27,6 +27,37 @@
>
{{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }}
</span>
<!-- RFC-009 Phase 4: pool status. Three mutually-exclusive states:
removed > cooldown > in-pool. Hidden when no pool data is loaded yet
or when the provider isn't configured (pool would never have probed it). -->
<template v-if="poolEntry && provider.configured">
<span
v-if="!poolEntry.inPool"
class="provider-badge pool-removed"
:title="t('settings.model.poolBadgeRemovedTitle', {
source: poolSourceLabel(poolEntry.removalSource),
message: poolEntry.removalMessage || '—'
})"
>
{{ t('settings.model.poolBadgeRemoved') }}
</span>
<span
v-else-if="poolEntry.inCooldown"
class="provider-badge pool-cooldown"
:title="t('settings.model.poolBadgeCooldownTitle', {
seconds: Math.ceil(poolEntry.cooldownRemainingMs / 1000)
})"
>
{{ t('settings.model.poolBadgeCooldown') }}
</span>
<span
v-else
class="provider-badge pool-in"
:title="t('settings.model.poolBadgeInPoolTitle')"
>
{{ t('settings.model.poolBadgeInPool') }}
</span>
</template>
</div>
<p class="provider-id">{{ provider.id }}</p>
</div>
@ -84,6 +115,17 @@
>
{{ t('common.delete') }}
</button>
<!-- RFC-009 Phase 4: manual reprobe visible when the provider has been
HARD-removed from the pool, lets the user recover without restart. -->
<button
v-if="poolEntry && !poolEntry.inPool && provider.configured"
class="card-btn"
:class="{ testing: reprobing }"
:disabled="reprobing"
@click="$emit('reprobe', provider)"
>
{{ reprobing ? t('settings.model.poolReprobing') : t('settings.model.poolReprobe') }}
</button>
</div>
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
@ -100,11 +142,16 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
import type { ProviderPoolEntry } from '@/api'
defineProps<{
provider: ProviderInfo
connectionTestingId: string | null
connectionResults: Record<string, any>
// RFC-009 Phase 4: pool status for this provider; null when pool API hasn't loaded yet.
poolEntry?: ProviderPoolEntry | null
// RFC-009 Phase 4: true while a manual reprobe is in flight for this provider.
reprobing?: boolean
isProviderActive: (provider: ProviderInfo) => boolean
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
getProviderIcon: (id: string) => string
@ -116,9 +163,22 @@ defineEmits<{
'provider-settings': [provider: ProviderInfo]
'test-connection': [provider: ProviderInfo]
'delete-provider': [provider: ProviderInfo]
'reprobe': [provider: ProviderInfo]
}>()
const { t } = useI18n()
/** Translate the backend RemovalSource enum into a human-readable label. */
function poolSourceLabel(source: string | null): string {
switch (source) {
case 'AUTH_ERROR': return t('settings.model.poolSourceAuthError')
case 'BILLING': return t('settings.model.poolSourceBilling')
case 'MODEL_NOT_FOUND': return t('settings.model.poolSourceModelNotFound')
case 'INIT_PROBE': return t('settings.model.poolSourceInitProbe')
case 'MANUAL': return t('settings.model.poolSourceManual')
default: return source || '—'
}
}
</script>
<style scoped>
@ -166,6 +226,10 @@ const { t } = useI18n()
.provider-badge.custom { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-badge.active { background: rgba(217, 119, 87, 0.12); color: var(--mc-primary-light); }
.provider-badge.fallback { background: rgba(99, 102, 241, 0.12); color: #6366f1; cursor: help; }
/* RFC-009 Phase 4: pool status. Green = healthy, amber = cooldown, red = removed. */
.provider-badge.pool-in { background: rgba(34, 197, 94, 0.12); color: #16a34a; cursor: help; }
.provider-badge.pool-cooldown { background: rgba(245, 158, 11, 0.14); color: #b45309; cursor: help; }
.provider-badge.pool-removed { background: rgba(239, 68, 68, 0.14); color: #dc2626; cursor: help; }
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }

View File

@ -24,6 +24,8 @@
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:pool-entry="providerPool[provider.id] || null"
:reprobing="reprobingId === provider.id"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
:get-provider-icon="getProviderIcon"
@ -32,6 +34,7 @@
@provider-settings="openProviderConfigModal"
@test-connection="handleTestConnection"
@delete-provider="onDeleteProvider"
@reprobe="reprobeProvider"
/>
</div>
</div>
@ -51,6 +54,8 @@
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:pool-entry="providerPool[provider.id] || null"
:reprobing="reprobingId === provider.id"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
:get-provider-icon="getProviderIcon"
@ -59,6 +64,7 @@
@provider-settings="openProviderConfigModal"
@test-connection="handleTestConnection"
@delete-provider="onDeleteProvider"
@reprobe="reprobeProvider"
/>
</div>
</div>
@ -151,6 +157,10 @@ const {
providerBaseUrlPlaceholder,
providerBaseUrlHint,
providerApiKeyPlaceholder,
providerPool,
reprobingId,
loadProviderPool,
reprobeProvider,
loadProviders,
loadActiveModel,
openCreateProviderModal,
@ -182,7 +192,7 @@ const localProviders = computed(() => providers.value.filter(p => p.isLocal))
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
onMounted(async () => {
await Promise.all([loadProviders(), loadActiveModel()])
await Promise.all([loadProviders(), loadActiveModel(), loadProviderPool()])
})
async function onSaveProvider() {

View File

@ -1,7 +1,8 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { modelApi, oauthApi } from '@/api'
import { modelApi, oauthApi, providerPoolApi } from '@/api'
import type { ProviderPoolEntry } from '@/api'
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
export function useProviders() {
@ -9,6 +10,10 @@ export function useProviders() {
const providers = ref<ProviderInfo[]>([])
const activeModels = ref<ActiveModelsInfo | null>(null)
// RFC-009 Phase 4: pool snapshot, indexed by providerId for O(1) lookup in templates.
const providerPool = ref<Record<string, ProviderPoolEntry>>({})
// RFC-009 Phase 4 PR-1e: providerId currently being manually reprobed.
const reprobingId = ref<string | null>(null)
const editingProvider = ref<ProviderInfo | null>(null)
const currentProvider = ref<ProviderInfo | null>(null)
const showProviderModal = ref(false)
@ -64,6 +69,51 @@ export function useProviders() {
activeModels.value = res.data || null
}
/**
* RFC-009 Phase 4: fetch the pool snapshot. Best-effort if it 404s
* (older backend) or errors, the badges just don't render. Don't block
* the rest of the model settings page on it.
*/
async function loadProviderPool() {
try {
const res: any = await providerPoolApi.snapshot()
const list: ProviderPoolEntry[] = res.data || []
providerPool.value = list.reduce((acc, entry) => {
acc[entry.providerId] = entry
return acc
}, {} as Record<string, ProviderPoolEntry>)
} catch (err) {
console.warn('[ProviderPool] snapshot failed (badges will be hidden)', err)
providerPool.value = {}
}
}
/**
* RFC-009 Phase 4 PR-1e: synchronously re-probe one provider, then
* refresh the pool snapshot so the badge updates. Returns the result
* so callers can show a toast.
*/
async function reprobeProvider(provider: ProviderInfo) {
reprobingId.value = provider.id
try {
const res: any = await providerPoolApi.reprobe(provider.id)
const data = res.data || {}
await loadProviderPool()
if (data.success) {
ElMessage.success(t('settings.model.poolReprobeOk'))
} else {
ElMessage.warning(t('settings.model.poolReprobeFail', { error: data.errorMessage || '—' }))
}
return data
} catch (err) {
ElMessage.error(t('settings.model.poolReprobeFail', {
error: err instanceof Error ? err.message : String(err)
}))
} finally {
reprobingId.value = null
}
}
async function refreshCurrentProvider(providerId: string) {
await Promise.all([loadProviders(), loadActiveModel()])
currentProvider.value = providers.value.find(provider => provider.id === providerId) || null
@ -477,6 +527,11 @@ export function useProviders() {
providerBaseUrlPlaceholder,
providerBaseUrlHint,
providerApiKeyPlaceholder,
// RFC-009 Phase 4
providerPool,
reprobingId,
loadProviderPool,
reprobeProvider,
// Methods
loadProviders,
loadActiveModel,

View File

@ -0,0 +1,144 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="citation-overlay" @click.self="$emit('update:modelValue', false)">
<div class="citation-drawer">
<div class="drawer-header">
<h3 class="drawer-title">{{ t('wiki.page.citations', { count: citations.length }) }}</h3>
<button class="drawer-close" @click="$emit('update:modelValue', false)"></button>
</div>
<div v-if="loading" class="drawer-loading">{{ t('common.loading') }}</div>
<div v-else-if="citations.length === 0" class="drawer-empty">
{{ t('wiki.page.noCitations') }}
</div>
<div v-else class="citation-list">
<div v-for="cit in citations" :key="cit.id" class="citation-item">
<div class="citation-raw-title">{{ cit.rawTitle || 'Source' }}</div>
<div class="citation-chunk-info">
Chunk {{ cit.chunkOrdinal ?? '?' }}
<span v-if="cit.startOffset != null" class="citation-offset">
(offset {{ cit.startOffset }}{{ cit.endOffset }})
</span>
</div>
<div v-if="cit.snippet" class="citation-snippet">"{{ cit.snippet }}"</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
pageId: number
kbId: number
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
interface Citation {
id: number
chunkId: number
rawTitle?: string
chunkOrdinal?: number
startOffset?: number
endOffset?: number
snippet?: string
}
const citations = ref<Citation[]>([])
const loading = ref(false)
async function fetchCitations() {
if (!props.pageId) return
loading.value = true
try {
const res: any = await wikiApi.getPageCitations(props.kbId, props.pageId)
citations.value = res.data || res || []
} catch {
citations.value = []
} finally {
loading.value = false
}
}
watch(() => [props.modelValue, props.pageId], ([open]) => {
if (open) fetchCitations()
}, { immediate: true })
</script>
<style scoped>
.citation-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
.citation-drawer {
width: min(420px, 90vw);
height: 100%;
background: var(--mc-bg-elevated);
border-left: 1px solid var(--mc-border);
box-shadow: -8px 0 24px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--mc-border-light);
}
.drawer-title { font-size: 16px; font-weight: 600; margin: 0; color: var(--mc-text-primary); }
.drawer-close { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--mc-text-secondary); padding: 4px 8px; border-radius: 6px; }
.drawer-close:hover { background: var(--mc-bg-sunken); }
.drawer-loading, .drawer-empty {
padding: 32px 20px;
text-align: center;
color: var(--mc-text-tertiary);
font-size: 14px;
}
.citation-list {
flex: 1;
overflow-y: auto;
padding: 12px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.citation-item {
padding: 12px 14px;
background: var(--mc-bg-muted);
border-radius: 10px;
border: 1px solid var(--mc-border-light);
}
.citation-raw-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
.citation-chunk-info { font-size: 11px; color: var(--mc-text-secondary); margin-bottom: 6px; }
.citation-offset { color: var(--mc-text-tertiary); }
.citation-snippet {
font-size: 12px;
color: var(--mc-text-secondary);
line-height: 1.5;
font-style: italic;
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,213 @@
<template>
<div class="job-stage-bar">
<div class="stage-dots">
<div
v-for="(stage, idx) in stages"
:key="stage.key"
class="stage-dot-group"
>
<div
class="stage-dot"
:class="dotClass(stage.key)"
:title="stage.key === currentStage && errorCode ? `${errorCode}: ${errorMessage}` : ''"
/>
<span v-if="idx < stages.length - 1" class="stage-line" :class="{ done: isStageComplete(stage.key) }" />
</div>
</div>
<div class="stage-labels">
<span v-for="stage in stages" :key="stage.key" class="stage-label" :class="{ active: stage.key === currentStage }">
{{ t(`wiki.jobStage.${stage.key}`) }}
</span>
</div>
<!-- Model & progress info -->
<div class="stage-info">
<span v-if="currentModel" class="info-model">
{{ t('wiki.jobInfo.currentModel') }}: {{ currentModel }}
</span>
<span v-if="isFallbackActive" class="info-fallback">
{{ t('wiki.jobInfo.fallbackActive') }}
</span>
<span v-if="pagesProgress" class="info-pages">
{{ pagesProgress }}
</span>
<span v-if="elapsed" class="info-elapsed">
{{ t('wiki.jobInfo.elapsedTime') }}: {{ elapsed }}
</span>
</div>
<!-- Error state with actions -->
<div v-if="status === 'failed'" class="stage-error">
<span class="error-badge"> {{ t('wiki.jobStage.failed') }} {{ errorCode }}</span>
<div class="error-actions">
<button class="btn-mini" @click="$emit('reprocess')">{{ t('wiki.reprocess') }}</button>
<button class="btn-mini btn-mini-alt" @click="$emit('repair')">{{ t('wiki.page.repair') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
stage: string
status: string
currentModel?: string
isFallbackActive?: boolean
errorCode?: string
errorMessage?: string
done?: number
total?: number
startedAt?: string
}>()
defineEmits<{
reprocess: []
repair: []
}>()
const stages = [
{ key: 'queued' },
{ key: 'routing' },
{ key: 'phase_a_running' },
{ key: 'phase_b_running' },
{ key: 'enriching' },
{ key: 'embedding' },
{ key: 'completed' },
]
const stageOrder = stages.map(s => s.key)
const currentStage = computed(() => props.stage)
function stageIndex(key: string): number {
return stageOrder.indexOf(key)
}
function isStageComplete(key: string): boolean {
const cur = stageIndex(currentStage.value)
const target = stageIndex(key)
return target < cur
}
function dotClass(key: string) {
const cur = stageIndex(currentStage.value)
const target = stageIndex(key)
if (props.status === 'failed' && key === currentStage.value) return 'failed'
if (target < cur) return 'done'
if (target === cur) return 'active'
return 'pending'
}
const pagesProgress = computed(() => {
if (props.total && props.total > 0) {
return t('wiki.jobInfo.pagesProgress', { done: props.done ?? 0, total: props.total })
}
return ''
})
const elapsed = computed(() => {
if (!props.startedAt) return ''
const start = new Date(props.startedAt).getTime()
const now = Date.now()
const sec = Math.floor((now - start) / 1000)
if (sec < 60) return `${sec}s`
return `${Math.floor(sec / 60)}m ${sec % 60}s`
})
</script>
<style scoped>
.job-stage-bar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 0;
}
.stage-dots {
display: flex;
align-items: center;
gap: 0;
}
.stage-dot-group {
display: flex;
align-items: center;
flex: 1;
}
.stage-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.3s;
}
.stage-dot.done { background: var(--mc-primary); }
.stage-dot.active { background: var(--mc-primary); box-shadow: 0 0 0 3px rgba(217,119,87,0.25); animation: pulse 1.5s infinite; }
.stage-dot.pending { background: var(--mc-border); }
.stage-dot.failed { background: var(--mc-danger); box-shadow: 0 0 0 3px rgba(245,108,108,0.2); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(217,119,87,0.25); }
50% { box-shadow: 0 0 0 5px rgba(217,119,87,0.1); }
}
.stage-line {
flex: 1;
height: 2px;
background: var(--mc-border);
margin: 0 2px;
transition: background 0.3s;
}
.stage-line.done { background: var(--mc-primary); }
.stage-labels {
display: flex;
justify-content: space-between;
}
.stage-label {
font-size: 9px;
color: var(--mc-text-tertiary);
text-align: center;
flex: 1;
}
.stage-label.active { color: var(--mc-primary); font-weight: 600; }
.stage-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: var(--mc-text-secondary);
}
.info-fallback { color: var(--mc-primary); font-weight: 500; }
.info-pages { font-variant-numeric: tabular-nums; }
.stage-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: var(--mc-danger-bg);
border-radius: 8px;
}
.error-badge { font-size: 11px; color: var(--mc-danger); font-weight: 500; }
.error-actions { display: flex; gap: 6px; }
.btn-mini {
padding: 3px 10px;
font-size: 11px;
border: 1px solid var(--mc-border);
border-radius: 6px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
cursor: pointer;
}
.btn-mini:hover { background: var(--mc-bg-sunken); }
.btn-mini-alt { color: var(--mc-primary); border-color: var(--mc-primary); }
</style>

View File

@ -0,0 +1,172 @@
<template>
<div class="page-header-bar">
<div class="page-header-left">
<!-- Page type badge -->
<span v-if="page.pageType" class="page-type-badge" :class="page.pageType">
{{ t(`wiki.page.type.${page.pageType}`) || page.pageType }}
</span>
<h2 class="page-title">{{ page.title }}</h2>
<div class="page-meta-row">
<span class="meta-badge">v{{ page.version }}</span>
<span class="meta-slug">{{ page.slug }}</span>
<span class="kicker-dot" :class="page.lastUpdatedBy === 'manual' ? 'manual' : 'ai'" />
<span class="meta-updater">
{{ page.lastUpdatedBy === 'ai' ? t('wiki.generatedByAi') : t('wiki.editedManually') }}
</span>
</div>
</div>
<div class="page-header-right">
<!-- Enrichment status -->
<span v-if="isEnriched" class="enrichment-status enriched">
<el-icon :size="12"><Link /></el-icon>
{{ t('wiki.page.enriched') }}
</span>
<button v-else class="btn-mini-enrich" @click="$emit('enrich')">
<el-icon :size="12"><Link /></el-icon>
{{ t('wiki.page.enrich') }}
</button>
<!-- Source citation link -->
<button v-if="sourceCount > 0" class="btn-citations" @click="$emit('viewCitations')">
{{ t('wiki.page.citations', { count: sourceCount }) }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Link } from '@element-plus/icons-vue'
const { t } = useI18n()
const props = defineProps<{
page: {
title: string
slug: string
version: number
lastUpdatedBy: string
content?: string | null
pageType?: string
sourceRawIds?: string
}
}>()
defineEmits<{
viewCitations: []
enrich: []
}>()
const isEnriched = computed(() =>
/\[\[.+?\]\]/.test(props.page.content ?? '')
)
const sourceCount = computed(() => {
if (!props.page.sourceRawIds) return 0
try {
return JSON.parse(props.page.sourceRawIds).length
} catch {
return 0
}
})
</script>
<style scoped>
.page-header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--mc-border-light);
}
.page-header-left { min-width: 0; }
.page-type-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.page-type-badge.entity { background: rgba(59,130,246,0.12); color: #3b82f6; }
.page-type-badge.concept { background: rgba(168,85,247,0.12); color: #a855f7; }
.page-type-badge.source { background: rgba(34,197,94,0.12); color: #22c55e; }
.page-type-badge.synthesis { background: rgba(234,179,8,0.12); color: #ca8a04; }
.page-title {
font-size: clamp(22px, 3vw, 30px);
line-height: 1.15;
letter-spacing: -0.03em;
font-weight: 700;
color: var(--mc-text-primary);
margin: 0;
}
.page-meta-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: var(--mc-text-secondary);
}
.meta-badge { padding: 2px 8px; background: var(--mc-bg-sunken); border-radius: 6px; font-weight: 600; font-size: 11px; }
.meta-slug { font-family: 'JetBrains Mono', monospace; font-size: 11px; opacity: 0.7; }
.kicker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.kicker-dot.ai { background: var(--el-color-primary, #409eff); }
.kicker-dot.manual { background: var(--el-color-success, #67c23a); }
.meta-updater { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.page-header-right {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
flex-shrink: 0;
}
.enrichment-status {
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.enrichment-status.enriched { background: rgba(34,197,94,0.1); color: #22c55e; }
.btn-mini-enrich {
font-size: 11px;
padding: 3px 10px;
border: 1px dashed var(--mc-primary);
border-radius: 6px;
background: transparent;
color: var(--mc-primary);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.btn-mini-enrich:hover { background: var(--mc-primary-bg); }
.btn-citations {
font-size: 11px;
padding: 3px 10px;
border: 1px solid var(--mc-border);
border-radius: 6px;
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
cursor: pointer;
}
.btn-citations:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); }
</style>

View File

@ -91,7 +91,23 @@
</button>
</div>
</div>
<div v-if="raw.processingStatus === 'processing'" class="raw-progress">
<!-- RFC-033: Job stage bar (when job data available) -->
<JobStageBar
v-if="rawJobs[raw.id]"
:stage="rawJobs[raw.id].stage"
:status="rawJobs[raw.id].status"
:current-model="rawJobs[raw.id].currentModelName ?? (rawJobs[raw.id].currentModelId ? `Model #${rawJobs[raw.id].currentModelId}` : undefined)"
:is-fallback-active="rawJobs[raw.id].currentModelId != null && rawJobs[raw.id].currentModelId !== rawJobs[raw.id].primaryModelId"
:error-code="rawJobs[raw.id].errorCode ?? undefined"
:error-message="rawJobs[raw.id].errorMessage ?? undefined"
:done="rawJobs[raw.id].done ?? raw.progressDone"
:total="rawJobs[raw.id].total ?? raw.progressTotal"
:started-at="rawJobs[raw.id].startedAt ?? undefined"
@reprocess="reprocess(raw.id)"
@repair="handleLocalRepair(raw.id)"
/>
<!-- Fallback: legacy progress bar when no job data -->
<div v-else-if="raw.processingStatus === 'processing'" class="raw-progress">
<div class="raw-progress-track">
<div
class="raw-progress-fill"
@ -143,10 +159,12 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import JobStageBar from './JobStageBar.vue'
import type { WikiProcessingJob } from '@/composables/useWikiJobPoller'
const { t } = useI18n()
const store = useWikiStore()
@ -263,6 +281,38 @@ onBeforeUnmount(() => {
}
})
// RFC-033: Job polling per raw material
const rawJobs = reactive<Record<number, WikiProcessingJob>>({})
let jobPoller: ReturnType<typeof setTimeout> | null = null
async function pollJobs() {
if (!store.currentKB) return
const processingRaws = store.rawMaterials.filter(
r => r.processingStatus === 'processing' || r.processingStatus === 'pending'
)
for (const raw of processingRaws) {
try {
const res: any = await wikiApi.getWikiJobs(store.currentKB.id, raw.id)
const list = res.data || res || []
if (list.length > 0) rawJobs[raw.id] = list[0]
} catch { /* ignore */ }
}
if (processingRaws.length > 0) {
jobPoller = setTimeout(pollJobs, 3000)
}
}
watch(hasProcessing, (active) => {
if (active) pollJobs()
else if (jobPoller) { clearTimeout(jobPoller); jobPoller = null }
}, { immediate: true })
async function handleLocalRepair(rawId: number) {
if (!store.currentKB) return
// For local repair, we'd need a page slug. For now, reprocess the raw material.
await reprocess(rawId)
}
const showAddText = ref(false)
const textTitle = ref('')
const textContent = ref('')

View File

@ -0,0 +1,143 @@
<template>
<div v-if="relatedPages.length > 0" class="related-panel">
<h4 class="related-title">{{ t('wiki.relation.relatedPages') }} ({{ relatedPages.length }})</h4>
<div class="related-list">
<div
v-for="rp in relatedPages"
:key="rp.slug"
class="related-item"
@click="$emit('navigate', rp.slug)"
>
<div class="related-signals">
<span
v-for="sig in rp.signals"
:key="sig"
class="signal-tag"
:class="sig"
>
{{ signalIcon(sig) }} {{ t(`wiki.relation.${sig}`) }}
</span>
</div>
<div class="related-info">
<span class="related-item-title">{{ rp.title }}</span>
<span class="related-score">{{ Number(rp.score).toFixed(1) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const props = defineProps<{
kbId: number
slug: string
}>()
defineEmits<{
navigate: [slug: string]
}>()
interface RelatedPage {
slug: string
title: string
summary: string
score: number
signals: string[]
}
const relatedPages = ref<RelatedPage[]>([])
function signalIcon(sig: string): string {
switch (sig) {
case 'shared_chunk': return '🔗'
case 'shared_raw': return '📂'
case 'direct_link': return '↗'
case 'semantic_near': return '◎'
default: return '•'
}
}
async function fetchRelated() {
if (!props.kbId || !props.slug) {
relatedPages.value = []
return
}
try {
const res: any = await wikiApi.getRelatedPages(props.kbId, props.slug, 5)
relatedPages.value = res.data || res || []
} catch {
relatedPages.value = []
}
}
watch(() => [props.kbId, props.slug], fetchRelated, { immediate: true })
</script>
<style scoped>
.related-panel {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--mc-border-light);
}
.related-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--mc-text-secondary);
margin-bottom: 10px;
letter-spacing: 0.05em;
}
.related-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.related-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid var(--mc-border-light);
}
.related-item:hover {
background: var(--mc-bg-muted);
border-color: var(--mc-border);
}
.related-signals {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.signal-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.signal-tag.shared_chunk { background: rgba(59,130,246,0.1); color: #3b82f6; }
.signal-tag.shared_raw { background: rgba(168,85,247,0.1); color: #a855f7; }
.signal-tag.direct_link { background: rgba(34,197,94,0.1); color: #22c55e; }
.signal-tag.semantic_near { background: rgba(234,179,8,0.1); color: #ca8a04; }
.related-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.related-item-title { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); }
.related-score { font-size: 11px; color: var(--mc-text-tertiary); font-variant-numeric: tabular-nums; }
</style>

View File

@ -5,25 +5,60 @@
<p class="config-desc">{{ t('wiki.configDesc') }}</p>
</div>
<!-- Embedding 模型绑定RFC Embedding UI -->
<!-- Embedding model binding -->
<div class="embedding-config">
<label class="embedding-label">
Embedding 模型
<span class="embedding-hint">用于该知识库的语义检索留空走系统默认</span>
Embedding Model
<span class="embedding-hint">Semantic search model for this KB; leave empty for system default</span>
</label>
<div class="embedding-row">
<select v-model="embeddingModelId" class="embedding-select" :disabled="savingEmbedding">
<option value="">跟随系统默认</option>
<option value="">Follow system default</option>
<option v-for="m in embeddingOptions" :key="m.id" :value="String(m.id)">
{{ m.name }} ({{ m.modelName }})
</option>
</select>
<button class="btn-secondary" @click="saveEmbeddingBinding" :disabled="savingEmbedding">
{{ savingEmbedding ? '保存中...' : '保存绑定' }}
{{ savingEmbedding ? t('wiki.saving') : t('common.save') }}
</button>
</div>
</div>
<!-- RFC-033: Step model strategy -->
<details class="config-section">
<summary class="section-toggle">{{ t('wiki.configPanel.modelStrategy') }}</summary>
<div class="step-models-grid">
<div v-for="step in stepKeys" :key="step" class="step-model-row">
<label class="step-label">{{ t(`wiki.configPanel.stepModel.${step}`) }}</label>
<select v-model="stepModels[step]" class="step-select">
<option value="">Global default</option>
<option v-for="m in chatModelOptions" :key="m.id" :value="String(m.id)">
{{ m.name }}
</option>
</select>
</div>
</div>
<div class="fallback-section">
<label class="step-label">{{ t('wiki.configPanel.fallbackModels') }}</label>
<div class="fallback-list">
<span v-for="(fId, idx) in fallbackModelIds" :key="idx" class="fallback-tag">
{{ chatModelOptions.find(m => String(m.id) === String(fId))?.name || fId }}
<button class="fallback-remove" @click="fallbackModelIds.splice(idx, 1)">×</button>
</span>
<select class="fallback-add-select" @change="addFallback($event)">
<option value="">+ Add</option>
<option v-for="m in chatModelOptions" :key="m.id" :value="String(m.id)">
{{ m.name }}
</option>
</select>
</div>
</div>
<button class="btn-secondary btn-sm" @click="saveStepModels" :disabled="savingStepModels">
{{ savingStepModels ? t('wiki.saving') : t('common.save') }}
</button>
</details>
<!-- Config editor -->
<textarea
v-model="configContent"
class="config-editor"
@ -37,14 +72,18 @@
{{ saving ? t('wiki.saving') : t('common.save') }}
</button>
</div>
<!-- RFC-033: Search preview -->
<WikiSearchPreview v-if="store.currentKB" :kb-id="store.currentKB.id" />
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi, modelApi } from '@/api/index'
import WikiSearchPreview from './WikiSearchPreview.vue'
const { t } = useI18n()
const store = useWikiStore()
@ -52,12 +91,19 @@ const store = useWikiStore()
const configContent = ref('')
const saving = ref(false)
// RFC Embedding UI: KB embedding
interface EmbeddingOption { id: string | number; name: string; modelName: string }
// Embedding binding
interface ModelOption { id: string | number; name: string; modelName: string }
const embeddingModelId = ref<string>('')
const embeddingOptions = ref<EmbeddingOption[]>([])
const embeddingOptions = ref<ModelOption[]>([])
const savingEmbedding = ref(false)
// Step model strategy
const stepKeys = ['route', 'create_page', 'merge_page', 'enrich', 'summary']
const stepModels = reactive<Record<string, string>>({})
const fallbackModelIds = ref<string[]>([])
const chatModelOptions = ref<ModelOption[]>([])
const savingStepModels = ref(false)
async function loadEmbeddingOptions() {
try {
const res = await modelApi.listByType('embedding')
@ -67,6 +113,15 @@ async function loadEmbeddingOptions() {
}
}
async function loadChatModelOptions() {
try {
const res = await modelApi.listByType('chat')
chatModelOptions.value = ((res.data as any[]) || []).filter(m => m.enabled !== false)
} catch (e) {
console.error('[WikiConfig] Failed to load chat model options', e)
}
}
function loadEmbeddingBinding() {
const kb: any = store.currentKB
embeddingModelId.value = kb?.embeddingModelId ? String(kb.embeddingModelId) : ''
@ -79,7 +134,6 @@ async function saveEmbeddingBinding() {
await wikiApi.updateKB(store.currentKB.id, {
embeddingModelId: embeddingModelId.value === '' ? null : embeddingModelId.value,
})
// store KB
const kb: any = store.currentKB
kb.embeddingModelId = embeddingModelId.value === '' ? null : Number(embeddingModelId.value)
} catch (e) {
@ -89,6 +143,67 @@ async function saveEmbeddingBinding() {
}
}
function loadStepModels() {
// Parse from KB configContent if it contains stepModels
stepKeys.forEach(k => stepModels[k] = '')
fallbackModelIds.value = []
if (!store.currentKB) return
try {
const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null
if (cfg?.stepModels) {
for (const key of stepKeys) {
const fullKey = `heavy_ingest.${key}`
if (cfg.stepModels[fullKey]) stepModels[key] = String(cfg.stepModels[fullKey])
}
}
if (cfg?.fallbackModelIds) {
fallbackModelIds.value = cfg.fallbackModelIds.map(String)
}
} catch { /* config might not be JSON */ }
}
async function saveStepModels() {
if (!store.currentKB) return
savingStepModels.value = true
try {
// Build stepModels map
const stepMap: Record<string, number> = {}
for (const key of stepKeys) {
if (stepModels[key]) {
stepMap[`heavy_ingest.${key}`] = Number(stepModels[key])
}
}
// Merge into existing config
let existingConfig: any = {}
try {
if (store.currentKB.configContent) {
existingConfig = JSON.parse(store.currentKB.configContent)
}
} catch { /* not JSON, will overwrite */ }
existingConfig.stepModels = Object.keys(stepMap).length > 0 ? stepMap : undefined
existingConfig.fallbackModelIds = fallbackModelIds.value.length > 0
? fallbackModelIds.value.map(Number)
: undefined
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
} catch (e) {
console.error('[WikiConfig] Failed to save step models', e)
} finally {
savingStepModels.value = false
}
}
function addFallback(event: Event) {
const select = event.target as HTMLSelectElement
const val = select.value
if (val && !fallbackModelIds.value.includes(val)) {
fallbackModelIds.value.push(val)
}
select.value = ''
}
async function loadConfig() {
if (!store.currentKB) return
try {
@ -114,9 +229,11 @@ async function saveConfig() {
watch(() => store.currentKB, () => {
loadConfig()
loadEmbeddingBinding()
loadStepModels()
}, { immediate: true })
loadEmbeddingOptions()
loadChatModelOptions()
</script>
<style scoped>
@ -128,7 +245,6 @@ loadEmbeddingOptions()
min-height: 0;
}
/* Header */
.config-header { padding-bottom: 10px; border-bottom: 1px solid var(--mc-border-light); }
.config-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 6px; letter-spacing: -0.02em; }
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
@ -142,35 +258,39 @@ loadEmbeddingOptions()
.embedding-select:focus { border-color: var(--mc-primary); }
.embedding-select:disabled { opacity: 0.6; cursor: not-allowed; }
/* Step model strategy */
.config-section { padding: 12px 14px; background: var(--mc-bg-sunken); border-radius: 10px; border: 1px solid var(--mc-border-light); }
.section-toggle { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); cursor: pointer; padding: 4px 0; }
.step-models-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.step-model-row { display: flex; align-items: center; gap: 12px; }
.step-label { font-size: 12px; color: var(--mc-text-secondary); min-width: 100px; }
.step-select { flex: 1; padding: 6px 10px; border: 1px solid var(--mc-border); border-radius: 6px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 12px; outline: none; }
.fallback-section { margin-top: 12px; }
.fallback-list { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; }
.fallback-tag { display: flex; align-items: center; gap: 4px; padding: 3px 8px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 6px; font-size: 11px; }
.fallback-remove { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 14px; padding: 0 2px; }
.fallback-remove:hover { color: var(--mc-danger); }
.fallback-add-select { padding: 4px 8px; border: 1px dashed var(--mc-border); border-radius: 6px; background: transparent; font-size: 11px; color: var(--mc-text-secondary); cursor: pointer; }
/* Editor */
.config-editor { width: 100%; flex: 1; min-height: 0; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: none; overflow: auto; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
.config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
/* Actions */
.config-actions { display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
/* Buttons */
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
@media (max-width: 768px) {
.config-editor {
min-height: 42vh;
flex: none;
resize: vertical;
}
.config-actions {
flex-direction: column-reverse;
}
.config-actions .btn-primary,
.config-actions .btn-secondary {
width: 100%;
justify-content: center;
}
.config-editor { min-height: 42vh; flex: none; resize: vertical; }
.config-actions { flex-direction: column-reverse; }
.config-actions .btn-primary, .config-actions .btn-secondary { width: 100%; justify-content: center; }
.step-model-row { flex-direction: column; align-items: flex-start; }
.step-label { min-width: 0; }
}
</style>

View File

@ -1,30 +1,11 @@
<template>
<div class="page-viewer" v-if="store.currentPage">
<!-- Header -->
<div class="page-viewer-header">
<div class="page-viewer-copy">
<div class="page-viewer-kicker">
<span class="kicker-dot" :class="store.currentPage.lastUpdatedBy === 'manual' ? 'manual' : 'ai'"></span>
{{ store.currentPage.lastUpdatedBy === 'ai' ? t('wiki.generatedByAi') : t('wiki.editedManually') }}
</div>
<h2 class="page-viewer-title">{{ store.currentPage.title }}</h2>
<div class="page-viewer-meta">
<span class="meta-badge">v{{ store.currentPage.version }}</span>
<span class="meta-slug">{{ store.currentPage.slug }}</span>
</div>
</div>
<div class="page-viewer-actions">
<button class="btn-secondary btn-sm" @click="editing = !editing">
{{ editing ? t('common.cancel') : t('common.edit') }}
</button>
<button v-if="editing" class="btn-primary btn-sm" @click="saveEdit">
{{ t('common.save') }}
</button>
<button v-if="!editing" class="btn-secondary btn-sm btn-delete" @click="handleDelete">
{{ t('common.delete') }}
</button>
</div>
</div>
<!-- RFC-033: Enhanced header with page_type, enrichment status, citations -->
<PageHeader
:page="store.currentPage"
@view-citations="citationDrawerOpen = true"
@enrich="handleEnrich"
/>
<!-- Summary Card -->
<div v-if="store.currentPage.summary && !editing" class="page-summary">
@ -32,12 +13,41 @@
<p class="summary-text">{{ store.currentPage.summary }}</p>
</div>
<!-- Actions bar -->
<div class="page-actions-bar">
<button class="btn-secondary btn-sm" @click="editing = !editing">
{{ editing ? t('common.cancel') : t('common.edit') }}
</button>
<button v-if="editing" class="btn-primary btn-sm" @click="saveEdit">
{{ t('common.save') }}
</button>
<button v-if="!editing" class="btn-secondary btn-sm btn-action" @click="handleEnrich">
<el-icon><Link /></el-icon>
{{ t('wiki.page.enrich') }}
</button>
<button v-if="!editing" class="btn-secondary btn-sm btn-action" @click="handleRepair">
<el-icon><SetUp /></el-icon>
{{ t('wiki.page.repair') }}
</button>
<button v-if="!editing" class="btn-secondary btn-sm btn-delete" @click="handleDelete">
{{ t('common.delete') }}
</button>
</div>
<!-- Content -->
<article v-if="!editing" class="page-content markdown-body" v-html="renderedContent"></article>
<textarea v-else v-model="editContent" class="page-editor" rows="30"></textarea>
<!-- Backlinks -->
<div v-if="backlinks.length > 0" class="backlinks-section">
<!-- RFC-033: Related Pages Panel (replaces backlinks) -->
<RelatedPagesPanel
v-if="!editing && store.currentKB"
:kb-id="store.currentKB.id"
:slug="store.currentPage.slug"
@navigate="openPage"
/>
<!-- Legacy backlinks (kept as fallback) -->
<div v-if="!editing && backlinks.length > 0" class="backlinks-section">
<h4 class="backlinks-title">{{ t('wiki.backlinks') }} ({{ backlinks.length }})</h4>
<div class="backlinks-list">
<span
@ -49,6 +59,20 @@
</span>
</div>
</div>
<!-- RFC-033: Citation drawer -->
<CitationDrawer
v-if="store.currentKB"
v-model="citationDrawerOpen"
:page-id="store.currentPage.id"
:kb-id="store.currentKB.id"
/>
<!-- Enrich toast -->
<div v-if="enrichToast" class="enrich-toast">
{{ enrichToast }}
<button class="toast-dismiss" @click="enrichToast = ''"></button>
</div>
</div>
</template>
@ -58,6 +82,10 @@ import { useI18n } from 'vue-i18n'
import { useWikiStore, type WikiPage } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { Link, SetUp } from '@element-plus/icons-vue'
import PageHeader from './PageHeader.vue'
import RelatedPagesPanel from './RelatedPagesPanel.vue'
import CitationDrawer from './CitationDrawer.vue'
const { t } = useI18n()
const store = useWikiStore()
@ -66,10 +94,11 @@ const { renderMarkdown } = useMarkdownRenderer()
const editing = ref(false)
const editContent = ref('')
const backlinks = ref<WikiPage[]>([])
const citationDrawerOpen = ref(false)
const enrichToast = ref('')
const renderedContent = computed(() => {
if (!store.currentPage?.content) return ''
// Pre-process wiki links [[title]] before markdown rendering
const content = store.currentPage.content.replace(/\[\[([^\]]+)\]\]/g, (_match, title) => {
const slug = title.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff\s-]/g, '').replace(/\s+/g, '-')
return `<a class="wiki-link" data-slug="${slug}" onclick="return false">${title}</a>`
@ -81,7 +110,6 @@ watch(() => store.currentPage, async (page) => {
if (page && store.currentKB) {
editing.value = false
editContent.value = page.content || ''
// Fetch backlinks
try {
const res: any = await wikiApi.getBacklinks(store.currentKB.id, page.slug)
backlinks.value = res.data || []
@ -111,12 +139,38 @@ async function handleDelete() {
}
}
async function handleEnrich() {
if (!store.currentKB || !store.currentPage) return
try {
await wikiApi.enrichPage(store.currentKB.id, store.currentPage.slug)
enrichToast.value = t('wiki.page.enrich') + '…'
setTimeout(() => { enrichToast.value = '' }, 5000)
} catch (e: any) {
console.error('[WikiViewer] Enrich failed:', e)
}
}
async function handleRepair() {
if (!store.currentKB || !store.currentPage) return
try {
await wikiApi.repairPage(store.currentKB.id, store.currentPage.slug)
enrichToast.value = t('wiki.page.repair') + '…'
setTimeout(async () => {
enrichToast.value = ''
if (store.currentKB && store.currentPage) {
await store.loadPage(store.currentKB.id, store.currentPage.slug)
}
}, 5000)
} catch (e: any) {
console.error('[WikiViewer] Repair failed:', e)
}
}
async function openPage(slug: string) {
if (!store.currentKB) return
await store.loadPage(store.currentKB.id, slug)
}
// Handle wiki link clicks via event delegation
onMounted(() => {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
@ -129,7 +183,6 @@ onMounted(() => {
</script>
<style scoped>
/* Buttons */
.page-viewer {
display: flex;
flex-direction: column;
@ -138,26 +191,15 @@ onMounted(() => {
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-primary.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-secondary.btn-delete { color: var(--el-color-danger, #f56c6c); }
.btn-secondary.btn-delete:hover { background: var(--el-color-danger-light-9, #fef0f0); border-color: var(--el-color-danger-light-5, #fab6b6); }
.btn-secondary.btn-action { color: var(--mc-primary); }
/* Header */
.page-viewer-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--mc-border-light); }
.page-viewer-copy { min-width: 0; }
.page-viewer-kicker { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--mc-text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.kicker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.kicker-dot.ai { background: var(--el-color-primary, #409eff); }
.kicker-dot.manual { background: var(--el-color-success, #67c23a); }
.page-viewer-title { font-size: clamp(24px, 3vw, 32px); line-height: 1.1; letter-spacing: -0.03em; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
.page-viewer-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 10px; margin-top: 10px; align-items: center; }
.meta-badge { padding: 2px 8px; background: var(--mc-bg-sunken); border-radius: 6px; font-weight: 600; font-size: 11px; }
.meta-slug { font-family: 'JetBrains Mono', monospace; font-size: 11px; opacity: 0.7; }
.page-viewer-actions { display: flex; gap: 8px; flex-shrink: 0; }
.page-actions-bar { display: flex; gap: 8px; flex-wrap: wrap; }
/* Summary */
.page-summary { padding: 16px 20px; background: var(--mc-bg-muted); border-radius: 12px; border-left: 3px solid var(--mc-primary); }
@ -190,30 +232,43 @@ onMounted(() => {
.backlinks-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--mc-border); }
.backlinks-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--mc-text-secondary); margin-bottom: 8px; letter-spacing: 0.05em; }
.backlinks-list { display: flex; flex-wrap: wrap; gap: 6px; }
.backlink-tag { padding: 5px 10px; background: var(--mc-bg-sunken); border-radius: 9999px; font-size: 12px; cursor: pointer; color: var(--mc-primary); transition: background 0.15s, transform 0.15s; }
.backlink-tag { padding: 5px 10px; background: var(--mc-bg-sunken); border-radius: 9999px; font-size: 12px; cursor: pointer; color: var(--mc-primary); transition: background 0.15s; }
.backlink-tag:hover { background: var(--mc-primary-bg); }
/* Enrich toast */
.enrich-toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 10px 16px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-primary);
border-radius: 12px;
font-size: 13px;
color: var(--mc-primary);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
display: flex;
align-items: center;
gap: 10px;
z-index: 999;
animation: toast-in 0.3s ease;
}
@keyframes toast-in {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.toast-dismiss {
border: none;
background: none;
cursor: pointer;
color: var(--mc-text-tertiary);
font-size: 14px;
}
@media (max-width: 768px) {
.page-viewer-header {
flex-direction: column;
}
.page-viewer-actions {
width: 100%;
}
.btn-primary.btn-sm,
.btn-secondary.btn-sm {
flex: 1;
justify-content: center;
}
.page-viewer-title {
font-size: 24px;
}
.page-editor {
min-height: 46vh;
}
.page-actions-bar { flex-direction: column; }
.page-actions-bar .btn-secondary,
.page-actions-bar .btn-primary { width: 100%; justify-content: center; }
.page-editor { min-height: 46vh; }
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<div class="search-preview">
<h4 class="preview-title">{{ t('wiki.configPanel.searchPreview') }}</h4>
<div class="preview-input-row">
<input
v-model="query"
type="text"
class="preview-input"
:placeholder="t('wiki.configPanel.searchPreviewPlaceholder')"
@keyup.enter="runSearch"
/>
<button class="btn-secondary" @click="runSearch" :disabled="searching || !query.trim()">
{{ searching ? '...' : t('wiki.configPanel.searchPreviewRun') }}
</button>
</div>
<div v-if="results.length > 0" class="preview-results">
<div v-for="r in results" :key="r.slug" class="preview-result-item">
<div class="result-header">
<span class="result-slug">[[{{ r.slug }}]]</span>
<span class="result-title">{{ r.title }}</span>
</div>
<div v-if="r.snippet" class="result-snippet">"{{ r.snippet }}"</div>
<div class="result-meta">
<span v-if="r.matchedBy?.length" class="result-matched">
{{ t('wiki.configPanel.searchPreviewRun') }}: {{ r.matchedBy.join(', ') }}
</span>
<span v-if="r.reason" class="result-reason">· {{ r.reason }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const props = defineProps<{
kbId: number
}>()
interface SearchResult {
slug: string
title: string
snippet?: string
matchedBy?: string[]
reason?: string
score: number
}
const query = ref('')
const results = ref<SearchResult[]>([])
const searching = ref(false)
async function runSearch() {
if (!query.value.trim() || !props.kbId) return
searching.value = true
try {
const res: any = await wikiApi.searchPreview(props.kbId, {
query: query.value.trim(),
mode: 'hybrid',
topK: 5,
})
results.value = res.data || res || []
} catch (e) {
console.error('[SearchPreview] Failed:', e)
results.value = []
} finally {
searching.value = false
}
}
</script>
<style scoped>
.search-preview {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: var(--mc-bg-sunken);
border-radius: 10px;
border: 1px solid var(--mc-border-light);
}
.preview-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.preview-input-row { display: flex; gap: 8px; }
.preview-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
font-size: 13px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
outline: none;
}
.preview-input:focus { border-color: var(--mc-primary); }
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.preview-results {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.preview-result-item {
padding: 10px 12px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 8px;
}
.result-header { display: flex; gap: 8px; align-items: center; margin-bottom: 4px; }
.result-slug { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--mc-primary); }
.result-title { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); }
.result-snippet { font-size: 12px; color: var(--mc-text-secondary); font-style: italic; line-height: 1.5; margin-bottom: 4px; max-height: 60px; overflow: hidden; }
.result-meta { display: flex; gap: 6px; font-size: 11px; color: var(--mc-text-tertiary); }
</style>

Some files were not shown because too many files have changed in this diff Show More