diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..969a0a3b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Git and IDE +.git +.idea +*.iml + +# Node artifacts +**/node_modules +**/dist +**/.nuxt +**/.output + +# Maven build output (will be rebuilt in Docker) +**/target + +# Desktop / webchat (not needed for server or sites build) +mateclaw-desktop + +# Data and logs +data +*.log + +# Misc +.env +*.md +!docs/**/*.md +!matevip-sites/**/*.md +!mateclaw-plugin-api/** +!mateclaw-server/** diff --git a/.env.example b/.env.example index ba9a3d1a..dbe2a616 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,10 @@ # MateClaw 环境变量配置 # 复制此文件为 .env 并填写实际值:cp .env.example .env # +# LLM API Key(DashScope、OpenAI 等)无需在此配置,启动后在管理界面「模型管理」中添加。 +# # ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。 -# ==================== LLM / 搜索 ==================== - -# 阿里云 DashScope API Key(必填) -# 申请地址:https://dashscope.aliyun.com/ -DASHSCOPE_API_KEY=your-dashscope-api-key-here - -# Serper 网页搜索 API Key(可选,用于 WebSearch 工具) -# 申请地址:https://serper.dev/ -SERPER_API_KEY= - # ==================== 数据库(Docker 模式必填) ==================== DB_HOST=localhost @@ -36,3 +28,29 @@ JWT_SECRET= # CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。 # 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。 MATECLAW_CORS_ALLOWED_ORIGINS= + +# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。 +# openssl rand -hex 32 +SEARXNG_SECRET= + +# ==================== 浏览器工具(可选) ==================== +# +# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。 +# 只有在下述场景才需要 override: +# +# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接: +# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222 +# +# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome): +# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable +# +# 3) 强制使用 Playwright channel(chrome / msedge / chrome-beta …): +# MATECLAW_BROWSER_CHANNEL=chrome +MATECLAW_BROWSER_CDP_URL= +MATECLAW_BROWSER_CHROME_PATH= +MATECLAW_BROWSER_CHANNEL= + +# ── Maven 镜像(国内加速)───────────────────────────────────────── +# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。 +# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。 +#MAVEN_FLAGS=-Paliyun-first diff --git a/.github/ISSUE_TEMPLATE/bug-en.yml b/.github/ISSUE_TEMPLATE/bug-en.yml new file mode 100644 index 00000000..0e2afc36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-en.yml @@ -0,0 +1,74 @@ +name: "🐛 Bug Report (English)" +description: Report something that's broken. Three required fields — fill them and submit. +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. Three things, that's it — any less and we can't locate it; any more wastes your time. + + > **Issues without a screenshot, log, or repro steps will be closed.** Not because we don't care — we genuinely can't fix what we can't reproduce. + + - type: textarea + id: what + attributes: + label: What broke? (required, attach screenshot) + description: | + One sentence describing the symptom + at least one screenshot (drag it into the text box). + If it's a backend error, paste the stack trace here too (wrapped in ```). + placeholder: | + Example: As a `member`-role user in ws-b, I clicked "Create from template". The new Agent appeared in the default workspace instead of ws-b. + + [drag in screenshot / screen recording] + + [paste backend stack trace or frontend console error] + validations: + required: true + + - type: textarea + id: repro + attributes: + label: How to reproduce? (required, numbered steps) + description: | + Steps that someone with zero context can follow. **A symptom you can't reproduce is a guess, not a bug.** + placeholder: | + 1. Log in as admin / admin123, create workspace ws-b + 2. Add user bob as ws-b member + 3. Log out, log back in as bob, switch UI to ws-b + 4. Go to Agents → "Create from template" → pick assistant → apply + 5. Switch to default workspace — the Agent shows up here + validations: + required: true + + - type: dropdown + id: module + attributes: + label: Affected module (optional, multi-select) + description: Which part of the system? Skip if unsure — helps maintainers triage. + multiple: true + options: + - Backend / 后端 + - Frontend (admin UI) / 前端 + - Desktop / 桌面端 + - Webchat embed widget + - Channel (DingTalk / Feishu / Telegram / Discord / QQ / Slack ...) + - Tool / 工具 + - Skill / 技能 + - Wiki / 知识库 + - Memory / 记忆 + - Agent / StateGraph runtime + - Auth / Workspace permission + - Deployment / DB migration + - Other + validations: + required: false + + - type: input + id: env + attributes: + label: Environment (required, one line) + description: version / workspace role / browser or client. One line. + placeholder: "v0.x.y / member / Chrome 130 on macOS 14.5" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/bug-zh.yml b/.github/ISSUE_TEMPLATE/bug-zh.yml new file mode 100644 index 00000000..e859f71e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-zh.yml @@ -0,0 +1,74 @@ +name: "🐛 Bug 报告(中文)" +description: 报告一个不工作的功能。三个必填项,写完就交。 +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 感谢花时间反馈。三件事,写完就好——少一件我们没法定位,多一件浪费你时间。 + + > **没截图、没日志、没步骤的 issue 我们会直接关掉**,不是不在乎,是真的修不了。 + + - type: textarea + id: what + attributes: + label: 出了什么问题?(必填,附截图) + description: | + 一句话说清现象 + 至少一张截图(直接拖进文本框即可)。 + 如果是后端报错,把后端日志也贴这里(用 ``` 包起来)。 + placeholder: | + 例:作为 member 角色用户,在 ws-b 工作区点「从模板创建」,新建出来的 Agent 出现在了默认工作区,不在 ws-b。 + + [拖入截图 / 录屏] + + [贴出后端 stack trace 或前端 console error] + validations: + required: true + + - type: textarea + id: repro + attributes: + label: 怎么复现?(必填,编号步骤) + description: | + 让一个完全不知情的人能按步骤复现。**说不出步骤的现象不是 bug,是猜想。** + placeholder: | + 1. 用 admin / admin123 登录,新建工作区 ws-b + 2. 添加用户 bob 为 ws-b 的 member + 3. 注销,用 bob 登录,前端切到 ws-b + 4. 点 Agents 页面 → 「从模板创建」 → 选 assistant → 应用 + 5. 切回默认工作区,看到 Agent 出现在了这里 + validations: + required: true + + - type: dropdown + id: module + attributes: + label: 影响模块(选填,多选) + description: 大致是哪一块?不确定就空着,方便维护者打 label。 + multiple: true + options: + - 后端 / Backend + - 前端 / Frontend (admin UI) + - 桌面端 / Desktop + - Webchat 嵌入组件 + - Channel(钉钉/飞书/Telegram/Discord/QQ/Slack...) + - Tool / 工具 + - Skill / 技能 + - Wiki / 知识库 + - Memory / 记忆 + - Agent / StateGraph 运行时 + - Auth / 工作区权限 + - 部署 / 数据库迁移 + - 其它 + validations: + required: false + + - type: input + id: env + attributes: + label: 环境(必填,一行) + description: 版本 / 工作区角色 / 浏览器或客户端。一行写完。 + placeholder: "v0.x.y / member / Chrome 130 macOS 14.5" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ef3ad23b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 使用问题先看文档 / Check the docs first + url: https://claw.mate.vip/docs + about: 安装、配置、用法问题文档里大多有答案 / Install, config, and usage questions are usually answered in the docs. + - name: 🔒 安全漏洞私下报告 / Report security issues privately + url: https://github.com/matevip/mateclaw/security/advisories/new + about: 安全相关问题请走 Security Advisory,不要开公开 issue / Please use Security Advisory for security-related issues, don't open a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature-en.yml b/.github/ISSUE_TEMPLATE/feature-en.yml new file mode 100644 index 00000000..9ba4cae8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-en.yml @@ -0,0 +1,41 @@ +name: "✨ Feature Request (English)" +description: Propose a new feature or improvement. Start with why, then what. +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + The key to a new feature is not "what it is" but "**who suffers without it, and how**". + + If you can't articulate who would use it and why, the feature probably shouldn't be built. + + - type: textarea + id: why + attributes: + label: What problem are you solving? (required) + description: | + Describe a real scenario. **Don't jump to "add an XX button"** — first explain why you need that button, and what hurts without it. + placeholder: | + Example: I switch the default model for 5 different Agents every day, and each switch takes 3 clicks in the settings page. + A global "quick switch default model" menu would save me 30 clicks a day. + validations: + required: true + + - type: textarea + id: what + attributes: + label: How should it work? (required) + description: | + A paragraph or a few bullets. If you can sketch it or share a mockup, even better (drag in images). + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you've tried? (optional) + description: | + If you can't think of any, leave it blank. **Don't invent content just to fill the field.** + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature-zh.yml b/.github/ISSUE_TEMPLATE/feature-zh.yml new file mode 100644 index 00000000..ded8f32d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-zh.yml @@ -0,0 +1,41 @@ +name: "✨ 功能建议(中文)" +description: 提一个新功能或改进。先讲为什么,再讲是什么。 +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + 新功能的关键不是"它是什么",是"**没有它,谁在受什么苦**"。 + + 如果你说不清谁会用、为什么用,这个功能大概率不该做。 + + - type: textarea + id: why + attributes: + label: 你在解决什么问题?(必填) + description: | + 描述真实场景。**不要直接写"应该加一个 XX 按钮"** —— 先说为什么要这个按钮、不加会怎样。 + placeholder: | + 例:我每天要给 5 个不同的 Agent 切换默认模型,每次都要进设置页改 3 处。 + 如果有一个"快速切换默认模型"的全局菜单,我每天能少点 30 次鼠标。 + validations: + required: true + + - type: textarea + id: what + attributes: + label: 你期望它怎么工作?(必填) + description: | + 一段话或几个 bullet。如果你能画个草图、贴个 mockup,更好(直接拖图)。 + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 你试过的替代方案?(选填) + description: | + 如果想不到替代方案,就空着。**不要为了填字段而瞎写。** + validations: + required: false diff --git a/.gitignore b/.gitignore index 7ae1acfc..6c24bfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -92,5 +92,9 @@ deploy/nginx/ssl/*.pem deploy/.env # Claude Code local settings +CLAUDE.md .claude/settings.local.json .claude/plans/ + +# Codex CLI local artifacts +.codex/ diff --git a/README.md b/README.md index 9a157378..5c25f7f5 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ # MateClaw -

Build AI that thinks, acts, remembers, and ships.

+

Your second brain

[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) -[![Java Version](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) +[![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) [![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) @@ -28,123 +28,110 @@ --- -An AI agent. A knowledge engine. A memory system. A tool runtime. A multi-channel presence. +> **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.** +> +> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress. -**One product. The whole widget.** +Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product. -MateClaw is a personal AI operating system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). It's not a chatbox, not a workflow builder, not just another coding assistant. It's the entire system — from reasoning to remembering to shipping — in one deployment. +**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence. -Three things make it insanely different: +--- -1. **Agents do work, not just talk** — ReAct + Plan-and-Execute. Not one-shot answers — iterative reasoning that actually completes tasks -2. **Knowledge is shaped, not just stored** — An LLM Wiki that digests raw material into structured, linked pages. The difference between a warehouse and a library -3. **End-to-end, no compromises** — Web console, desktop app, 7 IM channels, tool guardrails, enterprise auth. One team, one deployment, one experience +## Three things that make it different + +### 1 · Your AI doesn't die when a model does + +Primary key expired. Vendor returns 401. Network blip. Quota drained. + +Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn. + +You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time. + +### 2 · Knowledge that links itself + +Upload a PDF, a batch of markdown, a scraped page — raw material in. + +MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify. + +This is the difference between a warehouse and a library. + +### 3 · One product, five surfaces + +| Surface | What it is | +|---|---| +| **Web Console** | Full admin — agents, models, tools, skills, knowledge, security, cron | +| **Desktop** | Electron app with a bundled JRE 21. Double-click, run. No Java install | +| **Webchat Widget** | One ` + """) + void bumpUseCount(@Param("ids") List ids, @Param("now") LocalDateTime now); + + /** + * Soft-delete facts whose source_ref is no longer in the canonical set. + * Used during full rebuild to remove stale projections. + */ + @Update(""" + + """) + void deleteByAgentIdAndSourceRefNotIn(@Param("agentId") Long agentId, + @Param("keepSet") List keepSet, + @Param("now") LocalDateTime now); +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java new file mode 100644 index 00000000..30aea726 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java @@ -0,0 +1,79 @@ +package vip.mate.memory.fact.tool; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.fact.model.FactContradictionEntity; +import vip.mate.memory.fact.model.FactEntity; +import vip.mate.memory.fact.query.FactQueryService; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Agent tools for querying the fact projection. + * Read-only — no fact_add / fact_remove / fact_update tools (core invariant D1). + * + * @author MateClaw Team + */ +@Component +@RequiredArgsConstructor +public class FactQueryTool { + + private final FactQueryService queryService; + private final MemoryProperties properties; + + @Tool(description = "Probe facts about an entity. Returns relevant facts where the entity appears as subject or object.") + public String fact_probe( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Entity name to search for") String entity) { + if (!properties.getFact().isProjectionEnabled()) { + return "Fact projection is disabled."; + } + List facts = queryService.probe(agentId, entity); + if (facts.isEmpty()) return "No facts found for entity: " + entity; + + // Bump use count + queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList()); + + return facts.stream() + .map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust())) + .collect(Collectors.joining("\n")); + } + + @Tool(description = "Find facts related to an entity via entity references (multi-hop graph query).") + public String fact_related( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Entity name") String entity, + @ToolParam(description = "Number of hops (1-3)") int hops) { + if (!properties.getFact().isProjectionEnabled()) { + return "Fact projection is disabled."; + } + List facts = queryService.related(agentId, entity, Math.min(hops, 3)); + if (facts.isEmpty()) return "No related facts found for: " + entity; + + queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList()); + + return facts.stream() + .map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust())) + .collect(Collectors.joining("\n")); + } + + @Tool(description = "List unresolved fact contradictions detected during Dream consolidation.") + public String fact_list_contradictions( + @ToolParam(description = "Agent ID") Long agentId) { + if (!properties.getFact().isProjectionEnabled()) { + return "Fact projection is disabled."; + } + List contradictions = queryService.listContradictions(agentId); + if (contradictions.isEmpty()) return "No unresolved contradictions."; + + return contradictions.stream() + .map(c -> String.format("- Contradiction #%d: factA=%d vs factB=%d — %s", + c.getId(), c.getFactAId(), c.getFactBId(), + c.getDescription() != null ? c.getDescription() : "")) + .collect(Collectors.joining("\n")); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java new file mode 100644 index 00000000..72c397bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java @@ -0,0 +1,42 @@ +package vip.mate.memory.lifecycle; + +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.memory.MemoryProperties; +import vip.mate.memory.event.ConversationCompletedEvent; + +/** + * Dispatches ConversationCompletedEvent to MemoryManager.onSessionEnd unconditionally. + * + *

Contract: every successfully-persisted conversation end must reach all memory + * providers, regardless of whether summarization / nudge preconditions held. + * + *

This is a separate listener from PostConversationMemoryListener on purpose — + * that one has four early returns tied to summarize/nudge heuristics. Those are fine + * for summarize/nudge business logic, but none of them are appropriate gates for + * provider-level session-end signals (rfc-037 §3.7, decision D10). + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemoryLifecycleEventListener { + + private final MemoryLifecycleMediator mediator; + private final MemoryProperties props; + + @Async + @EventListener + public void onConversationCompleted(ConversationCompletedEvent event) { + if (!props.isLifecycleMediatorEnabled()) return; + try { + mediator.onSessionEnd(event.agentId(), event.conversationId()); + } catch (Exception e) { + log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java new file mode 100644 index 00000000..eb5e1e30 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java @@ -0,0 +1,84 @@ +package vip.mate.memory.lifecycle; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.memory.spi.MemoryManager; + +/** + * Mediator between the Agent entry-layer (AgentService) and MemoryManager. + * Agent code only calls this class; it hides the details of when/how + * providers are invoked across a turn's lifecycle. + * + *

Non-goals: + *

    + *
  • Does NOT call MemoryRecallTracker.trackRecalls — AgentService already + * owns that call; duplicating here would double recall_count / daily_count + * and pollute Dream scoring (rfc-037 F4).
  • + *
  • Does NOT prefetch next-turn recall keyed on current-turn query — + * query-conditioned providers cannot reuse stale queries (rfc-037 F2).
  • + *
+ * + *

Thread-safety: all public methods are reentrant; per-turn state lives + * in {@link TurnContext}. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemoryLifecycleMediator { + + private final MemoryManager memoryManager; + private final ApplicationEventPublisher events; + + /** + * Called BEFORE the LLM is invoked for a turn. + * Returns the memory-context block to inject, or "" if none. + * + *

Latency contract: synchronous. BuiltinMemoryProvider returns "" so the + * only cost today is iteration overhead (<5ms). + */ + public String beforeLlmCall(TurnContext ctx) { + try { + String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery()); + events.publishEvent(new TurnStartedEvent(ctx)); + log.debug("[Memory] beforeLlmCall: agent={}, contextLen={}", ctx.agentId(), + context != null ? context.length() : 0); + return context; + } catch (Exception e) { + log.debug("[Memory] beforeLlmCall failed (non-fatal): {}", e.getMessage()); + return ""; + } + } + + /** + * Called AFTER the LLM finishes a turn successfully. + * Non-blocking: MemoryManager.syncAll dispatches to provider.syncTurn(), + * each provider is responsible for being async internally. + */ + public void afterLlmCall(TurnContext ctx, String assistantReply) { + try { + memoryManager.syncAll(ctx.agentId(), ctx.conversationId(), + ctx.userQuery(), assistantReply); + events.publishEvent(new TurnCompletedEvent(ctx, assistantReply)); + log.debug("[Memory] afterLlmCall: agent={}, conv={}, replyLen={}", ctx.agentId(), + ctx.conversationId(), assistantReply != null ? assistantReply.length() : 0); + } catch (Exception e) { + log.debug("[Memory] afterLlmCall failed (non-fatal): {}", e.getMessage()); + } + } + + /** + * Called when a conversation ends (from MemoryLifecycleEventListener). + */ + public void onSessionEnd(Long agentId, String conversationId) { + try { + memoryManager.onSessionEnd(agentId, conversationId); + log.debug("[Memory] onSessionEnd: agent={}, conv={}", agentId, conversationId); + } catch (Exception e) { + log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java new file mode 100644 index 00000000..690bea8c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java @@ -0,0 +1,10 @@ +package vip.mate.memory.lifecycle; + +/** + * Published after syncAll completes for a turn. + * + * @param context the turn context + * @param assistantReply the LLM response text + * @author MateClaw Team + */ +public record TurnCompletedEvent(TurnContext context, String assistantReply) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java new file mode 100644 index 00000000..d7b6f4c2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java @@ -0,0 +1,19 @@ +package vip.mate.memory.lifecycle; + +/** + * Minimal turn-scoped context; built once per turn at AgentService level. + * + * @param agentId the agent ID + * @param conversationId the conversation ID + * @param sessionId session ID (may equal conversationId in Phase 1) + * @param turnNumber turn sequence number within the conversation + * @param userQuery the current user message + * @author MateClaw Team + */ +public record TurnContext( + Long agentId, + String conversationId, + String sessionId, + int turnNumber, + String userQuery +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java new file mode 100644 index 00000000..c92faadb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java @@ -0,0 +1,9 @@ +package vip.mate.memory.lifecycle; + +/** + * Published after prefetchAll completes, before the LLM call. + * + * @param context the turn context + * @author MateClaw Team + */ +public record TurnStartedEvent(TurnContext context) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java new file mode 100644 index 00000000..b912bd87 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/DreamReportEntity.java @@ -0,0 +1,62 @@ +package vip.mate.memory.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Dream report entity — persists each dream consolidation run. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_dream_report") +public class DreamReportEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long agentId; + + /** NIGHTLY | FOCUSED */ + private String mode; + + /** Topic hint for FOCUSED mode; null for NIGHTLY */ + private String topic; + + /** cron | user | api */ + private String triggerSource; + + /** userId or "system" */ + private String triggeredBy; + + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; + + private Integer candidateCount; + + private Integer promotedCount; + + private Integer rejectedCount; + + /** Diff between old and new MEMORY.md */ + private String memoryDiff; + + /** LLM explanation (first 500 chars) */ + private String llmReason; + + /** SUCCESS | FAILED | SKIPPED */ + private String status; + + private String errorMessage; + + @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/memory/model/MemoryRecallEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java index c5bf87b7..3902e95e 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MemoryRecallEntity.java @@ -50,12 +50,17 @@ public class MemoryRecallEntity { /** 是否已提升到 MEMORY.md */ private Boolean promoted; + /** Times this candidate was reviewed but not promoted (Dream v2, Phase 1 write-only) */ + private Integer reviewCount; + + /** Last time this candidate was reviewed during a dream run */ + private LocalDateTime lastReviewedAt; + @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/memory/model/MorningCardSeenEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java new file mode 100644 index 00000000..df388dfe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/model/MorningCardSeenEntity.java @@ -0,0 +1,33 @@ +package vip.mate.memory.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Morning card seen state — tracks per (user, agent) whether the card was dismissed. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_morning_card_seen") +public class MorningCardSeenEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long agentId; + + private LocalDateTime lastSeenAt; + + private Long lastReportId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java new file mode 100644 index 00000000..aad533c4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/DreamReportMapper.java @@ -0,0 +1,14 @@ +package vip.mate.memory.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.memory.model.DreamReportEntity; + +/** + * Mapper for mate_dream_report table. + * + * @author MateClaw Team + */ +@Mapper +public interface DreamReportMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java new file mode 100644 index 00000000..92c645b2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/repository/MorningCardSeenMapper.java @@ -0,0 +1,9 @@ +package vip.mate.memory.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.memory.model.MorningCardSeenEntity; + +@Mapper +public interface MorningCardSeenMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java index c10ba4f8..58a34fb1 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java @@ -52,7 +52,7 @@ public class DreamingScheduler { continue; } try { - emergenceService.consolidate(agent.getId()); + emergenceService.consolidate(agent.getId(), vip.mate.memory.service.DreamMode.NIGHTLY, null); success++; } catch (Exception e) { failed++; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java new file mode 100644 index 00000000..06f8ac0b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamMode.java @@ -0,0 +1,11 @@ +package vip.mate.memory.service; + +/** + * Dream consolidation modes. Phase 1: NIGHTLY + FOCUSED only. + * + * @author MateClaw Team + */ +public enum DreamMode { + NIGHTLY, + FOCUSED +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java new file mode 100644 index 00000000..38bac953 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamReport.java @@ -0,0 +1,29 @@ +package vip.mate.memory.service; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Structured dream consolidation result returned by consolidate(). + * + * @author MateClaw Team + */ +public record DreamReport( + Long id, + Long agentId, + DreamMode mode, + String topic, + String triggerSource, + String triggeredBy, + LocalDateTime startedAt, + LocalDateTime finishedAt, + int candidateCount, + int promotedCount, + int rejectedCount, + String memoryDiff, + String llmReason, + DreamStatus status, + String errorMessage, + List promoted, + List rejected +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java new file mode 100644 index 00000000..5196507b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/DreamStatus.java @@ -0,0 +1,12 @@ +package vip.mate.memory.service; + +/** + * Dream consolidation result status. + * + * @author MateClaw Team + */ +public enum DreamStatus { + SUCCESS, + FAILED, + SKIPPED +} 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 aff44dcf..bfa545e8 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 @@ -9,25 +9,31 @@ 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.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.memory.event.DreamCompletedEvent; +import vip.mate.memory.event.DreamFailedEvent; +import vip.mate.memory.event.MemoryWriteEvent; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.model.DreamReportEntity; import vip.mate.memory.model.MemoryRecallEntity; +import vip.mate.memory.repository.DreamReportMapper; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; -import java.util.Comparator; -import java.util.List; +import java.time.LocalDateTime; +import java.util.*; import java.util.stream.Collectors; /** - * 记忆整合服务 + * Memory emergence (dream) service. *

- * 读取近 N 天的 daily notes,提炼反复出现的模式和重要信息, - * 合并到 MEMORY.md 中。 + * Reads daily notes + scored recall candidates, invokes LLM to consolidate + * recurring patterns into MEMORY.md, and produces a structured DreamReport. * * @author MateClaw Team */ @@ -42,19 +48,36 @@ public class MemoryEmergenceService { private final MemoryProperties properties; private final ObjectMapper objectMapper; private final MemoryRecallService recallService; + private final DreamReportMapper dreamReportMapper; + private final vip.mate.memory.archive.MemoryArchiveService archiveService; + private final ApplicationEventPublisher eventPublisher; + private final vip.mate.memory.fact.contradiction.ContradictionDetector contradictionDetector; /** - * 执行记忆整合:将 daily notes 中的重复模式提炼到 MEMORY.md + * Legacy signature — delegates to NIGHTLY mode for backward compatibility. + */ + public DreamReport consolidate(Long agentId) { + return consolidate(agentId, DreamMode.NIGHTLY, null); + } + + /** + * Execute memory consolidation with the specified mode and optional topic. * * @param agentId Agent ID + * @param mode NIGHTLY or FOCUSED + * @param topic topic hint for FOCUSED mode (null for NIGHTLY) + * @return structured DreamReport (never null) */ - public void consolidate(Long agentId) { + public DreamReport consolidate(Long agentId, DreamMode mode, String topic) { + LocalDateTime startedAt = LocalDateTime.now(); + String triggerSource = mode == DreamMode.NIGHTLY ? "cron" : "user"; + if (!properties.isEmergenceEnabled()) { log.debug("[Memory] Emergence is disabled, skipping for agent={}", agentId); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "emergence disabled"); } - // 1. 列出所有 memory/*.md 文件 + // 1. Load daily notes List allFiles = workspaceFileService.listFiles(agentId); List dailyFilenames = allFiles.stream() .map(WorkspaceFileEntity::getFilename) @@ -65,13 +88,11 @@ public class MemoryEmergenceService { if (dailyFilenames.isEmpty()) { log.info("[Memory] No daily notes found for agent={}, skipping emergence", agentId); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "no daily notes"); } - // 2. 批量读取 daily notes 内容(避免 N+1 查询) StringBuilder dailyNotesBuilder = new StringBuilder(); for (String filename : dailyFilenames) { - // TODO: 未来可优化为 IN 批量查询,当前 listFiles() 会清除 content 字段 WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); if (file != null && file.getContent() != null && !file.getContent().isBlank()) { dailyNotesBuilder.append("### ").append(filename).append("\n"); @@ -82,41 +103,28 @@ public class MemoryEmergenceService { if (dailyNotes.isEmpty()) { log.info("[Memory] All daily notes are empty for agent={}, skipping emergence", agentId); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "all daily notes empty"); } - // 3. 读取现有 MEMORY.md - String memoryContent = readFileContentSafe(agentId, "MEMORY.md"); + // 2. Read existing MEMORY.md (for diff later) + String oldMemoryContent = readFileContentSafe(agentId, "MEMORY.md"); - // 4. 计算召回评分(必须在 resetDailyCounts 之前,否则 velocity 信号被清零) + // 3. Score candidates (must happen before resetDailyCounts) List scoredCandidates = recallService.computeScores(agentId); boolean hasScoredCandidates = !scoredCandidates.isEmpty(); - // 5. 评分快照完成后再重置 dailyCount,为下一轮积累 + // 4. Reset daily counts for next accumulation cycle recallService.resetDailyCounts(agentId); + // 5. Build prompt based on mode String systemPrompt = PromptLoader.loadPrompt("memory/emergence-system"); - String userPrompt; + String userPrompt = buildUserPrompt(mode, topic, oldMemoryContent, scoredCandidates, + hasScoredCandidates, dailyNotes); - if (hasScoredCandidates) { - // 使用评分增强的 prompt - String candidatesText = formatScoredCandidates(scoredCandidates); - String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user"); - userPrompt = userTemplate - .replace("{memory}", memoryContent) - .replace("{scored_candidates}", candidatesText) - .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) - .replace("{daily_notes}", dailyNotes); - log.info("[Memory] Emergence with {} scored candidates for agent={}", scoredCandidates.size(), agentId); - } else { - // 冷启动:回退到原有纯 LLM 逻辑 - String userTemplate = PromptLoader.loadPrompt("memory/emergence-user"); - userPrompt = userTemplate - .replace("{memory}", memoryContent) - .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) - .replace("{daily_notes}", dailyNotes); - } + log.info("[Memory] Emergence {} with {} candidates for agent={}, topic={}", + mode, scoredCandidates.size(), agentId, topic); + // 6. Call LLM String llmResponse; try { ChatModel chatModel = buildChatModel(); @@ -128,69 +136,155 @@ public class MemoryEmergenceService { llmResponse = response.getResult().getOutput().getText(); } catch (Exception e) { log.warn("[Memory] Emergence LLM call failed for agent={}: {}", agentId, e.getMessage()); - return; + return buildFailedReport(agentId, mode, topic, triggerSource, startedAt, + scoredCandidates.size(), e.getMessage()); } - // 5. 解析并应用 + // 7. Parse and apply try { JsonNode root = parseJsonResponse(llmResponse); if (root == null || !root.path("should_update").asBoolean(false)) { String reason = root != null ? root.path("reason").asText("") : "parse failed"; log.info("[Memory] No emergence update needed for agent={}: {}", agentId, reason); - return; + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, reason); } JsonNode memoryNode = root.path("memory_content"); - if (!memoryNode.isNull() && memoryNode.isTextual()) { - String newContent = memoryNode.asText().trim(); - if (!newContent.isEmpty()) { - workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); - String reason = root.path("reason").asText(""); - log.info("[Memory] Emergence completed for agent={}: {}", agentId, reason); + if (memoryNode.isNull() || !memoryNode.isTextual()) { + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "no memory_content in response"); + } - // 逐候选检查:只有内容被 LLM 实际采纳(出现在新 MEMORY.md 中)的才标记为已提升 - if (hasScoredCandidates) { - List promotedIds = scoredCandidates.stream() - .filter(c -> candidateAdoptedInMemory(c, newContent)) - .map(MemoryRecallEntity::getId) - .collect(Collectors.toList()); - if (!promotedIds.isEmpty()) { - recallService.markPromoted(promotedIds); - } - log.info("[Memory] Promoted {}/{} recall candidates for agent={}", - promotedIds.size(), scoredCandidates.size(), agentId); + String newContent = memoryNode.asText().trim(); + if (newContent.isEmpty()) { + return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content"); + } - // 写入 DREAMS.md 整合日记 - appendDreamDiary(agentId, scoredCandidates, promotedIds); + workspaceFileService.saveFile(agentId, "MEMORY.md", newContent); + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent)); + String llmReason = root.path("reason").asText(""); + log.info("[Memory] Emergence completed for agent={}: {}", agentId, llmReason); + + // Determine promoted vs rejected candidates + List promotedEntries = new ArrayList<>(); + List rejectedEntries = new ArrayList<>(); + + if (hasScoredCandidates) { + Set promotedIds = new HashSet<>(); + for (MemoryRecallEntity c : scoredCandidates) { + if (candidateAdoptedInMemory(c, newContent)) { + promotedIds.add(c.getId()); + promotedEntries.add(new PromotedEntry( + c.getId(), c.getFilename(), c.getSnippetPreview(), c.getScore())); + } else { + // Increment review_count for rejected candidates + int newReviewCount = (c.getReviewCount() != null ? c.getReviewCount() : 0) + 1; + rejectedEntries.add(new RejectedEntry( + c.getId(), c.getFilename(), c.getSnippetPreview(), + c.getScore(), newReviewCount)); } } + + if (!promotedIds.isEmpty()) { + recallService.markPromoted(new ArrayList<>(promotedIds)); + } + // Update review_count / last_reviewed_at for rejected candidates + recallService.incrementReviewCounts( + rejectedEntries.stream().map(RejectedEntry::recallId).toList()); + + log.info("[Memory] Promoted {}/{} recall candidates for agent={}", + promotedIds.size(), scoredCandidates.size(), agentId); + + // Append dream diary + appendDreamDiary(agentId, scoredCandidates, new ArrayList<>(promotedIds), mode, topic); } + + // Compute diff + String memoryDiff = computeDiff(oldMemoryContent, newContent); + + // Build and persist report + DreamReport report = buildSuccessReport(agentId, mode, topic, triggerSource, startedAt, + scoredCandidates.size(), promotedEntries, rejectedEntries, memoryDiff, + truncate(llmReason, 500)); + persistReport(report); + + // Contradiction detection — synchronous step after persist (D11) + try { + contradictionDetector.detect(agentId, promotedEntries); + } catch (Exception ce) { + log.debug("[Memory] Contradiction detection failed (non-fatal): {}", ce.getMessage()); + } + + return report; + } catch (Exception e) { log.warn("[Memory] Failed to parse/apply emergence result for agent={}: {}", agentId, e.getMessage()); + return buildFailedReport(agentId, mode, topic, triggerSource, startedAt, + scoredCandidates.size(), e.getMessage()); } } /** - * 将本轮 dreaming 结果追加到 DREAMS.md 整合日记 + * Build user prompt based on dream mode. */ - private void appendDreamDiary(Long agentId, List allCandidates, List promotedIds) { + private String buildUserPrompt(DreamMode mode, String topic, String memoryContent, + List scoredCandidates, + boolean hasScoredCandidates, String dailyNotes) { + if (mode == DreamMode.FOCUSED && topic != null && !topic.isBlank()) { + // FOCUSED mode: use topic-biased prompt + String candidatesText = hasScoredCandidates ? formatScoredCandidates(scoredCandidates) : "(no scored candidates)"; + String userTemplate = PromptLoader.loadPrompt("memory/emergence-focused-user"); + return userTemplate + .replace("{memory}", memoryContent) + .replace("{topic}", topic) + .replace("{scored_candidates}", candidatesText) + .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) + .replace("{daily_notes}", dailyNotes); + } + + // NIGHTLY mode: existing scored or plain prompt + if (hasScoredCandidates) { + String candidatesText = formatScoredCandidates(scoredCandidates); + String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user"); + return userTemplate + .replace("{memory}", memoryContent) + .replace("{scored_candidates}", candidatesText) + .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) + .replace("{daily_notes}", dailyNotes); + } else { + String userTemplate = PromptLoader.loadPrompt("memory/emergence-user"); + return userTemplate + .replace("{memory}", memoryContent) + .replace("{day_range}", String.valueOf(properties.getEmergenceDayRange())) + .replace("{daily_notes}", dailyNotes); + } + } + + /** + * Append dream diary to DREAMS.md. + */ + void appendDreamDiary(Long agentId, List allCandidates, + List promotedIds, DreamMode mode, String topic) { try { - java.util.Set promotedSet = new java.util.HashSet<>(promotedIds); + Set promotedSet = new HashSet<>(promotedIds); List promoted = allCandidates.stream() .filter(c -> promotedSet.contains(c.getId())) - .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) .toList(); List kept = allCandidates.stream() .filter(c -> !promotedSet.contains(c.getId())) - .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) .toList(); - String timestamp = java.time.LocalDateTime.now() + String timestamp = LocalDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); StringBuilder diary = new StringBuilder(); - diary.append("## ").append(timestamp).append(" Dreaming\n\n"); + diary.append("## ").append(timestamp).append(" Dreaming"); + if (mode == DreamMode.FOCUSED && topic != null) { + diary.append(" [FOCUSED: ").append(topic).append("]"); + } + diary.append("\n\n"); diary.append(String.format("**评分候选**: %d 条(阈值 %.1f)\n", allCandidates.size(), properties.getEmergenceScoreThreshold())); diary.append(String.format("**实际整合**: %d 条\n\n", promoted.size())); @@ -213,13 +307,13 @@ public class MemoryEmergenceService { diary.append("\n"); } - // 读取现有 DREAMS.md,追加新日记 + // Read existing DREAMS.md, append new diary String existing = readFileContentSafe(agentId, "DREAMS.md"); String newContent = existing.isBlank() ? "# Dreaming 整合日记\n\n" + diary : existing + "\n" + diary; - // 防止无限膨胀:超过 20KB 时截断,只保留最近的内容 + // Fallback hard truncation at 20KB (Phase 0 behavior preserved when archive flag is off) if (newContent.length() > 20_000) { int cutPoint = newContent.length() - 16_000; int safePoint = newContent.indexOf("\n## ", cutPoint); @@ -227,7 +321,6 @@ public class MemoryEmergenceService { newContent = "# Dreaming 整合日记\n\n> 早期记录已归档\n\n" + newContent.substring(safePoint + 1); } else { - // 没找到 ## 标记,硬截断保留最后 16KB newContent = "# Dreaming 整合日记\n\n> 早期记录已归档\n\n" + newContent.substring(cutPoint); } @@ -235,11 +328,86 @@ public class MemoryEmergenceService { workspaceFileService.saveFile(agentId, "DREAMS.md", newContent); log.info("[Memory] Dream diary appended for agent={}", agentId); + + // Archive old entries or fall back to 20KB truncation + if (properties.getDream().isArchiveEnabled()) { + archiveService.archiveOldDreams(agentId); + } } catch (Exception e) { log.warn("[Memory] Failed to write dream diary for agent={}: {}", agentId, e.getMessage()); } } + // ==================== Report builders ==================== + + private DreamReport buildSuccessReport(Long agentId, DreamMode mode, String topic, + String triggerSource, LocalDateTime startedAt, + int candidateCount, + List promoted, + List rejected, + String memoryDiff, String llmReason) { + return new DreamReport(null, agentId, mode, topic, triggerSource, "system", + startedAt, LocalDateTime.now(), candidateCount, + promoted.size(), rejected.size(), memoryDiff, llmReason, + DreamStatus.SUCCESS, null, promoted, rejected); + } + + private DreamReport buildSkippedReport(Long agentId, DreamMode mode, String topic, + String triggerSource, LocalDateTime startedAt, + String reason) { + DreamReport report = new DreamReport(null, agentId, mode, topic, triggerSource, "system", + startedAt, LocalDateTime.now(), 0, 0, 0, null, reason, + DreamStatus.SKIPPED, null, List.of(), List.of()); + persistReport(report); + return report; + } + + private DreamReport buildFailedReport(Long agentId, DreamMode mode, String topic, + String triggerSource, LocalDateTime startedAt, + int candidateCount, String errorMessage) { + DreamReport report = new DreamReport(null, agentId, mode, topic, triggerSource, "system", + startedAt, LocalDateTime.now(), candidateCount, 0, 0, null, null, + DreamStatus.FAILED, errorMessage, List.of(), List.of()); + persistReport(report); + return report; + } + + private void persistReport(DreamReport report) { + try { + DreamReportEntity entity = new DreamReportEntity(); + entity.setAgentId(report.agentId()); + entity.setMode(report.mode().name()); + entity.setTopic(report.topic()); + entity.setTriggerSource(report.triggerSource()); + entity.setTriggeredBy(report.triggeredBy()); + entity.setStartedAt(report.startedAt()); + entity.setFinishedAt(report.finishedAt()); + entity.setCandidateCount(report.candidateCount()); + entity.setPromotedCount(report.promotedCount()); + entity.setRejectedCount(report.rejectedCount()); + entity.setMemoryDiff(report.memoryDiff()); + entity.setLlmReason(report.llmReason()); + entity.setStatus(report.status().name()); + entity.setErrorMessage(report.errorMessage()); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateTime(LocalDateTime.now()); + entity.setDeleted(0); + dreamReportMapper.insert(entity); + log.debug("[Memory] DreamReport persisted: agent={}, mode={}, status={}", + report.agentId(), report.mode(), report.status()); + // Publish event for SSE broadcast + if (report.status() == DreamStatus.SUCCESS) { + eventPublisher.publishEvent(new DreamCompletedEvent(report)); + } else if (report.status() == DreamStatus.FAILED) { + eventPublisher.publishEvent(new DreamFailedEvent(report)); + } + } catch (Exception e) { + log.warn("[Memory] Failed to persist DreamReport for agent={}: {}", report.agentId(), e.getMessage()); + } + } + + // ==================== Helpers ==================== + private String formatScoredCandidates(List candidates) { StringBuilder sb = new StringBuilder(); for (MemoryRecallEntity entry : candidates) { @@ -253,16 +421,11 @@ public class MemoryEmergenceService { return sb.toString().trim(); } - /** - * 判断候选片段是否被 LLM 实际采纳到新 MEMORY.md 中。 - * 通过检查片段预览中的关键短语(取前 3 个非空行的前 20 字符)是否出现在新内容中。 - */ private boolean candidateAdoptedInMemory(MemoryRecallEntity candidate, String newMemoryContent) { String preview = candidate.getSnippetPreview(); if (preview == null || preview.isBlank() || newMemoryContent == null) { return false; } - // 从 snippet 提取关键短语进行匹配 String[] lines = preview.split("\n"); int matched = 0; int checked = 0; @@ -271,17 +434,29 @@ public class MemoryEmergenceService { if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; if (checked >= 3) break; checked++; - // 取行的核心内容(去掉 markdown 标记),检查是否出现在新 MEMORY.md 中 String key = trimmed.replaceAll("^[-*>]+\\s*", ""); if (key.length() > 20) key = key.substring(0, 20); if (key.length() >= 5 && newMemoryContent.contains(key)) { matched++; } } - // 至少有 1 个关键短语命中才算采纳 return matched > 0; } + private String computeDiff(String oldContent, String newContent) { + if (oldContent == null || oldContent.isBlank()) return "(new file)"; + if (oldContent.equals(newContent)) return "(no change)"; + // Simple line-count diff for Phase 1 + int oldLines = oldContent.split("\n").length; + int newLines = newContent.split("\n").length; + return String.format("-%d/+%d lines", oldLines, newLines); + } + + private String truncate(String s, int maxLen) { + if (s == null) return null; + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } + private ChatModel buildChatModel() { ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); return agentGraphBuilder.buildRuntimeChatModel(defaultModel); @@ -309,7 +484,7 @@ public class MemoryEmergenceService { } } - private String readFileContentSafe(Long agentId, String filename) { + String readFileContentSafe(Long agentId, String filename) { try { WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); return file != null && file.getContent() != null ? file.getContent() : ""; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java new file mode 100644 index 00000000..1d38f130 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryHilService.java @@ -0,0 +1,77 @@ +package vip.mate.memory.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.time.LocalDate; + +/** + * Human-in-the-Loop service for memory editing. + *

+ * When a user edits a memory entry, this service writes it back to MEMORY.md + * with a hidden metadata marker () so that + * future Dream runs do not overwrite user modifications. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemoryHilService { + + private final WorkspaceFileService workspaceFileService; + private final ApplicationEventPublisher eventPublisher; + + /** + * Edit a section in MEMORY.md identified by key (section heading). + * Appends user-edited metadata so Dream prompts respect user changes. + */ + public void editMemoryEntry(Long agentId, String key, String newContent) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md"); + if (file == null || file.getContent() == null) { + log.warn("[HiL] MEMORY.md not found for agent={}", agentId); + return; + } + + String memoryContent = file.getContent(); + String sectionHeader = "## " + key; + int headerIdx = memoryContent.indexOf(sectionHeader); + + if (headerIdx < 0) { + // Section not found — append as new section + String metadata = ""; + String newSection = "\n\n" + sectionHeader + "\n" + newContent.trim() + "\n" + metadata; + memoryContent = memoryContent.trim() + newSection; + } else { + // Find section boundaries + int contentStart = memoryContent.indexOf('\n', headerIdx) + 1; + int nextSection = memoryContent.indexOf("\n## ", contentStart); + int sectionEnd = nextSection > 0 ? nextSection : memoryContent.length(); + + // Replace section content + String metadata = ""; + String replacement = newContent.trim() + "\n" + metadata + "\n"; + memoryContent = memoryContent.substring(0, contentStart) + replacement + + memoryContent.substring(sectionEnd); + } + + workspaceFileService.saveFile(agentId, "MEMORY.md", memoryContent); + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "user-edit", newContent)); + log.info("[HiL] User edited MEMORY.md section '{}' for agent={}", key, agentId); + } + + /** + * Check if a section heading exists in MEMORY.md. + * Used by DreamController to validate edit key before allowing write. + */ + public boolean sectionExists(Long agentId, String key) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md"); + if (file == null || file.getContent() == null) return false; + return file.getContent().contains("## " + key); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java index 39e4ea70..6cf287f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java @@ -227,7 +227,7 @@ public class MemoryRecallService { } /** - * 标记候选为已提升 + * Mark candidates as promoted to MEMORY.md. */ public void markPromoted(List ids) { if (ids == null || ids.isEmpty()) return; @@ -237,6 +237,21 @@ public class MemoryRecallService { .set(MemoryRecallEntity::getPromoted, true)); } + /** + * Increment review_count and set last_reviewed_at for rejected candidates. + * Phase 1: write-only; filtering by review_count is deferred to Phase 2. + */ + public void incrementReviewCounts(List ids) { + if (ids == null || ids.isEmpty()) return; + for (Long id : ids) { + recallMapper.update(null, + new LambdaUpdateWrapper() + .eq(MemoryRecallEntity::getId, id) + .setSql("review_count = COALESCE(review_count, 0) + 1") + .set(MemoryRecallEntity::getLastReviewedAt, java.time.LocalDateTime.now())); + } + } + // ==================== 查询方法(供 API 使用) ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java new file mode 100644 index 00000000..1037d4ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MorningCardService.java @@ -0,0 +1,97 @@ +package vip.mate.memory.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.memory.model.DreamReportEntity; +import vip.mate.memory.model.MorningCardSeenEntity; +import vip.mate.memory.repository.DreamReportMapper; +import vip.mate.memory.repository.MorningCardSeenMapper; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Morning card service — determines whether to show a dream summary card + * when a user enters an agent view. Scope is per (userId, agentId). + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MorningCardService { + + private final MorningCardSeenMapper seenMapper; + private final DreamReportMapper dreamReportMapper; + + /** + * Get the morning card for a user+agent. Returns null if no unseen dream exists. + */ + public Map getCardFor(Long userId, Long agentId) { + if (userId == null || agentId == null) return null; + // Find the latest successful dream report for this agent + DreamReportEntity latestReport = dreamReportMapper.selectOne( + new LambdaQueryWrapper() + .eq(DreamReportEntity::getAgentId, agentId) + .eq(DreamReportEntity::getStatus, "SUCCESS") + .eq(DreamReportEntity::getDeleted, 0) + .orderByDesc(DreamReportEntity::getStartedAt) + .last("LIMIT 1")); + + if (latestReport == null) { + return null; // No dream yet + } + + // Check if user has already seen this report + MorningCardSeenEntity seen = seenMapper.selectOne( + new LambdaQueryWrapper() + .eq(MorningCardSeenEntity::getUserId, userId) + .eq(MorningCardSeenEntity::getAgentId, agentId)); + + if (seen != null && seen.getLastReportId() != null + && seen.getLastReportId().equals(latestReport.getId())) { + return null; // Already seen + } + + // Build card data + Map card = new LinkedHashMap<>(); + card.put("reportId", latestReport.getId()); + card.put("mode", latestReport.getMode()); + card.put("topic", latestReport.getTopic()); + card.put("startedAt", latestReport.getStartedAt()); + card.put("promotedCount", latestReport.getPromotedCount()); + card.put("rejectedCount", latestReport.getRejectedCount()); + card.put("llmReason", latestReport.getLlmReason()); + card.put("memoryDiff", latestReport.getMemoryDiff()); + return card; + } + + /** + * Mark the morning card as seen for a user+agent. + */ + public void markSeen(Long userId, Long agentId, Long reportId) { + MorningCardSeenEntity existing = seenMapper.selectOne( + new LambdaQueryWrapper() + .eq(MorningCardSeenEntity::getUserId, userId) + .eq(MorningCardSeenEntity::getAgentId, agentId)); + + if (existing != null) { + existing.setLastSeenAt(LocalDateTime.now()); + existing.setLastReportId(reportId); + existing.setUpdateTime(LocalDateTime.now()); + seenMapper.updateById(existing); + } else { + MorningCardSeenEntity entity = new MorningCardSeenEntity(); + entity.setUserId(userId); + entity.setAgentId(agentId); + entity.setLastSeenAt(LocalDateTime.now()); + entity.setLastReportId(reportId); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateTime(LocalDateTime.now()); + seenMapper.insert(entity); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java b/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java new file mode 100644 index 00000000..a40c1b54 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/PromotedEntry.java @@ -0,0 +1,13 @@ +package vip.mate.memory.service; + +/** + * A candidate that was adopted into MEMORY.md during a dream. + * + * @author MateClaw Team + */ +public record PromotedEntry( + Long recallId, + String filename, + String snippetPreview, + double score +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java b/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java new file mode 100644 index 00000000..ac7b1bb4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/RejectedEntry.java @@ -0,0 +1,14 @@ +package vip.mate.memory.service; + +/** + * A candidate that was scored but not adopted into MEMORY.md during a dream. + * + * @author MateClaw Team + */ +public record RejectedEntry( + Long recallId, + String filename, + String snippetPreview, + double score, + int reviewCount +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java new file mode 100644 index 00000000..c012ac92 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/SoulSummarizerService.java @@ -0,0 +1,136 @@ +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.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +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 vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * SOUL.md auto-evolution service. + *

+ * Subscribes to {@link MemoryWriteEvent}; after every K writes (configured by + * soulUpdateInterval), triggers an LLM call to regenerate SOUL.md from the + * agent's current memory state. + * + *

When soulUpdateInterval=0, this service is a no-op. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SoulSummarizerService { + + private final WorkspaceFileService workspaceFileService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final MemoryProperties properties; + + /** Per-agent write counter since last SOUL update */ + private final Map writeCounters = new ConcurrentHashMap<>(); + + @Async + @EventListener + public void onMemoryWrite(MemoryWriteEvent event) { + int interval = properties.getSoulUpdateInterval(); + if (interval <= 0) return; + + Long agentId = event.agentId(); + AtomicInteger counter = writeCounters.computeIfAbsent(agentId, k -> new AtomicInteger(0)); + int count = counter.incrementAndGet(); + + if (count < interval) { + log.debug("[SOUL] Write {}/{} for agent={}, waiting...", count, interval, agentId); + return; + } + + // Reset counter and trigger SOUL update + counter.set(0); + log.info("[SOUL] Triggering SOUL.md update for agent={} (after {} writes)", agentId, interval); + + try { + updateSoul(agentId); + } catch (Exception e) { + log.warn("[SOUL] Failed to update SOUL.md for agent={}: {}", agentId, e.getMessage()); + } + } + + /** + * Regenerate SOUL.md from current agent memory state. + */ + void updateSoul(Long agentId) { + // Read current files + String memoryContent = readSafe(agentId, "MEMORY.md"); + String profileContent = readSafe(agentId, "PROFILE.md"); + String currentSoul = readSafe(agentId, "SOUL.md"); + + String systemPrompt = PromptLoader.loadPrompt("memory/soul-summarize"); + String userPrompt = String.format(""" + ## Current SOUL.md + ``` + %s + ``` + + ## PROFILE.md + ``` + %s + ``` + + ## MEMORY.md + ``` + %s + ``` + + Based on the above, regenerate SOUL.md. Keep it concise and personal. + Output ONLY the new SOUL.md content (no fences, no explanation). + """, currentSoul, profileContent, memoryContent); + + ChatModel chatModel = buildChatModel(); + Prompt prompt = new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt) + )); + ChatResponse response = chatModel.call(prompt); + String newSoul = response.getResult().getOutput().getText(); + + if (newSoul != null && !newSoul.isBlank() && newSoul.length() > 50) { + workspaceFileService.saveFile(agentId, "SOUL.md", newSoul.trim()); + log.info("[SOUL] Updated SOUL.md for agent={} ({} chars)", agentId, newSoul.length()); + } else { + log.debug("[SOUL] LLM returned empty/short response, skipping SOUL update"); + } + } + + private ChatModel buildChatModel() { + ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); + return agentGraphBuilder.buildRuntimeChatModel(defaultModel); + } + + private String readSafe(Long agentId, String filename) { + try { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); + return file != null && file.getContent() != null ? file.getContent() : ""; + } catch (Exception e) { + return ""; + } + } +} 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 73b54911..c5295fd8 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 @@ -2,7 +2,9 @@ package vip.mate.memory.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.memory.event.MemoryWriteEvent; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; @@ -35,6 +37,7 @@ public class StructuredMemoryService { private static final Pattern SECTION_PATTERN = Pattern.compile("^## (.+)$", Pattern.MULTILINE); private final WorkspaceFileService workspaceFileService; + private final ApplicationEventPublisher eventPublisher; /** Per-file lock to prevent concurrent read-modify-write on the same file */ private final ConcurrentHashMap fileLocks = new ConcurrentHashMap<>(); @@ -69,6 +72,8 @@ public class StructuredMemoryService { workspaceFileService.saveFile(agentId, filename, updated); log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})", existingSection != null ? "Updated" : "Added", key, agentId, source); + // Publish event for SOUL auto-evolution (Phase 2) + eventPublisher.publishEvent(new MemoryWriteEvent(agentId, filename, "remember", content)); } finally { lock.unlock(); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java index bd5b5423..b5dd387f 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java @@ -1,8 +1,11 @@ package vip.mate.memory.spi; +import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.memory.MemoryProperties; +import vip.mate.memory.spi.decorator.MetricsMemoryProvider; +import vip.mate.memory.spi.decorator.RetryableMemoryProvider; import java.util.ArrayList; import java.util.Comparator; @@ -32,20 +35,43 @@ public class MemoryManager { /** External plugin memory provider (single-select constraint) */ private volatile MemoryProvider externalPluginProvider = null; - public MemoryManager(List allProviders, MemoryProperties properties) { + public MemoryManager(List allProviders, MemoryProperties properties, + org.springframework.beans.factory.ObjectProvider meterRegistryProvider) { + MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable(); Set disabled = properties.getDisabledProviders(); - this.providers = allProviders.stream() + List filtered = allProviders.stream() .filter(MemoryProvider::isAvailable) .filter(p -> !disabled.contains(p.id())) .sorted(Comparator.comparingInt(MemoryProvider::order)) .collect(Collectors.toList()); + // Assemble decorator chain based on flags + this.providers = filtered.stream() + .map(p -> wrapWithDecorators(p, properties, meterRegistry)) + .collect(Collectors.toList()); + if (!disabled.isEmpty()) { log.info("[MemoryManager] Disabled providers: {}", disabled); } - log.info("[MemoryManager] Active providers ({}): {}", + String decorators = ""; + if (properties.getProviderRetryAttempts() > 1) decorators += "+retry(" + properties.getProviderRetryAttempts() + ")"; + if (properties.isProviderMetricsEnabled()) decorators += "+metrics"; + log.info("[MemoryManager] Active providers ({}): {} {}", this.providers.size(), - this.providers.stream().map(MemoryProvider::id).collect(Collectors.joining(", "))); + filtered.stream().map(MemoryProvider::id).collect(Collectors.joining(", ")), + decorators); + } + + private MemoryProvider wrapWithDecorators(MemoryProvider provider, MemoryProperties properties, + MeterRegistry meterRegistry) { + MemoryProvider result = provider; + if (properties.getProviderRetryAttempts() > 1) { + result = new RetryableMemoryProvider(result, properties.getProviderRetryAttempts()); + } + if (properties.isProviderMetricsEnabled() && meterRegistry != null) { + result = new MetricsMemoryProvider(result, meterRegistry); + } + return result; } // ==================== System Prompt ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java index 5784e079..af8b3328 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java @@ -95,4 +95,38 @@ public interface MemoryProvider { default String onPreCompress(Long agentId, List messages) { return ""; } + + /** + * Notification that a memory write occurred. Called after canonical memory + * files (structured/*.md, MEMORY.md) are updated. + * + *

Phase 2: SOUL auto-evolution subscribes to this. + * + * @param agentId the agent ID + * @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md") + * @param action what happened ("append", "update", "consolidate") + * @param content the written content + */ + default void onMemoryWrite(Long agentId, String target, String action, String content) { + } + + /** + * Warm up provider internal state (embeddings, index handles, connection pools). + * Called when an agent session is likely to start. Providers decide what to cache. + * + *

Phase 2: provider internal-state cache (not recall text cache — F2). + * + * @param agentId the agent ID + */ + default void warmup(Long agentId) { + } + + /** + * Evict cached internal state for an agent. Called on agent deactivation or + * memory pressure. + * + * @param agentId the agent ID + */ + default void evict(Long agentId) { + } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java new file mode 100644 index 00000000..850a7370 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java @@ -0,0 +1,38 @@ +package vip.mate.memory.spi.decorator; + +import vip.mate.memory.spi.MemoryProvider; + +import java.util.Collections; +import java.util.List; + +/** + * Base decorator for MemoryProvider. All methods delegate to the wrapped provider. + * Subclass and override specific methods to add behavior (retry, metrics, etc.). + * + * @author MateClaw Team + */ +public abstract class MemoryProviderDecorator implements MemoryProvider { + + protected final MemoryProvider delegate; + + protected MemoryProviderDecorator(MemoryProvider delegate) { + this.delegate = delegate; + } + + @Override public String id() { return delegate.id(); } + @Override public int order() { return delegate.order(); } + @Override public boolean isAvailable() { return delegate.isAvailable(); } + @Override public String systemPromptBlock(Long agentId) { return delegate.systemPromptBlock(agentId); } + @Override public String prefetch(Long agentId, String userQuery) { return delegate.prefetch(agentId, userQuery); } + @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + } + @Override public List getToolBeans() { return delegate.getToolBeans(); } + @Override public void onSessionEnd(Long agentId, String conversationId) { delegate.onSessionEnd(agentId, conversationId); } + @Override public String onPreCompress(Long agentId, List messages) { return delegate.onPreCompress(agentId, messages); } + @Override public void onMemoryWrite(Long agentId, String target, String action, String content) { + delegate.onMemoryWrite(agentId, target, action, content); + } + @Override public void warmup(Long agentId) { delegate.warmup(agentId); } + @Override public void evict(Long agentId) { delegate.evict(agentId); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java new file mode 100644 index 00000000..90b1f792 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MetricsMemoryProvider.java @@ -0,0 +1,71 @@ +package vip.mate.memory.spi.decorator; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import vip.mate.memory.spi.MemoryProvider; + +/** + * Decorator that records Micrometer metrics for prefetch/sync/session-end operations. + *

+ * Metrics emitted (all with tag provider=...): + * - memory.prefetch.latency (Timer) + * - memory.prefetch.failures (Counter) + * - memory.sync.duration (Timer) + * - memory.sync.failures (Counter) + * - memory.session_end.duration (Timer) + * + * @author MateClaw Team + */ +public class MetricsMemoryProvider extends MemoryProviderDecorator { + + private final MeterRegistry meterRegistry; + private final Timer prefetchTimer; + private final Timer syncTimer; + private final Timer sessionEndTimer; + + public MetricsMemoryProvider(MemoryProvider delegate, MeterRegistry meterRegistry) { + super(delegate); + this.meterRegistry = meterRegistry; + String providerId = delegate.id(); + this.prefetchTimer = Timer.builder("memory.prefetch.latency") + .tag("provider", providerId) + .register(meterRegistry); + this.syncTimer = Timer.builder("memory.sync.duration") + .tag("provider", providerId) + .register(meterRegistry); + this.sessionEndTimer = Timer.builder("memory.session_end.duration") + .tag("provider", providerId) + .register(meterRegistry); + } + + @Override + public String prefetch(Long agentId, String userQuery) { + return prefetchTimer.record(() -> { + try { + return delegate.prefetch(agentId, userQuery); + } catch (Exception e) { + meterRegistry.counter("memory.prefetch.failures", + "provider", delegate.id()).increment(); + throw e; + } + }); + } + + @Override + public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + syncTimer.record(() -> { + try { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + } catch (Exception e) { + meterRegistry.counter("memory.sync.failures", + "provider", delegate.id()).increment(); + throw e; + } + }); + } + + @Override + public void onSessionEnd(Long agentId, String conversationId) { + sessionEndTimer.record(() -> delegate.onSessionEnd(agentId, conversationId)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java new file mode 100644 index 00000000..6d88583c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/RetryableMemoryProvider.java @@ -0,0 +1,68 @@ +package vip.mate.memory.spi.decorator; + +import lombok.extern.slf4j.Slf4j; +import vip.mate.memory.spi.MemoryProvider; + +/** + * Decorator that retries failed prefetch/syncTurn calls with exponential backoff. + * + * @author MateClaw Team + */ +@Slf4j +public class RetryableMemoryProvider extends MemoryProviderDecorator { + + private final int maxAttempts; + + public RetryableMemoryProvider(MemoryProvider delegate, int maxAttempts) { + super(delegate); + this.maxAttempts = maxAttempts; + } + + @Override + public String prefetch(Long agentId, String userQuery) { + Exception lastException = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return delegate.prefetch(agentId, userQuery); + } catch (Exception e) { + lastException = e; + if (attempt < maxAttempts) { + log.debug("[Retry] prefetch attempt {}/{} failed for provider={}: {}", + attempt, maxAttempts, delegate.id(), e.getMessage()); + sleep(attempt); + } + } + } + log.warn("[Retry] prefetch exhausted {} attempts for provider={}: {}", + maxAttempts, delegate.id(), lastException != null ? lastException.getMessage() : ""); + return ""; + } + + @Override + public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) { + Exception lastException = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + delegate.syncTurn(agentId, conversationId, userMessage, assistantReply); + return; + } catch (Exception e) { + lastException = e; + if (attempt < maxAttempts) { + log.debug("[Retry] syncTurn attempt {}/{} failed for provider={}: {}", + attempt, maxAttempts, delegate.id(), e.getMessage()); + sleep(attempt); + } + } + } + log.warn("[Retry] syncTurn exhausted {} attempts for provider={}: {}", + maxAttempts, delegate.id(), lastException != null ? lastException.getMessage() : ""); + } + + private void sleep(int attempt) { + try { + Thread.sleep((long) Math.pow(2, attempt - 1) * 100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} 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 e3398632..f9f56ef0 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 @@ -49,7 +49,6 @@ public class PlanEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; /** 子计划列表(非数据库字段,查询时填充) */ 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 63513500..3b0ab79c 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 @@ -45,6 +45,5 @@ public class SubPlanEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java index cf2c77fd..11e443af 100644 --- a/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/plugin/model/PluginEntity.java @@ -61,6 +61,5 @@ public class PluginEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index 9cb24b4f..b9d7b157 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -1,5 +1,6 @@ package vip.mate.skill.controller; +import com.baomidou.mybatisplus.core.metadata.IPage; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -34,10 +35,28 @@ public class SkillController { private final SkillWorkspaceManager workspaceManager; private final SkillSynthesisService synthesisService; - @Operation(summary = "获取技能列表") + @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping - public R> list() { - return R.ok(skillService.listSkills()); + public R> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String skillType, + @RequestParam(required = false) Boolean enabled, + @RequestParam(required = false) String scanStatus) { + return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus)); + } + + @Operation(summary = "获取各类型技能计数(tab 徽章用)") + @GetMapping("/counts") + public R> counts() { + return R.ok(skillService.countByType()); + } + + @Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)") + @PostMapping("/{id}/rescan") + public R rescan(@PathVariable Long id) { + return R.ok(skillService.rescanSecurity(id)); } @Operation(summary = "获取已启用技能列表") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java new file mode 100644 index 00000000..f951f64d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java @@ -0,0 +1,305 @@ +package vip.mate.skill.installer; + +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.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Service; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Builtin skill seed service — RFC-044 §4.2. + * + *

Scans {@code classpath:skills/*\/SKILL.md} on startup, parses each + * frontmatter, and upserts the row into {@code mate_skill} so the bundled + * SKILL.md becomes the single source of truth. + * + *

Replaces (and obsoletes) the per-skill {@code INSERT INTO mate_skill} + * blocks in {@code data-{locale}.sql}. Those are kept for one release as a + * compatibility shim — see RFC-044 §4.2 step 3. + * + *

Upsert key: {@code name}. The mate_skill primary key {@code id} + * is preserved on update, so nothing referencing a skill by id breaks. + * + *

Field merge policy: frontmatter wins where present; if the + * frontmatter omits a field (e.g. {@code icon}, {@code tags}, {@code author}), + * the existing DB value is preserved rather than blanked out. New skills + * (with no DB row yet) get sensible defaults. + * + *

Order: 110 — runs after Flyway and {@link + * vip.mate.config.DatabaseBootstrapRunner} (Order 1), so SQL seeds load first + * and this service then overlays the authoritative classpath SKILL.md. + */ +@Slf4j +@Service +@Order(110) +@RequiredArgsConstructor +public class BuiltinSkillSeedService implements ApplicationRunner { + + private static final String SKILL_GLOB = "classpath*:skills/*/SKILL.md"; + private static final String DEFAULT_AUTHOR = "MateClaw"; + private static final String DEFAULT_ICON = "🛠️"; + private static final String DEFAULT_VERSION = "1.0.0"; + private static final String SKILL_TYPE_BUILTIN = "builtin"; + + private final SkillMapper skillMapper; + private final SkillFrontmatterParser frontmatterParser; + private final ObjectMapper objectMapper; + + @Override + public void run(ApplicationArguments args) { + try { + syncBuiltinSkills(); + } catch (Exception e) { + log.warn("[SkillSeed] Sync failed (table may not exist yet): {}", e.getMessage()); + } + } + + /** Public so tests and admin endpoints can re-trigger sync. */ + public SyncStats syncBuiltinSkills() { + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources; + try { + resources = resolver.getResources(SKILL_GLOB); + } catch (Exception e) { + log.warn("[SkillSeed] Failed to scan {}: {}", SKILL_GLOB, e.getMessage()); + return new SyncStats(0, 0, 0, 0); + } + + int inserted = 0, updated = 0, unchanged = 0, skipped = 0; + for (Resource resource : resources) { + try { + String content = readContent(resource); + SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content); + String name = parsed.getName(); + if (name == null || name.isBlank()) { + log.warn("[SkillSeed] {}: SKILL.md has no `name` in frontmatter — skipped", + resource.getDescription()); + skipped++; + continue; + } + + SkillEntity existing = skillMapper.selectOne( + new LambdaQueryWrapper().eq(SkillEntity::getName, name)); + + if (existing == null) { + SkillEntity row = buildNew(parsed, content); + skillMapper.insert(row); + inserted++; + log.info("[SkillSeed] inserted '{}' (version={})", name, row.getVersion()); + } else if (mergeIntoExisting(existing, parsed, content)) { + skillMapper.updateById(existing); + updated++; + log.info("[SkillSeed] updated '{}' (version={})", name, existing.getVersion()); + } else { + unchanged++; + } + } catch (Exception e) { + log.warn("[SkillSeed] Failed to process {}: {}", resource.getDescription(), e.getMessage()); + skipped++; + } + } + log.info("[SkillSeed] Builtin skills: {} inserted, {} updated, {} unchanged, {} skipped", + inserted, updated, unchanged, skipped); + return new SyncStats(inserted, updated, unchanged, skipped); + } + + private String readContent(Resource resource) throws Exception { + try (InputStream is = resource.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** Build a brand-new entity for a skill that has no row in mate_skill yet. */ + private SkillEntity buildNew(SkillFrontmatterParser.ParsedSkillMd parsed, String content) { + SkillEntity row = new SkillEntity(); + row.setName(parsed.getName()); + row.setDescription(nullIfBlank(parsed.getDescription())); + row.setSkillType(SKILL_TYPE_BUILTIN); + row.setBuiltin(true); + row.setEnabled(true); + row.setSkillContent(content); + row.setVersion(stringFromFrontmatter(parsed, "version", DEFAULT_VERSION)); + row.setIcon(stringFromFrontmatter(parsed, "icon", DEFAULT_ICON)); + row.setAuthor(stringFromFrontmatter(parsed, "author", DEFAULT_AUTHOR)); + row.setTags(tagsFromFrontmatter(parsed, parsed.getName())); + // RFC-042 §2.2 — optional bilingual display names from frontmatter. + row.setNameZh(stringFromFrontmatter(parsed, "nameZh", null)); + row.setNameEn(stringFromFrontmatter(parsed, "nameEn", null)); + row.setConfigJson(buildConfigJson(parsed)); + LocalDateTime now = LocalDateTime.now(); + row.setCreateTime(now); + row.setUpdateTime(now); + row.setDeleted(0); + return row; + } + + /** + * Apply frontmatter onto an existing row. Returns {@code true} if any + * tracked field changed and the row needs an UPDATE. + * + *

Frontmatter wins where present. Fields the frontmatter omits are + * left as-is so we don't blank out values populated elsewhere (UI, + * legacy SQL seed, manual admin tweaks). + */ + private boolean mergeIntoExisting(SkillEntity existing, + SkillFrontmatterParser.ParsedSkillMd parsed, + String content) { + boolean dirty = false; + + String desc = nullIfBlank(parsed.getDescription()); + if (desc != null && !Objects.equals(existing.getDescription(), desc)) { + existing.setDescription(desc); + dirty = true; + } + + String version = stringFromFrontmatter(parsed, "version", null); + if (version != null && !Objects.equals(existing.getVersion(), version)) { + existing.setVersion(version); + dirty = true; + } + + String icon = stringFromFrontmatter(parsed, "icon", null); + if (icon != null && !Objects.equals(existing.getIcon(), icon)) { + existing.setIcon(icon); + dirty = true; + } + + String author = stringFromFrontmatter(parsed, "author", null); + if (author != null && !Objects.equals(existing.getAuthor(), author)) { + existing.setAuthor(author); + dirty = true; + } + + String tags = tagsFromFrontmatter(parsed, null); + if (tags != null && !Objects.equals(existing.getTags(), tags)) { + existing.setTags(tags); + dirty = true; + } + + // RFC-042 §2.2 — bilingual display names. Frontmatter wins; if silent, + // preserve whatever the SQL seed or a manual UI edit populated. + String nameZh = stringFromFrontmatter(parsed, "nameZh", null); + if (nameZh != null && !Objects.equals(existing.getNameZh(), nameZh)) { + existing.setNameZh(nameZh); + dirty = true; + } + String nameEn = stringFromFrontmatter(parsed, "nameEn", null); + if (nameEn != null && !Objects.equals(existing.getNameEn(), nameEn)) { + existing.setNameEn(nameEn); + dirty = true; + } + + String configJson = buildConfigJson(parsed); + if (!Objects.equals(existing.getConfigJson(), configJson)) { + existing.setConfigJson(configJson); + dirty = true; + } + + if (!Objects.equals(existing.getSkillContent(), content)) { + existing.setSkillContent(content); + dirty = true; + } + + // Re-affirm builtin classification — historic rows occasionally drifted. + if (!SKILL_TYPE_BUILTIN.equals(existing.getSkillType())) { + existing.setSkillType(SKILL_TYPE_BUILTIN); + dirty = true; + } + if (!Boolean.TRUE.equals(existing.getBuiltin())) { + existing.setBuiltin(true); + dirty = true; + } + + return dirty; + } + + @SuppressWarnings("unchecked") + private String stringFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed, + String key, String fallback) { + Map fm = parsed.getFrontmatter(); + if (fm == null) return fallback; + Object value = fm.get(key); + if (value == null) return fallback; + String s = value.toString().trim(); + return s.isEmpty() ? fallback : s; + } + + /** + * Build the canonical {@code tags} string. Accepts either a CSV string, + * a YAML list, or — when the frontmatter is silent — derives a single + * tag from the supplied default (typically the skill name). + * + *

Returns {@code null} when nothing usable was supplied; callers use + * that as "do not touch". + */ + @SuppressWarnings("unchecked") + private String tagsFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed, String defaultTag) { + Map fm = parsed.getFrontmatter(); + if (fm != null) { + Object raw = fm.get("tags"); + if (raw instanceof List list) { + StringBuilder sb = new StringBuilder(); + for (Object item : list) { + if (item == null) continue; + String s = item.toString().trim(); + if (s.isEmpty()) continue; + if (sb.length() > 0) sb.append(','); + sb.append(s); + } + if (sb.length() > 0) return sb.toString(); + } else if (raw instanceof String s && !s.isBlank()) { + return s.trim(); + } + } + return defaultTag != null ? defaultTag : null; + } + + /** + * Stable {@code config_json} payload. Preserves the historical shape + * ({@code upstream}, {@code entryFile}) and adds {@code requiredTools} + * derived from {@code dependencies.tools}. + */ + private String buildConfigJson(SkillFrontmatterParser.ParsedSkillMd parsed) { + // LinkedHashMap → stable key ordering → stable diff against existing. + Map config = new LinkedHashMap<>(); + config.put("upstream", "mateclaw"); + config.put("entryFile", "SKILL.md"); + + SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies(); + if (deps != null && deps.getTools() != null && !deps.getTools().isEmpty()) { + config.put("requiredTools", deps.getTools()); + } + if (parsed.getPlatforms() != null && !parsed.getPlatforms().isEmpty()) { + config.put("platforms", parsed.getPlatforms()); + } + try { + return objectMapper.writeValueAsString(config); + } catch (Exception e) { + // Fall back to legacy shape — never break startup over JSON encoding. + return "{\"upstream\":\"mateclaw\",\"entryFile\":\"SKILL.md\"}"; + } + } + + private String nullIfBlank(String s) { + return s == null || s.isBlank() ? null : s; + } + + public record SyncStats(int inserted, int updated, int unchanged, int skipped) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index 0528cb4a..5fcc5fe8 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -18,9 +18,23 @@ public class SkillEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; - /** 技能名称 */ + /** 技能名称 — immutable slug, used as primary identifier */ private String name; + /** + * RFC-042 §2.2 — locale-specific display name for zh-CN. + * {@code null} → UI falls back to {@code name}. + */ + @TableField(value = "name_zh", updateStrategy = FieldStrategy.ALWAYS) + private String nameZh; + + /** + * RFC-042 §2.2 — locale-specific display name for en-US. + * {@code null} → UI falls back to {@code name}. + */ + @TableField(value = "name_en", updateStrategy = FieldStrategy.ALWAYS) + private String nameEn; + /** 技能描述 */ private String description; @@ -84,12 +98,24 @@ public class SkillEntity { */ private String securityScanStatus; + /** + * RFC-042 §2.3 — persisted JSON array of the last scan's findings + * ({@code [{ruleId,severity,category,title,description,filePath, + * lineNumber,snippet,remediation}]}). Populated by + * {@code SkillPackageResolver} after every scan so the admin UI can + * render "why blocked" without re-resolving. + */ + @TableField(value = "security_scan_result", updateStrategy = FieldStrategy.ALWAYS) + private String securityScanResult; + + /** RFC-042 §2.3 — wall-clock time of the last scan write-back. */ + private LocalDateTime securityScanTime; + @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/skill/runtime/SkillDependencyChecker.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java index ae0cff6c..f6102be1 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java @@ -5,8 +5,10 @@ import lombok.Builder; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import vip.mate.skill.runtime.SkillFrontmatterParser.SkillDependencies; +import vip.mate.tool.ToolRegistry; import vip.mate.tool.model.ToolEntity; import vip.mate.tool.repository.ToolMapper; @@ -15,6 +17,7 @@ import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.concurrent.TimeUnit; /** @@ -23,10 +26,20 @@ import java.util.concurrent.TimeUnit; */ @Slf4j @Service -@RequiredArgsConstructor public class SkillDependencyChecker { private final ToolMapper toolMapper; + private final ToolRegistry toolRegistry; + + /** + * {@code @Lazy} on {@link ToolRegistry}: this bean is constructed during startup, + * and ToolRegistry transitively depends on MCP / plugin infrastructure that also + * runs early — the lazy proxy breaks that cycle. + */ + public SkillDependencyChecker(ToolMapper toolMapper, @Lazy ToolRegistry toolRegistry) { + this.toolMapper = toolMapper; + this.toolRegistry = toolRegistry; + } private static final String CURRENT_OS = detectOS(); @@ -75,9 +88,13 @@ public class SkillDependencyChecker { } } - // 4. 内部工具检查 + // 4. 内部工具检查 — fetch the runtime function-name set once per skill + // so we don't hit reflection N times when a skill lists many tools. + Set runtimeFunctionNames = dependencies.getTools().isEmpty() + ? Set.of() + : fetchRuntimeFunctionNames(); for (String toolName : dependencies.getTools()) { - if (!isToolAvailable(toolName)) { + if (!isToolAvailable(toolName, runtimeFunctionNames)) { missing.add("tool:" + toolName); allSatisfied = false; } @@ -118,18 +135,25 @@ public class SkillDependencyChecker { } } - private boolean isToolAvailable(String toolName) { + /** + * The runtime registry (ToolRegistry) is authoritative: it knows the exact + * function names LLMs and skills call by ({@code @Tool} method name / MCP + * tool id / plugin tool name). The {@code mate_tool} DB overlay stores + * class names + bean names and does NOT match that vocabulary, so checking + * the DB alone mis-reports every real skill dependency as "missing". + * + *

We keep the DB lookup as a secondary fallback for the edge case where + * someone has inserted a custom row whose {@code name} happens to equal the + * function name. + */ + private boolean isToolAvailable(String toolName, Set runtimeFunctionNames) { + if (runtimeFunctionNames.contains(toolName)) { + return true; + } try { - // 先按 name 精确匹配 Long count = toolMapper.selectCount(new LambdaQueryWrapper() .eq(ToolEntity::getName, toolName) .eq(ToolEntity::getEnabled, true)); - if (count > 0) return true; - - // 再按 beanName 匹配(兼容 Spring Bean 名称) - count = toolMapper.selectCount(new LambdaQueryWrapper() - .eq(ToolEntity::getBeanName, toolName) - .eq(ToolEntity::getEnabled, true)); return count > 0; } catch (Exception e) { log.debug("Tool check failed for '{}': {}", toolName, e.getMessage()); @@ -137,6 +161,16 @@ public class SkillDependencyChecker { } } + private Set fetchRuntimeFunctionNames() { + try { + return toolRegistry.availableFunctionNames(); + } catch (Exception e) { + log.warn("Failed to fetch runtime tool function names, falling back to DB check only: {}", + e.getMessage()); + return Set.of(); + } + } + private static boolean isWindows() { return CURRENT_OS.equals("windows"); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index aee8b9c2..32859256 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -5,14 +5,17 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.SkillWorkspaceManager; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; /** @@ -38,6 +41,7 @@ public class SkillPackageResolver { private final SkillDependencyChecker dependencyChecker; private final ObjectMapper objectMapper; private final SkillWorkspaceManager workspaceManager; + private final SkillMapper skillMapper; /** * 解析技能实体为运行时技能包(完整流程) @@ -72,9 +76,75 @@ public class SkillPackageResolver { // 4. 综合判定 runtimeAvailable resolveRuntimeAvailability(resolved); + // 5. RFC-042 §2.3 — persist the scan outcome so the admin UI can show + // findings after a restart (previously they lived only in memory). + persistScanOutcome(entity, resolved); + return resolved; } + /** + * Write back the latest scan status / findings JSON / timestamp when + * they differ from what's already on the row. Keeps the DB in sync + * without re-writing on every idempotent refresh. + * + *

Diff-based so a fresh resolve loop across N enabled skills is + * effectively free when nothing has changed on disk. Errors here are + * non-fatal — the scan result is already attached to {@code resolved}, + * so the UI will still see it for this request. + */ + private void persistScanOutcome(SkillEntity entity, ResolvedSkill resolved) { + if (entity == null || entity.getId() == null) return; + + String newStatus = deriveScanStatus(resolved); + String newJson = serializeFindings(resolved.getSecurityFindings()); + boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus); + boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson); + + if (!statusChanged && !findingsChanged) { + return; + } + + try { + SkillEntity update = new SkillEntity(); + update.setId(entity.getId()); + update.setSecurityScanStatus(newStatus); + update.setSecurityScanResult(newJson); + update.setSecurityScanTime(LocalDateTime.now()); + skillMapper.updateById(update); + // Keep the in-memory entity coherent with the DB so the next + // resolve in the same tick doesn't redundantly write again. + entity.setSecurityScanStatus(newStatus); + entity.setSecurityScanResult(newJson); + entity.setSecurityScanTime(update.getSecurityScanTime()); + } catch (Exception e) { + log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage()); + } + } + + /** + * Collapse the resolver's rich security state back into the {@code + * PASSED / FAILED / null} tri-state used on the row. + */ + private String deriveScanStatus(ResolvedSkill resolved) { + if (resolved.isSecurityBlocked()) return "FAILED"; + List findings = resolved.getSecurityFindings(); + if (findings != null && !findings.isEmpty()) return "PASSED"; // scanned and found non-blocking issues + // No block, no findings — treat as scanned-clean (still PASSED so + // listEnabledSkills() doesn't treat it as never-scanned). + return "PASSED"; + } + + private String serializeFindings(List findings) { + if (findings == null || findings.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(findings); + } catch (Exception e) { + log.debug("Failed to serialize findings: {}", e.getMessage()); + return null; + } + } + // ==================== 阶段 1:内容解析 ==================== private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) { 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 761ba0c4..47cfe4c2 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 @@ -104,6 +104,21 @@ public class SkillRuntimeService { .collect(Collectors.toList()); } + /** + * Rescan one skill on demand (RFC-042 §2.3.4) — runs the full resolver + * pipeline (content + security + dependency), which writes the updated + * scan result to DB as a side-effect, and then invalidates the active + * skills cache so subsequent reads reflect the new status. + */ + public ResolvedSkill rescanSingle(SkillEntity skill) { + ResolvedSkill resolved = packageResolver.resolve(skill); + activeSkillsCache.invalidateAll(); + log.info("Rescanned skill '{}' (id={}): status={}, blocked={}", + skill.getName(), skill.getId(), + skill.getSecurityScanStatus(), resolved.isSecurityBlocked()); + return resolved; + } + /** * 根据名称查找 active skill */ diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index a903155c..51eafe5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -1,6 +1,8 @@ package vip.mate.skill.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -12,6 +14,7 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties; import java.nio.file.Files; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -58,6 +61,81 @@ public class SkillService { .orderByDesc(SkillEntity::getCreateTime)); } + /** + * Paginated skill listing for the SkillMarket admin UI. + * + *

RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters + * are all optional; empty or {@code null} means "no filter". Keyword + * searches name / description / tags with LIKE. + * + *

{@code scanStatus} (RFC-042 §2.3.5) filters on {@code + * security_scan_status}: {@code "FAILED"} surfaces blocked skills so the + * admin can inspect findings and rescan, {@code "PASSED"} shows scanned + * clean rows, {@code null} / empty means no scan filter. + */ + public IPage pageSkills(int page, int size, String keyword, + String skillType, Boolean enabled, + String scanStatus) { + Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (keyword != null && !keyword.isBlank()) { + String kw = keyword.trim(); + wrapper.and(w -> w + .like(SkillEntity::getName, kw) + .or().like(SkillEntity::getDescription, kw) + .or().like(SkillEntity::getTags, kw)); + } + if (skillType != null && !skillType.isBlank()) { + wrapper.eq(SkillEntity::getSkillType, skillType); + } + if (enabled != null) { + wrapper.eq(SkillEntity::getEnabled, enabled); + } + if (scanStatus != null && !scanStatus.isBlank()) { + wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase()); + } + + wrapper.orderByDesc(SkillEntity::getBuiltin) + .orderByDesc(SkillEntity::getCreateTime); + + return skillMapper.selectPage(pageParam, wrapper); + } + + /** + * Manually re-run security + dependency resolution for a single skill + * (RFC-042 §2.3.4). Triggered from the admin UI after the user fixes + * flagged code and wants an immediate verdict instead of waiting for + * the next refresh event. + * + *

The resolver itself persists the outcome — this method just kicks + * it and returns the reloaded row. + */ + public SkillEntity rescanSecurity(Long id) { + SkillEntity skill = getSkill(id); // throws MateClawException if missing + if (runtimeService == null) { + throw new MateClawException("err.skill.runtime_unavailable", + "Skill runtime not initialized yet; retry in a moment"); + } + runtimeService.rescanSingle(skill); + return skillMapper.selectById(id); + } + + /** + * Aggregate skill counts per {@code skill_type}, plus an {@code all} + * rollup. Feeds the SkillMarket tab badges without pulling every row. + */ + public Map countByType() { + Map result = new LinkedHashMap<>(); + result.put("all", skillMapper.selectCount(null)); + for (String type : List.of("builtin", "mcp", "dynamic")) { + result.put(type, skillMapper.selectCount( + new LambdaQueryWrapper() + .eq(SkillEntity::getSkillType, type))); + } + return result; + } + /** * 获取已启用的技能列表(Agent 运行时使用) *

diff --git a/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java b/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java new file mode 100644 index 00000000..98ecadfc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java @@ -0,0 +1,90 @@ +package vip.mate.stt; + +import java.util.Locale; +import java.util.Map; + +/** + * Filename inference helper for the OpenAI Whisper STT path. + * + *

Whisper's {@code /v1/audio/transcriptions} endpoint reads the + * filename extension on the multipart {@code file} part to infer + * audio format; the {@code Content-Type} alone isn't enough because + * Hutool's 3-arg {@code form(name, bytes, fileName)} overload deduces + * the multipart Content-Type from the extension we pass. Hence this + * helper picks an extension that matches the actual bytes. + * + *

Previous bug (pre-fix): both providers hardcoded {@code "audio.ogg"} + * as the default filename even when the upstream content was WebM/Opus, + * which DashScope's HTTP path then tried to decode as Ogg and 400'd. + * That bug + DashScope's HTTP STT are both gone now (DashScope went to + * WebSocket); this class survives because Whisper still cares. + */ +public final class AudioMimeTypes { + + /** Fallback when neither contentType nor filename gives us a hint. */ + private static final String DEFAULT_EXTENSION = "wav"; + + /** content-type → conventional file extension. Ordered for documentation only. */ + private static final Map CONTENT_TYPE_TO_EXTENSION = Map.ofEntries( + Map.entry("audio/wav", "wav"), + Map.entry("audio/wave", "wav"), + Map.entry("audio/x-wav", "wav"), + Map.entry("audio/mpeg", "mp3"), + Map.entry("audio/mp3", "mp3"), + Map.entry("audio/mp4", "m4a"), + Map.entry("audio/m4a", "m4a"), + Map.entry("audio/x-m4a", "m4a"), + Map.entry("audio/aac", "aac"), + Map.entry("audio/flac", "flac"), + Map.entry("audio/ogg", "ogg"), + Map.entry("audio/webm", "webm"), + Map.entry("audio/amr", "amr")); + + /** file extension → conventional content-type (the inverse, for filename-first cases). */ + private static final Map EXTENSION_TO_CONTENT_TYPE = Map.ofEntries( + Map.entry("wav", "audio/wav"), + Map.entry("mp3", "audio/mpeg"), + Map.entry("m4a", "audio/mp4"), + Map.entry("mp4", "audio/mp4"), + Map.entry("aac", "audio/aac"), + Map.entry("flac", "audio/flac"), + Map.entry("ogg", "audio/ogg"), + Map.entry("webm", "audio/webm"), + Map.entry("amr", "audio/amr")); + + private AudioMimeTypes() {} + + /** + * Choose a filename for the upload. Order of precedence: + *

    + *
  1. The caller-supplied filename, when it has a known audio extension.
  2. + *
  3. A name synthesised from the content-type, e.g. {@code audio/mpeg → audio.mp3}.
  4. + *
  5. {@code audio.wav} as a final fallback (WAV is universally accepted).
  6. + *
+ */ + public static String resolveFileName(String fileName, String contentType) { + if (fileName != null && !fileName.isBlank() && extensionOf(fileName) != null) { + return fileName; + } + String extension = extensionForContentType(contentType); + return "audio." + (extension != null ? extension : DEFAULT_EXTENSION); + } + + /** Extract the lower-cased extension (without the dot), or null. Package-private for tests. */ + static String extensionOf(String fileName) { + if (fileName == null) return null; + int dot = fileName.lastIndexOf('.'); + if (dot < 0 || dot == fileName.length() - 1) return null; + String ext = fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + return EXTENSION_TO_CONTENT_TYPE.containsKey(ext) ? ext : null; + } + + /** Map a content-type to its conventional extension, or null when unknown. */ + static String extensionForContentType(String contentType) { + if (contentType == null) return null; + // Strip parameters: "audio/webm; codecs=opus" → "audio/webm" + int semi = contentType.indexOf(';'); + String base = (semi >= 0 ? contentType.substring(0, semi) : contentType).trim().toLowerCase(Locale.ROOT); + return CONTENT_TYPE_TO_EXTENSION.get(base); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java index 63c2e2a4..92c78714 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttProvider.java @@ -3,7 +3,16 @@ package vip.mate.stt; import vip.mate.system.model.SystemSettingsDTO; /** - * STT 语音识别提供商接口 + * STT 语音识别提供商接口. + * + *

Auto-detect ordering uses ascending priority (low number = preferred). + * Most providers can use the default {@link #autoDetectOrder()} value, but + * providers with strong language bias should override + * {@link #autoDetectOrder(String)} so the registry picks the right primary + * for the user's locale: OpenAI Whisper is the canonical English path, + * DashScope Paraformer is the canonical Chinese path. Mixing them up at + * dispatch time costs accuracy AND latency (the wrong primary tends to + * produce garbage that the fallback can't easily compensate for). */ public interface SttProvider { String id(); @@ -12,6 +21,23 @@ public interface SttProvider { int autoDetectOrder(); boolean isAvailable(SystemSettingsDTO config); + /** + * Per-language priority hook. Default implementation returns the + * language-agnostic {@link #autoDetectOrder()} value, so existing + * providers stay backwards-compatible. Override when the provider has + * a known language strength — e.g. OpenAI Whisper returns a smaller + * number for {@code "en"} than for {@code "zh"} to win the auto-pick + * for English users. + * + * @param language IETF / ISO-639 language hint, possibly {@code null}. + * Implementations should match conservatively (prefix + * match on {@code zh}, {@code en}, etc.) and gracefully + * fall back to {@link #autoDetectOrder()} on unknown. + */ + default int autoDetectOrder(String language) { + return autoDetectOrder(); + } + /** * 转写音频 */ diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java index 63468dca..7846e7f9 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttProviderRegistry.java @@ -11,41 +11,85 @@ import java.util.function.Function; import java.util.stream.Collectors; /** - * STT 提供商注册表 + * STT 提供商注册表. + * + *

Provider selection is two-stage: + *

    + *
  1. If the user pinned a specific provider in settings, that wins.
  2. + *
  3. Otherwise sort all available providers by + * {@link SttProvider#autoDetectOrder(String)} — the per-language + * hook lets Whisper win on English while Paraformer wins on Chinese. + * Falls back to the language-agnostic order when no language hint + * is supplied.
  4. + *
+ * + *

The registry is constructed once at startup and the unsorted provider + * list is held — sorting happens on demand because the order depends on the + * incoming language hint. */ @Slf4j @Component public class SttProviderRegistry { - private final List sortedProviders; + private final List providers; private final Map providerMap; public SttProviderRegistry(List providers) { - this.sortedProviders = providers.stream() - .sorted(Comparator.comparingInt(SttProvider::autoDetectOrder)) - .toList(); + this.providers = List.copyOf(providers); this.providerMap = providers.stream() .collect(Collectors.toMap(SttProvider::id, Function.identity())); - log.info("注册 STT 提供商 {} 个: {}", sortedProviders.size(), - sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList()); + log.info("注册 STT 提供商 {} 个: {}", providers.size(), + providers.stream() + .map(p -> p.id() + "(default-order=" + p.autoDetectOrder() + ")") + .toList()); } + /** Backwards-compatible no-language overload. Prefer the (config, language) form. */ public SttProvider resolve(SystemSettingsDTO config) { + return resolve(config, null); + } + + /** + * Pick the primary provider given the user's settings + a language hint. + * Returns null when nothing is available — callers should treat that as + * "no API key configured anywhere" and surface the actionable error. + */ + public SttProvider resolve(SystemSettingsDTO config, String language) { String configuredId = config.getSttProvider(); if (configuredId != null && !configuredId.isBlank() && !"auto".equals(configuredId)) { SttProvider p = providerMap.get(configuredId); if (p != null && p.isAvailable(config)) return p; + // Configured-but-unavailable falls through to auto so the user + // still gets a result if any other provider has its key set. } - for (SttProvider p : sortedProviders) { + for (SttProvider p : sortedByLanguage(language)) { if (p.isAvailable(config)) return p; } return null; } + /** Backwards-compatible no-language overload. */ public List fallbackCandidates(SystemSettingsDTO config, String excludeId) { - return sortedProviders.stream() + return fallbackCandidates(config, excludeId, null); + } + + /** + * Available providers other than {@code excludeId}, ordered by their + * priority for the given language. The fallback list always uses the + * language-aware order — if Whisper failed on Chinese, Paraformer is + * the right next-best, not whatever happened to come next in the + * default order. + */ + public List fallbackCandidates(SystemSettingsDTO config, String excludeId, String language) { + return sortedByLanguage(language).stream() .filter(p -> !p.id().equals(excludeId)) .filter(p -> p.isAvailable(config)) .toList(); } + + private List sortedByLanguage(String language) { + return providers.stream() + .sorted(Comparator.comparingInt(p -> p.autoDetectOrder(language))) + .toList(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttService.java b/mateclaw-server/src/main/java/vip/mate/stt/SttService.java index 44432bac..c7da7d8d 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/SttService.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttService.java @@ -26,6 +26,15 @@ public class SttService { return Map.of("success", false, "error", "STT 功能未启用,请在系统设置中开启"); } + // Per-call dispatch trace — without this the only signal that STT + // is even being attempted is the eventual provider success/failure + // log, which makes "no audio reached us" indistinguishable from + // "audio reached us but provider rejected it". + int bytes = audioData != null ? audioData.length : 0; + log.info("[STT] dispatch bytes={} fileName={} contentType={} language={} provider={}", + bytes, fileName, contentType, language, + config.getSttProvider() != null ? config.getSttProvider() : "auto"); + SttRequest request = SttRequest.builder() .audioData(audioData) .fileName(fileName) @@ -45,26 +54,61 @@ public class SttService { } private SttResult transcribeWithFallback(SttRequest request, SystemSettingsDTO config) { - SttProvider primary = providerRegistry.resolve(config); - if (primary == null) { - return SttResult.failure("没有可用的 STT Provider,请检查配置"); + // Language hint resolution order: + // 1. Caller-supplied request.language (explicit per-call override) + // 2. UI language from system settings (zh-CN / en-US) + // 3. null — registry falls back to the language-agnostic order + // Both registry primary-pick and fallback-candidate ordering use the + // same hint; without it Paraformer/Whisper would frequently swap + // priorities mid-fallback for the same conversation. + // + // Critical: write the resolved hint BACK into the request, so + // providers (especially DashScope's run-task language_hints field) + // see it. Pre-fix, providers got null even though we routed by + // zh-CN — DashScope's auto-detect would then sit through 2-3s of + // Chinese audio and emit zero result-generated events. + String languageHint = request.getLanguage(); + if (languageHint == null || languageHint.isBlank()) { + languageHint = config.getLanguage(); + if (languageHint != null && !languageHint.isBlank()) { + request.setLanguage(languageHint); + } } + SttProvider primary = providerRegistry.resolve(config, languageHint); + if (primary == null) { + // Most common cause: no provider has its API key configured. Tell + // the operator that explicitly so they don't dig through provider + // logs looking for the real reason. + log.warn("[STT] no provider available — check DashScope / OpenAI API keys in 模型管理"); + return SttResult.failure("没有可用的 STT Provider,请在模型管理中配置 DashScope 或 OpenAI API Key"); + } + log.info("[STT] primary provider resolved: {} (language={})", primary.id(), languageHint); SttResult result = primary.transcribe(request, config); - if (result.isSuccess()) return result; + if (result.isSuccess()) { + log.info("[STT] success via {} ({} chars)", primary.id(), result.getText() != null ? result.getText().length() : 0); + return result; + } + log.warn("[STT] primary {} failed: {}", primary.id(), result.getErrorMessage()); List errors = new ArrayList<>(); errors.add(primary.id() + ": " + result.getErrorMessage()); if (Boolean.TRUE.equals(config.getSttFallbackEnabled())) { - for (SttProvider fb : providerRegistry.fallbackCandidates(config, primary.id())) { - log.info("[STT] Trying fallback provider: {}", fb.id()); + for (SttProvider fb : providerRegistry.fallbackCandidates(config, primary.id(), languageHint)) { + log.info("[STT] trying fallback provider: {}", fb.id()); result = fb.transcribe(request, config); - if (result.isSuccess()) return result; + if (result.isSuccess()) { + log.info("[STT] fallback success via {} ({} chars)", fb.id(), + result.getText() != null ? result.getText().length() : 0); + return result; + } + log.warn("[STT] fallback {} failed: {}", fb.id(), result.getErrorMessage()); errors.add(fb.id() + ": " + result.getErrorMessage()); } } + log.error("[STT] all providers failed — errors: {}", errors); return SttResult.failure("所有 STT Provider 均失败\n" + String.join("\n", errors)); } } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java b/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java new file mode 100644 index 00000000..1f634385 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java @@ -0,0 +1,69 @@ +package vip.mate.stt; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Strip the RIFF/WAVE header off a WAV blob to expose raw PCM samples. + * + *

DashScope's realtime ASR expects the {@code parameters.format = "pcm"} + * input as **bare 16-bit signed little-endian PCM**, not WAV. The frontend + * (see {@code mateclaw-ui/src/utils/wavEncoder.ts}) emits a 16 kHz mono + * 16-bit WAV with the canonical 44-byte header — this helper unwraps it. + * + *

Why not just send the WAV: DashScope rejects with "format mismatch" + * because the first 44 bytes look like garbage when interpreted as PCM + * samples — they're the RIFF magic + format chunk metadata. + * + *

Limitations: handles only the canonical 44-byte WAV layout produced by + * MateClaw's WavRecorder. WAVs with extra chunks (LIST, JUNK, …) before the + * data chunk would need a chunk-walking parser. We don't currently accept + * arbitrary uploads, so the tighter scope is fine; if this changes, + * extend {@link #extract} to scan for the {@code "data"} chunk header + * instead of assuming offset 36. + */ +public final class WavPcmExtractor { + + /** Bytes before the "data" chunk in a canonical mono 16-bit PCM WAV. */ + public static final int CANONICAL_HEADER_BYTES = 44; + + /** Sample rate field offset in the canonical WAV header. */ + private static final int OFFSET_SAMPLE_RATE = 24; + + private WavPcmExtractor() {} + + /** + * Extract raw PCM bytes from a WAV blob. Throws when the input is too short + * or the magic header bytes don't look like RIFF/WAVE — better to fail loud + * here than ship garbage to DashScope and chase a confusing error code. + */ + public static byte[] extract(byte[] wavBytes) { + if (wavBytes == null || wavBytes.length < CANONICAL_HEADER_BYTES) { + throw new IllegalArgumentException( + "WAV input too short: " + (wavBytes == null ? 0 : wavBytes.length) + " bytes"); + } + if (wavBytes[0] != 'R' || wavBytes[1] != 'I' || wavBytes[2] != 'F' || wavBytes[3] != 'F' + || wavBytes[8] != 'W' || wavBytes[9] != 'A' || wavBytes[10] != 'V' || wavBytes[11] != 'E') { + throw new IllegalArgumentException("Not a WAV (missing RIFF/WAVE magic)"); + } + byte[] pcm = new byte[wavBytes.length - CANONICAL_HEADER_BYTES]; + System.arraycopy(wavBytes, CANONICAL_HEADER_BYTES, pcm, 0, pcm.length); + return pcm; + } + + /** + * Read the sample rate from a WAV header. Used by callers that need to + * tell DashScope the actual rate of the audio (the API requires the rate + * up front in the {@code run-task} message — getting it wrong produces + * recognisable but distorted transcripts). + */ + public static int sampleRate(byte[] wavBytes) { + if (wavBytes == null || wavBytes.length < CANONICAL_HEADER_BYTES) { + throw new IllegalArgumentException("WAV input too short for sample-rate read"); + } + return ByteBuffer.wrap(wavBytes, OFFSET_SAMPLE_RATE, 4) + .order(ByteOrder.LITTLE_ENDIAN) + .getInt(); + } + +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java index 18e9c5d3..8c4a8415 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java @@ -1,7 +1,5 @@ package vip.mate.stt.provider; -import cn.hutool.http.HttpRequest; -import cn.hutool.http.HttpResponse; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -11,63 +9,486 @@ import vip.mate.llm.service.ModelProviderService; import vip.mate.stt.SttProvider; import vip.mate.stt.SttRequest; import vip.mate.stt.SttResult; +import vip.mate.stt.WavPcmExtractor; import vip.mate.system.model.SystemSettingsDTO; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + /** - * DashScope STT Provider — Paraformer(OpenAI 兼容接口) - *

- * 复用模型管理中的 DashScope API Key。中文识别效果优秀。 + * DashScope STT Provider — Paraformer Realtime via WebSocket. + * + *

DashScope's only sync-callable STT path is the realtime WebSocket API + * — there is no /audio/transcriptions endpoint on either the + * native or OpenAI-compatible HTTP surface (verified empirically, returns + * 404). The earlier sync-HTTP version of this provider was speculative and + * has been replaced by this one. + * + *

Wire protocol

+ * Documented at Aliyun DashScope Realtime ASR. Message exchange: + *
    + *
  1. Open WS to {@value #WS_URL} with {@code Authorization: bearer + * } header.
  2. + *
  3. Client sends a {@code run-task} text frame with task_id + + * paraformer-realtime-v2 model + format/sample-rate parameters.
  4. + *
  5. Server replies with {@code task-started} text frame.
  6. + *
  7. Client streams raw 16-bit PCM bytes as binary frames (chunked at + * ~100ms each = {@value #CHUNK_BYTES} bytes for 16 kHz mono).
  8. + *
  9. Server emits {@code result-generated} events as transcripts come + * in. Each event carries a sentence keyed by {@code begin_time}; + * later events with the same {@code begin_time} update the same + * sentence (interim → final).
  10. + *
  11. Client sends {@code finish-task} text frame; server replies with + * {@code task-finished}; both sides close.
  12. + *
+ * + *

The {@link SttProvider} interface is sync — we bridge the async WS + * conversation to a blocking call via {@link CountDownLatch} (run-task ack + * + task-finished ack) plus an overall hard timeout. The whole transcribe + * call returns either a full transcript or a domain-typed + * {@link SttResult#failure} after at most {@value #OVERALL_TIMEOUT_MS}ms. */ @Slf4j @Component @RequiredArgsConstructor public class DashScopeSttProvider implements SttProvider { + /** DashScope WS endpoint for realtime inference (audio/text/multimodal). */ + static final URI WS_URL = URI.create("wss://dashscope.aliyuncs.com/api-ws/v1/inference/"); + + /** Default model — paraformer-realtime-v2 is the canonical 2024+ realtime ASR. */ + static final String DEFAULT_MODEL = "paraformer-realtime-v2"; + + /** Default sample rate in Hz. Must match the actual WAV — the helper reads it. */ + static final int DEFAULT_SAMPLE_RATE_HZ = 16_000; + + /** ~100ms of 16 kHz / 16-bit / mono PCM. DashScope recommends 100-300ms chunks. */ + static final int CHUNK_BYTES = 3200; + + /** + * How long to sleep between chunks. Paraformer-Realtime expects audio to + * arrive at roughly the natural recording rate; if we dump the whole clip + * in tens of milliseconds the server discards the stream and replies with + * task-finished + zero result-generated events. The official Python SDK + * does the same with {@code time.sleep(0.1)} between chunks. Matches + * {@link #CHUNK_BYTES} (100ms of audio → 100ms wall sleep). + */ + static final long CHUNK_PACING_MS = 100L; + + /** How long to wait for the WS handshake + task-started ack before giving up. */ + static final long TASK_STARTED_TIMEOUT_MS = 10_000L; + + /** Overall budget for a single transcribe — beyond this we abort the WS. */ + static final long OVERALL_TIMEOUT_MS = 60_000L; + private final ModelProviderService modelProviderService; private final ObjectMapper objectMapper; - - private static final String BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"; - private static final String DEFAULT_MODEL = "paraformer-v2"; + /** Shared HttpClient — JDK's WebSocket builder doesn't reuse the underlying + * connection pool when you allocate a fresh client per call, so making + * this a field saves a connection-pool spin-up on every transcribe. */ + private final HttpClient httpClient = HttpClient.newHttpClient(); @Override public String id() { return "dashscope"; } - @Override public String label() { return "DashScope (Paraformer)"; } + @Override public String label() { return "DashScope (Paraformer Realtime)"; } @Override public boolean requiresCredential() { return true; } @Override public int autoDetectOrder() { return 150; } + /** + * Per-language priority. Paraformer is the strongest mainstream Chinese + * STT, so push it ahead of Whisper on zh — see {@link SttProvider} javadoc + * for the routing rationale. + */ + @Override + public int autoDetectOrder(String language) { + if (language == null) return autoDetectOrder(); + String lang = language.toLowerCase(); + if (lang.startsWith("zh")) return 60; + return autoDetectOrder(); + } + @Override public boolean isAvailable(SystemSettingsDTO config) { - try { return modelProviderService.isProviderConfigured("dashscope"); } - catch (Exception e) { return false; } + try { + return modelProviderService.isProviderConfigured("dashscope"); + } catch (Exception e) { + log.warn("[DashScope STT] availability check failed: {}", e.getMessage()); + return false; + } } @Override public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { try { String apiKey = modelProviderService.getProviderConfig("dashscope").getApiKey(); - if (apiKey == null) return SttResult.failure("DashScope API Key 未配置"); - - String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL; - String fileName = request.getFileName() != null ? request.getFileName() : "audio.ogg"; - - HttpResponse response = HttpRequest.post(BASE_URL + "/audio/transcriptions") - .header("Authorization", "Bearer " + apiKey) - .form("model", model) - .form("file", request.getAudioData(), request.getContentType(), fileName) - .timeout(60_000) - .execute(); - - if (response.getStatus() == 200) { - JsonNode result = objectMapper.readTree(response.body()); - String text = result.path("text").asText(""); - log.info("[DashScope STT] Transcribed {} chars (model={})", text.length(), model); - return SttResult.success(text); - } else { - log.warn("[DashScope STT] Failed: HTTP {} - {}", response.getStatus(), response.body()); - return SttResult.failure("DashScope STT 失败: HTTP " + response.getStatus()); + if (apiKey == null || apiKey.isBlank()) { + return SttResult.failure("DashScope API Key 未配置"); } + byte[] audio = request.getAudioData(); + if (audio == null || audio.length < WavPcmExtractor.CANONICAL_HEADER_BYTES) { + return SttResult.failure("音频为空或过短"); + } + byte[] pcm = WavPcmExtractor.extract(audio); + int sampleRate = WavPcmExtractor.sampleRate(audio); + String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL; + String taskId = UUID.randomUUID().toString().replace("-", ""); + + // Peak/RMS check — the silence path is a failure mode worth its + // own log line so users can tell "mic captured nothing" from + // "DashScope rejected real audio". Successful calls log peak/rms + // at DEBUG only; a healthy call shouldn't produce a per-request + // INFO log every time the user holds the talk button. + int[] peakRms = computePcmPeakRms(pcm); + if (peakRms[0] == 0) { + log.warn("[DashScope STT] PCM is silent (peak=0, bytes={}) — check mic permission / frontend recording", + pcm.length); + return SttResult.failure( + "音频为静音(PCM peak=0)— 检查麦克风权限或前端录制实现"); + } + log.debug("[DashScope STT] PCM stats — bytes={} samples={} peak={} rms={} sampleRate={}", + pcm.length, pcm.length / 2, peakRms[0], peakRms[1], sampleRate); + + DashScopeSession session = new DashScopeSession(taskId, objectMapper); + WebSocket ws; + try { + ws = httpClient.newWebSocketBuilder() + .header("Authorization", "bearer " + apiKey) + .connectTimeout(Duration.ofMillis(TASK_STARTED_TIMEOUT_MS)) + .buildAsync(WS_URL, session) + .get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + return SttResult.failure("DashScope WS 握手超时"); + } + + try { + // 1. run-task. Envelope dumped at DEBUG only — the JSON is + // identical across calls modulo task_id + language hint, so + // logging it on every transcribe just clutters logs. + String runTask = buildRunTask(taskId, model, sampleRate, request.getLanguage()); + log.debug("[DashScope STT] run-task envelope: {}", runTask); + ws.sendText(runTask, true).get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + // 2. wait for task-started ack + if (!session.awaitTaskStarted(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + return SttResult.failure("DashScope task-started 超时"); + } + if (session.failed()) { + return SttResult.failure("DashScope: " + session.errorMessage()); + } + + // 3. stream PCM chunks at real-time pace. Paraformer-Realtime + // is built for live mic input and silently drops audio when it + // arrives faster than wall-clock — symptom is 0 chars + // transcribed even though the protocol completes successfully + // (no task-failed). Sleep 100ms between 100ms chunks so total + // send time ≈ audio duration, matching what DashScope's own + // SDK examples do (time.sleep(0.1) per chunk). + int chunksSent = 0; + long sendStart = System.currentTimeMillis(); + for (int offset = 0; offset < pcm.length; offset += CHUNK_BYTES) { + int len = Math.min(CHUNK_BYTES, pcm.length - offset); + ByteBuffer chunk = ByteBuffer.wrap(pcm, offset, len); + ws.sendBinary(chunk, true).get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS); + chunksSent++; + Thread.sleep(CHUNK_PACING_MS); + // Cheap fail-fast: if the server already said we're done / + // failed mid-stream, stop sending so we don't waste seconds + // sleeping on a dead connection. + if (session.failed() || session.taskFinishedRaised()) break; + } + long sendDuration = System.currentTimeMillis() - sendStart; + log.debug("[DashScope STT] streamed {} chunks ({} bytes) in {} ms", + chunksSent, pcm.length, sendDuration); + + // 4. finish-task + ws.sendText(buildFinishTask(taskId), true).get(TASK_STARTED_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + // 5. wait for task-finished + if (!session.awaitTaskFinished(OVERALL_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + return SttResult.failure("DashScope task-finished 超时"); + } + if (session.failed()) { + return SttResult.failure("DashScope: " + session.errorMessage()); + } + + String text = session.aggregatedText(); + log.info("[DashScope STT] Transcribed {} chars from {} result-events " + + "(model={}, sampleRate={}, pcmBytes={})", + text.length(), session.resultEventCount(), model, sampleRate, pcm.length); + if (text.isEmpty() && session.resultEventCount() == 0) { + // Distinct failure mode: protocol completed cleanly but + // server never sent a single result-generated event. + // Almost always means the audio was discarded for + // pacing/format reasons. Surface as a typed failure so + // the fallback chain (Whisper) can still try. + return SttResult.failure( + "DashScope 收到 0 个识别事件——可能是音频格式或节奏问题"); + } + return SttResult.success(text); + } finally { + // Best-effort close. abort() is fire-and-forget; we don't need to wait. + try { + ws.sendClose(WebSocket.NORMAL_CLOSURE, "done"); + } catch (Exception ignored) { + ws.abort(); + } + } + } catch (TimeoutException e) { + log.warn("[DashScope STT] timeout: {}", e.getMessage()); + return SttResult.failure("DashScope STT 超时: " + e.getMessage()); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + log.error("[DashScope STT] WS error: {}", cause.getMessage(), cause); + return SttResult.failure("DashScope STT WS 错误: " + cause.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return SttResult.failure("DashScope STT 被中断"); } catch (Exception e) { log.error("[DashScope STT] Error: {}", e.getMessage(), e); return SttResult.failure("DashScope STT 异常: " + e.getMessage()); } } + + /* ====================================================================== */ + /* Wire-format helpers (package-private for unit testing). */ + /* ====================================================================== */ + + String buildRunTask(String taskId, String model, int sampleRate, String language) throws Exception { + Map parameters = new LinkedHashMap<>(); + parameters.put("format", "pcm"); + parameters.put("sample_rate", sampleRate); + // Language hint when supplied — paraformer-realtime-v2 supports + // "zh", "en", "ja", "ko" via language_hints. Skip when null/blank + // to let the model auto-detect. + if (language != null && !language.isBlank()) { + // Strip locale suffix (zh-CN → zh). + String hint = language.toLowerCase(); + int dash = hint.indexOf('-'); + if (dash > 0) hint = hint.substring(0, dash); + parameters.put("language_hints", new String[]{hint}); + } + + Map payload = Map.of( + "task_group", "audio", + "task", "asr", + "function", "recognition", + "model", model, + "parameters", parameters, + "input", Map.of()); + Map message = Map.of( + "header", Map.of( + "action", "run-task", + "task_id", taskId, + "streaming", "duplex"), + "payload", payload); + return objectMapper.writeValueAsString(message); + } + + /** + * Compute peak (max absolute value) and RMS for 16-bit signed + * little-endian PCM bytes. Returns {peak, rms} as ints for log-friendly + * formatting. Both metrics are in raw int16 units (-32768..32767). + * + *

Reference values for 16-bit PCM at typical recording levels: + *

    + *
  • Silence / muted mic: peak ≤ 5, rms ≤ 2
  • + *
  • Quiet speech: peak ≈ 1000-5000, rms ≈ 200-1000
  • + *
  • Normal speech: peak ≈ 5000-20000, rms ≈ 1000-5000
  • + *
  • Loud / close-mic: peak ≈ 20000-32000, rms ≈ 5000-15000
  • + *
+ */ + static int[] computePcmPeakRms(byte[] pcm) { + if (pcm == null || pcm.length < 2) { + return new int[]{0, 0}; + } + int peak = 0; + long sumSq = 0; + int sampleCount = pcm.length / 2; + for (int i = 0; i < sampleCount; i++) { + // Little-endian 16-bit signed: low byte first. + int lo = pcm[i * 2] & 0xFF; + int hi = pcm[i * 2 + 1]; // signed + int sample = (hi << 8) | lo; + int abs = Math.abs(sample); + if (abs > peak) peak = abs; + sumSq += (long) sample * sample; + } + int rms = (int) Math.sqrt((double) sumSq / sampleCount); + return new int[]{peak, rms}; + } + + String buildFinishTask(String taskId) throws Exception { + Map message = Map.of( + "header", Map.of( + "action", "finish-task", + "task_id", taskId, + "streaming", "duplex"), + "payload", Map.of("input", Map.of())); + return objectMapper.writeValueAsString(message); + } + + /* ====================================================================== */ + /* WebSocket.Listener: collects events and signals task-started/finished. */ + /* ====================================================================== */ + + /** + * State machine for one DashScope ASR conversation. Package-private so + * unit tests can drive it with synthetic JSON without hitting the network. + */ + static class DashScopeSession implements WebSocket.Listener { + private final String taskId; + private final ObjectMapper mapper; + private final CountDownLatch taskStarted = new CountDownLatch(1); + private final CountDownLatch taskFinished = new CountDownLatch(1); + + /** + * Sentence buffer keyed by begin_time. DashScope emits multiple + * {@code result-generated} events for the same sentence as it gets + * refined (interim → final); each new event for a given begin_time + * supersedes the previous text. LinkedHashMap preserves arrival + * order, which roughly matches speech order, for the final concat. + */ + private final Map sentencesByBeginTime = new LinkedHashMap<>(); + + /** Buffer for fragmented text frames (WS allows partial messages). */ + private final StringBuilder textFrameBuf = new StringBuilder(); + + private final AtomicReference errorMessage = new AtomicReference<>(); + + /** Counts result-generated events — distinguishes "server got our audio + * but recognised nothing" (>0 events, all empty text) from "server + * saw zero audio frames" (0 events). Helps diagnose pacing / + * format issues. */ + private int resultEventCount; + + DashScopeSession(String taskId, ObjectMapper mapper) { + this.taskId = taskId; + this.mapper = mapper; + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textFrameBuf.append(data); + if (last) { + handleMessage(textFrameBuf.toString()); + textFrameBuf.setLength(0); + } + webSocket.request(1); + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + errorMessage.compareAndSet(null, "WS error: " + error.getMessage()); + taskStarted.countDown(); + taskFinished.countDown(); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + // If the server closes before task-finished, unblock waiters. + if (taskFinished.getCount() > 0) { + errorMessage.compareAndSet(null, + "WS closed before task-finished (status=" + statusCode + ", reason=" + reason + ")"); + } + taskStarted.countDown(); + taskFinished.countDown(); + return null; + } + + /** Package-private hook so unit tests can drive {@link DashScopeSession} without a real WebSocket. */ + void handleMessage(String json) { + // Always trace the raw frame at DEBUG — this is invaluable when + // the protocol completes "successfully" but produces no + // transcripts. Without seeing every frame it's impossible to + // tell whether DashScope sent us a status-update / warning we + // ignored, or just stayed silent between task-started and + // task-finished. + log.debug("[DashScope STT] frame: {}", json); + try { + JsonNode node = mapper.readTree(json); + String event = node.path("header").path("event").asText(); + switch (event) { + case "task-started" -> taskStarted.countDown(); + case "result-generated" -> { + resultEventCount++; + JsonNode sentence = node.path("payload").path("output").path("sentence"); + if (sentence.isObject()) { + long beginTime = sentence.path("begin_time").asLong(0L); + String text = sentence.path("text").asText(""); + // Always overwrite — later events for the same begin_time + // carry the more-final transcript. + sentencesByBeginTime.put(beginTime, text); + } + } + case "task-finished" -> taskFinished.countDown(); + case "task-failed" -> { + String msg = node.path("header").path("error_message").asText("unknown"); + String code = node.path("header").path("error_code").asText(""); + errorMessage.compareAndSet(null, + code.isEmpty() ? msg : (code + " — " + msg)); + // Wake both latches so the caller can return the typed + // failure instead of timing out for the full budget. + taskStarted.countDown(); + taskFinished.countDown(); + } + // Anything else (status updates, model warnings, beta + // events) gets surfaced at INFO so it shows up without + // turning DEBUG on. If DashScope rolls out a new event + // type we should know about, this catches it. + default -> log.info("[DashScope STT] unhandled event '{}' frame={}", event, json); + } + } catch (Exception e) { + log.warn("[DashScope STT] failed to parse WS message: {}", e.getMessage()); + } + } + + boolean awaitTaskStarted(long timeout, TimeUnit unit) throws InterruptedException { + return taskStarted.await(timeout, unit); + } + + boolean awaitTaskFinished(long timeout, TimeUnit unit) throws InterruptedException { + return taskFinished.await(timeout, unit); + } + + boolean failed() { + return errorMessage.get() != null; + } + + String errorMessage() { + return errorMessage.get(); + } + + /** True once task-finished has been observed — used by the sender + * loop to bail out early instead of pacing through dead-WS sleeps. */ + boolean taskFinishedRaised() { + return taskFinished.getCount() == 0; + } + + int resultEventCount() { + return resultEventCount; + } + + String aggregatedText() { + // Concat in begin_time order. Different sentences typically don't + // need separator characters because Chinese text streams are + // already glued; for safety against missed punctuation we leave + // a soft join ("") rather than space — Whisper-style space + // joining produces odd-looking Chinese transcripts. + StringBuilder sb = new StringBuilder(); + sentencesByBeginTime.values().forEach(sb::append); + return sb.toString(); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java index 923dd261..1ac7a75e 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.llm.service.ModelProviderService; +import vip.mate.stt.AudioMimeTypes; import vip.mate.stt.SttProvider; import vip.mate.stt.SttRequest; import vip.mate.stt.SttResult; @@ -33,10 +34,33 @@ public class OpenAiSttProvider implements SttProvider { @Override public boolean requiresCredential() { return true; } @Override public int autoDetectOrder() { return 100; } + /** + * Whisper is the canonical English STT and noticeably weaker on Chinese + * (it tends to produce simplified-character output even for traditional + * input, and short Chinese clips frequently transcribe to gibberish). + * Boost Whisper's priority for English/Japanese/Korean (where it leads), + * and de-prioritise it for Chinese so DashScope (Paraformer) wins the + * auto-pick. + */ + @Override + public int autoDetectOrder(String language) { + if (language == null) return autoDetectOrder(); + String lang = language.toLowerCase(); + if (lang.startsWith("zh")) return 250; // pushed below DashScope Paraformer + if (lang.startsWith("en") + || lang.startsWith("ja") + || lang.startsWith("ko")) return 80; // pulled above DashScope + return autoDetectOrder(); + } + @Override public boolean isAvailable(SystemSettingsDTO config) { - try { return modelProviderService.isProviderConfigured("openai"); } - catch (Exception e) { return false; } + try { + return modelProviderService.isProviderConfigured("openai"); + } catch (Exception e) { + log.warn("[OpenAI STT] availability check failed: {}", e.getMessage()); + return false; + } } @Override @@ -48,12 +72,18 @@ public class OpenAiSttProvider implements SttProvider { String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/audio/transcriptions"; String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL; - String fileName = request.getFileName() != null ? request.getFileName() : "audio.ogg"; + // AudioMimeTypes ensures the filename extension matches the + // actual bytes (audio.wav, audio.mp3, etc.), which Hutool then + // uses to infer the multipart Content-Type. Don't pass + // contentType to .form() explicitly — Hutool has no + // form(String,byte[],String,String) overload, and the wrong + // dispatch crashes with ClassCastException on byte[] → Object[]. + String fileName = AudioMimeTypes.resolveFileName(request.getFileName(), request.getContentType()); HttpResponse response = HttpRequest.post(url) .header("Authorization", "Bearer " + apiKey) .form("model", model) - .form("file", request.getAudioData(), request.getContentType(), fileName) + .form("file", request.getAudioData(), fileName) .timeout(60_000) .execute(); diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 9d0471a8..75852890 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -70,6 +70,16 @@ public class SystemSettingsDTO { @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String minimaxApiKey; private String minimaxApiKeyMasked; + /** + * MiniMax API region — selects which host to call. Shared by image + video + * providers because the API key is the same across both: + *
    + *
  • {@code "global"} (default) → {@code https://api.minimax.io}
  • + *
  • {@code "cn"} → {@code https://api.minimaxi.com} (lower latency from + * mainland China; required for accounts registered there).
  • + *
+ */ + private String minimaxRegion; // ===== 图片生成配置 ===== /** 是否启用图片生成能力 */ 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 ef4e1a7a..1128ffb4 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 @@ -8,6 +8,7 @@ import vip.mate.exception.MateClawException; import vip.mate.llm.model.ProviderInfoDTO; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelProviderService; +import vip.mate.tool.browser.BrowserDiagnosticsService; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.runtime.McpClientManager; import vip.mate.tool.mcp.runtime.McpClientManager.ConnectionResult; @@ -34,6 +35,7 @@ public class SystemHealthService { private final McpClientManager mcpClientManager; private final McpServerService mcpServerService; private final DatabaseBootstrapRunner bootstrapRunner; + private final BrowserDiagnosticsService browserDiagnostics; public HealthResponse check() { List checks = new ArrayList<>(); @@ -50,6 +52,9 @@ public class SystemHealthService { // 4. Database initialization check checks.add(checkDatabase()); + // 5. Browser launch pre-flight (common failure source on fresh win/linux hosts) + checks.add(checkBrowser()); + // Determine overall status String overall = "healthy"; for (HealthCheck c : checks) { @@ -161,6 +166,28 @@ public class SystemHealthService { ); } + private HealthCheck checkBrowser() { + try { + BrowserDiagnosticsService.Report report = browserDiagnostics.run(); + String status = switch (report.overall()) { + case "healthy" -> "healthy"; + case "warning" -> "warning"; + default -> "error"; + }; + String message = "healthy".equals(report.overall()) + ? "Browser launch ready" + : String.join(" | ", report.advice()); + HealthAction action = "healthy".equals(report.overall()) + ? null + : new HealthAction("Diagnose", "/api/v1/system/browser-health"); + return new HealthCheck("browser", status, message, action); + } catch (Exception e) { + log.warn("Browser diagnostics failed: {}", e.getMessage()); + return new HealthCheck("browser", "warning", + "Browser diagnostics failed: " + e.getMessage(), null); + } + } + // ==================== Response Records ==================== public record HealthResponse(String overall, List checks) {} diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 8471102c..6251c0f4 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -64,6 +64,18 @@ public class SystemSettingService { private final SystemSettingMapper systemSettingMapper; + /** + * Resolve the SearXNG base URL: DB value takes priority; fall back to the + * {@code SEARXNG_BASE_URL} environment variable so Docker deployments work + * out-of-the-box without manual configuration in the UI. + */ + private String resolveSearxngBaseUrl() { + String dbValue = getValue(SEARXNG_BASE_URL_KEY, ""); + if (dbValue != null && !dbValue.isBlank()) return dbValue; + String envValue = System.getenv("SEARXNG_BASE_URL"); + return (envValue != null && !envValue.isBlank()) ? envValue : ""; + } + public SystemSettingsDTO getSettings() { SystemSettingsDTO dto = new SystemSettingsDTO(); dto.setLanguage(getValue(LANGUAGE_KEY, "zh-CN")); @@ -79,7 +91,7 @@ public class SystemSettingService { dto.setTavilyBaseUrl(getValue(TAVILY_BASE_URL_KEY, "https://api.tavily.com/search")); // Keyless provider 配置 dto.setDuckduckgoEnabled(Boolean.parseBoolean(getValue(DUCKDUCKGO_ENABLED_KEY, "true"))); - dto.setSearxngBaseUrl(getValue(SEARXNG_BASE_URL_KEY, "")); + dto.setSearxngBaseUrl(resolveSearxngBaseUrl()); // API Key 脱敏回显 dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, ""))); dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, ""))); @@ -153,7 +165,7 @@ public class SystemSettingService { dto.setTavilyApiKey(getValue(TAVILY_API_KEY_KEY, "")); dto.setTavilyBaseUrl(getValue(TAVILY_BASE_URL_KEY, "https://api.tavily.com/search")); dto.setDuckduckgoEnabled(Boolean.parseBoolean(getValue(DUCKDUCKGO_ENABLED_KEY, "true"))); - dto.setSearxngBaseUrl(getValue(SEARXNG_BASE_URL_KEY, "")); + dto.setSearxngBaseUrl(resolveSearxngBaseUrl()); return dto; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ConcurrencyUnsafe.java b/mateclaw-server/src/main/java/vip/mate/tool/ConcurrencyUnsafe.java new file mode 100644 index 00000000..22bdb97c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/ConcurrencyUnsafe.java @@ -0,0 +1,46 @@ +package vip.mate.tool; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a {@link org.springframework.ai.tool.annotation.Tool}-annotated method + * as not safe to run concurrently with itself or with other tools that + * touch the same state. + * + *

Read by {@link vip.mate.tool.ToolConcurrencyRegistry} at startup. The + * registry returns {@code true} from + * {@link ToolConcurrencyRegistry#isUnsafe(String)} for marked tools, which + * causes {@code ToolExecutionExecutor} to execute them in their own batch + * (no parallelism, no overlap with the surrounding safe batch).

+ * + *

Use cases:

+ *
    + *
  • File writes / edits ({@code WriteFileTool}, {@code EditFileTool})
  • + *
  • Shell command execution ({@code ShellExecuteTool})
  • + *
  • Stateful workspace mutations ({@code WorkspaceMemoryTool}, {@code SkillManageTool})
  • + *
  • Persistent operations on shared resources ({@code CronJobTool}, {@code DatasourceTool})
  • + *
  • Long-running generative tools where API rate limits forbid parallel calls + * ({@code ImageGenerateTool}, {@code VideoGenerateTool})
  • + *
+ * + *

Read-only and idempotent tools should remain unannotated; they will be + * batched together for parallel execution by the executor.

+ * + *

For MCP-provided tools the executor will eventually consult the + * {@code annotations.readOnlyHint} field from the MCP {@code Tool} schema; + * that integration is tracked as a Phase 4 follow-up. Until then MCP tools + * default to safe (their pre-existing behavior).

+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ConcurrencyUnsafe { + + /** + * Optional human-readable reason. Surfaced in startup logs to help + * operators audit which tools have been marked unsafe. + */ + String value() default ""; +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java new file mode 100644 index 00000000..c6624c69 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java @@ -0,0 +1,129 @@ +package vip.mate.tool; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Component; +import org.springframework.util.ClassUtils; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Startup-scanned registry of tools that must run sequentially + * ({@link ConcurrencyUnsafe}-annotated). Replaces the previous hardcoded + * {@code DEFAULT_UNSAFE_TOOLS} set in {@code ToolExecutionExecutor}. + * + *

Discovery walks every bean definition and inspects the declared + * class's methods for the {@link Tool} + {@link ConcurrencyUnsafe} pair. + * Beans are not instantiated by this scan — we only resolve the bean + * class name and load it via the class loader, which preserves {@code @Lazy} + * semantics and avoids triggering ChatModel / DataSource / MCP-client + * construction at registry init.

+ * + *

Tool name resolution mirrors Spring AI's logic: {@code @Tool#name()} + * when set, otherwise the method's simple name.

+ * + *

The registry is immutable after {@link #scan()}; the unsafe set is + * populated once and consulted on every tool execution. MCP tools are not + * scanned (their {@link Tool} annotations live inside the MCP framework, not + * on user-visible methods); MCP support is tracked as a follow-up.

+ */ +@Slf4j +@Component +public class ToolConcurrencyRegistry { + + private final ConfigurableApplicationContext applicationContext; + + /** Populated once at startup; never mutated thereafter. */ + private volatile Set unsafeNames = Collections.emptySet(); + + public ToolConcurrencyRegistry(ConfigurableApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @PostConstruct + void scan() { + Set discovered = new HashSet<>(); + ConfigurableListableBeanFactory factory = applicationContext.getBeanFactory(); + ClassLoader classLoader = applicationContext.getClassLoader(); + + for (String beanName : factory.getBeanDefinitionNames()) { + Class beanClass = resolveBeanClassWithoutInstantiating(factory, beanName, classLoader); + if (beanClass == null) continue; + + // Unwrap CGLIB subclasses (proxies) so we see user-declared methods. + Class userClass = ClassUtils.getUserClass(beanClass); + for (Method method : userClass.getDeclaredMethods()) { + Tool tool = method.getAnnotation(Tool.class); + if (tool == null) continue; + ConcurrencyUnsafe unsafe = method.getAnnotation(ConcurrencyUnsafe.class); + if (unsafe == null) continue; + String toolName = tool.name() != null && !tool.name().isEmpty() ? tool.name() : method.getName(); + discovered.add(toolName); + log.info("[ToolConcurrencyRegistry] Marked tool '{}' as unsafe ({}#{}): {}", + toolName, userClass.getSimpleName(), method.getName(), + unsafe.value().isEmpty() ? "no reason given" : unsafe.value()); + } + } + // Keep the legacy hardcoded names so existing deployments without + // annotations still see the same behavior. New code should rely on + // the @ConcurrencyUnsafe annotation rather than this list. + discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "edit_file")); + this.unsafeNames = Collections.unmodifiableSet(discovered); + log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}", + unsafeNames.size(), unsafeNames); + } + + /** + * Resolve a bean's class without instantiating it. + * Preference order: + *
    + *
  1. {@link BeanDefinition#getBeanClassName()} → {@link Class#forName} via the context class loader + * (works for stereotype-scanned components).
  2. + *
  3. {@code factory.getType(beanName, false)} as a fallback for + * {@code @Bean}-defined or programmatically registered beans. + * The {@code false} flag forbids FactoryBean initialization.
  4. + *
+ * Returns {@code null} when neither path yields a class — for example, + * lambda-defined beans without a resolvable class name. + */ + private static Class resolveBeanClassWithoutInstantiating(ConfigurableListableBeanFactory factory, + String beanName, + ClassLoader classLoader) { + try { + BeanDefinition bd = factory.getBeanDefinition(beanName); + String className = bd.getBeanClassName(); + if (className != null && !className.isEmpty()) { + try { + return Class.forName(className, false, classLoader); + } catch (ClassNotFoundException | LinkageError ignored) { + // Fall through to factory.getType fallback. + } + } + } catch (Exception ignored) { + // No bean definition (singleton registered programmatically); fall through. + } + try { + return factory.getType(beanName, false); + } catch (Exception ignored) { + return null; + } + } + + /** {@code true} when the named tool must execute alone (no parallelism). */ + public boolean isUnsafe(String toolName) { + return toolName != null && unsafeNames.contains(toolName); + } + + /** Defensive copy for diagnostics / admin endpoints. */ + public Set snapshot() { + return unsafeNames; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index 24cdadd2..8a736fe1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -18,6 +18,8 @@ import vip.mate.i18n.LocaleAwareToolCallback; import java.util.ArrayList; import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -71,6 +73,19 @@ public class ToolRegistry { * 通过数据库 enabled 标志过滤,确保 UI 开关真正生效 */ public List getEnabledTools() { + return List.copyOf(getEnabledToolBeansByName().values()); + } + + /** + * Iterate Spring beans once, returning a {@code beanName → bean} map of every + * currently-enabled @Tool bean. + *

+ * This is the single source of truth for "which @Tool beans should the agent see"; both + * {@link #getEnabledTools()} and {@link #getEnabledToolSet()} build on it. Returning + * {@link LinkedHashMap} preserves the discovery order from {@code getBeansWithAnnotation}, + * which {@link AgentToolSet} relies on (built-in tools first, MCP tools second). + */ + private LinkedHashMap getEnabledToolBeansByName() { // 1. 从数据库获取明确禁用的 beanName 黑名单 // 逻辑:只有 DB 中存在记录且 enabled=false 的才跳过 // DB 中没有记录的 bean 默认启用(向后兼容 + 新工具自动可用) @@ -82,7 +97,7 @@ public class ToolRegistry { .map(ToolEntity::getBeanName) .collect(Collectors.toSet()); - List tools = new ArrayList<>(); + LinkedHashMap enabled = new LinkedHashMap<>(); // 2. 扫描 Spring 容器中所有带 @Tool 方法的 Bean Map beans = applicationContext.getBeansWithAnnotation(Component.class); @@ -93,19 +108,20 @@ public class ToolRegistry { boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods()) .anyMatch(m -> m.isAnnotationPresent(Tool.class)); - if (hasToolMethod) { - // 3. 只有 DB 中明确 enabled=false 的才跳过,其余全部启用 - if (disabledBeanNames.contains(beanName)) { - log.debug("Skipped disabled tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName); - } else { - tools.add(bean); - log.debug("Registered tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName); - } + if (!hasToolMethod) { + continue; + } + // 3. 只有 DB 中明确 enabled=false 的才跳过,其余全部启用 + if (disabledBeanNames.contains(beanName)) { + log.debug("Skipped disabled tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName); + } else { + enabled.put(beanName, bean); + log.debug("Registered tool bean: {} (beanName={})", bean.getClass().getSimpleName(), beanName); } } - log.info("Total enabled tools: {}", tools.size()); - return tools; + log.info("Total enabled tools: {}", enabled.size()); + return enabled; } /** @@ -116,7 +132,16 @@ public class ToolRegistry { * 2. 当前容器中所有 ToolCallbackProvider(MCP server 等) */ public AgentToolSet getEnabledToolSet() { - List toolBeans = getEnabledTools(); + // Build both the bean list and the identity-based name lookup in one pass — the + // latter lets AgentToolSet's alias index resolve a saved binding like + // "BrowserUseTool" or "browserUseTool" back to the same callback as "browser_use". + LinkedHashMap beansByName = getEnabledToolBeansByName(); + List toolBeans = new ArrayList<>(beansByName.values()); + IdentityHashMap nameByBean = new IdentityHashMap<>(); + for (Map.Entry e : beansByName.entrySet()) { + nameByBean.put(e.getValue(), e.getKey()); + } + Map providerBeans = applicationContext.getBeansOfType(ToolCallbackProvider.class); List providers = new ArrayList<>(providerBeans.values()); @@ -164,7 +189,66 @@ public class ToolRegistry { log.info("Building AgentToolSet: toolBeans={}, providers={}, pluginTools={}, totalCallbacks={}", toolBeans.size(), providers.size(), pluginToolCount, localizedCallbacks.size()); - return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks); + return AgentToolSet.fromCallbacks(toolBeans, localizedCallbacks, nameByBean::get); + } + + /** + * Returns every runtime identifier by which a currently-enabled tool can be + * referenced — SKILL.md authors use all three conventions interchangeably: + *
    + *
  • {@code @Tool} function name (e.g. {@code browser_use}, {@code runSkillScript})
  • + *
  • Spring bean name (e.g. {@code browserUseTool}, {@code skillScriptTool})
  • + *
  • MCP tool id / plugin tool name (routed via {@code ToolCallbackProvider})
  • + *
+ * Returning the union lets {@link vip.mate.skill.runtime.SkillDependencyChecker} + * accept whichever convention a skill happens to declare. + */ + public Set availableFunctionNames() { + Set names = new java.util.HashSet<>(); + + Set disabledBeanNames = toolMapper.selectList( + new LambdaQueryWrapper() + .eq(ToolEntity::getEnabled, false) + .isNotNull(ToolEntity::getBeanName) + ).stream().map(ToolEntity::getBeanName).collect(Collectors.toSet()); + + // 1. @Tool beans — register both the bean name and every function name exposed. + Map beans = applicationContext.getBeansWithAnnotation(Component.class); + for (Map.Entry entry : beans.entrySet()) { + String beanName = entry.getKey(); + Object bean = entry.getValue(); + if (disabledBeanNames.contains(beanName)) continue; + boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods()) + .anyMatch(m -> m.isAnnotationPresent(Tool.class)); + if (!hasToolMethod) continue; + names.add(beanName); + for (ToolCallback cb : ToolCallbacks.from(bean)) { + names.add(cb.getToolDefinition().name()); + } + } + + // 2. MCP providers — only function names exist here. + Map providers = applicationContext.getBeansOfType(ToolCallbackProvider.class); + for (ToolCallbackProvider provider : providers.values()) { + ToolCallback[] cbs = provider.getToolCallbacks(); + if (cbs == null) continue; + for (ToolCallback cb : cbs) { + names.add(cb.getToolDefinition().name()); + } + } + + // 3. Plugin-registered tools — evaluate availability lazily so disabled plugins drop out. + for (PluginToolEntry entry : pluginTools) { + try { + if (Boolean.TRUE.equals(entry.availabilityCheck().get())) { + names.add(entry.callback().getToolDefinition().name()); + } + } catch (Exception ignored) { + // Unreachable plugin tools don't contribute to the set. + } + } + + return names; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java new file mode 100644 index 00000000..4d216bda --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java @@ -0,0 +1,320 @@ +package vip.mate.tool.browser; + +import cn.hutool.http.HttpUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Diagnoses why the browser tool might fail to launch on this host. + * + *

Runs a dry inventory — detecting system browsers, Playwright cache, Node runtime, + * required shared libraries on Linux, container / root context — without actually + * launching a session. Produces a structured report with actionable next steps. + */ +@Slf4j +@Service +public class BrowserDiagnosticsService { + + private static final boolean IS_WINDOWS = System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT).contains("win"); + private static final boolean IS_LINUX = System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT).contains("linux"); + + /** Shared libraries Chromium needs on Linux. Missing any is a hard block. */ + private static final List REQUIRED_LINUX_LIBS = List.of( + "libnss3", "libgbm", "libasound", "libxkbcommon", "libx11", "libxcomposite", + "libxdamage", "libxrandr", "libxfixes", "libatk", "libcups", "libpango" + ); + + private final BrowserProperties props; + + public BrowserDiagnosticsService(BrowserProperties props) { + this.props = props; + } + + public Report run() { + List findings = new ArrayList<>(); + findings.add(inspectEnvironment()); + findings.add(inspectConfiguredCdp()); + findings.add(inspectConfiguredPath()); + findings.add(inspectEnvPath()); + findings.add(inspectSystemBrowsers()); + findings.add(inspectPlaywrightCache()); + if (IS_LINUX) { + findings.add(inspectLinuxLibs()); + } + + String overall = deriveOverall(findings); + List advice = deriveAdvice(findings); + return new Report(overall, findings, advice); + } + + // ==================== Individual probes ==================== + + private Finding inspectEnvironment() { + Map data = new LinkedHashMap<>(); + data.put("os", System.getProperty("os.name")); + data.put("arch", System.getProperty("os.arch")); + data.put("user", System.getProperty("user.name")); + data.put("container", BrowserLauncher.isRunningInContainer()); + data.put("root", BrowserLauncher.isRunningAsRoot()); + return new Finding("environment", Status.INFO, "Runtime environment", data, null); + } + + private Finding inspectConfiguredCdp() { + String url = props.getCdpUrl(); + if (url == null || url.isBlank()) { + return new Finding("config.cdp-url", Status.INFO, "mateclaw.browser.cdp-url not set", Map.of(), null); + } + Map data = new LinkedHashMap<>(); + data.put("url", url); + try { + String resp = HttpUtil.get(stripTrailing(url) + "/json/version", 2000); + if (resp != null && resp.contains("webSocketDebuggerUrl")) { + data.put("reachable", true); + return new Finding("config.cdp-url", Status.OK, + "CDP endpoint reachable", data, null); + } + data.put("reachable", false); + data.put("response", resp); + return new Finding("config.cdp-url", Status.ERROR, + "CDP endpoint did not return a valid /json/version payload", data, + "Ensure Chrome was started with --remote-debugging-port=" + port(url) + " and /json/version is reachable."); + } catch (Exception e) { + data.put("error", e.getMessage()); + return new Finding("config.cdp-url", Status.ERROR, + "CDP endpoint unreachable: " + e.getMessage(), data, + "Start Chrome with --remote-debugging-port or clear mateclaw.browser.cdp-url."); + } + } + + private Finding inspectConfiguredPath() { + String path = props.getChromePath(); + if (path == null || path.isBlank()) { + return new Finding("config.chrome-path", Status.INFO, "mateclaw.browser.chrome-path not set", Map.of(), null); + } + Path p = Path.of(path); + if (!Files.exists(p)) { + return new Finding("config.chrome-path", Status.ERROR, + "Configured chrome-path does not exist: " + path, Map.of("path", path), + "Install Chrome at that path, or clear mateclaw.browser.chrome-path."); + } + if (!Files.isExecutable(p)) { + return new Finding("config.chrome-path", Status.ERROR, + "Configured chrome-path is not executable: " + path, Map.of("path", path), + "chmod +x the binary, or point to the real chrome executable."); + } + return new Finding("config.chrome-path", Status.OK, "Configured chrome-path is valid", + Map.of("path", path), null); + } + + private Finding inspectEnvPath() { + String env = System.getenv("CHROME_PATH"); + if (env == null || env.isBlank()) { + return new Finding("env.CHROME_PATH", Status.INFO, "CHROME_PATH not set", Map.of(), null); + } + Path p = Path.of(env); + if (!Files.exists(p)) { + return new Finding("env.CHROME_PATH", Status.WARN, + "CHROME_PATH points to a missing file: " + env, Map.of("path", env), + "Fix CHROME_PATH or unset it to let auto-detection run."); + } + return new Finding("env.CHROME_PATH", Status.OK, "CHROME_PATH resolves to a real file", + Map.of("path", env), null); + } + + private Finding inspectSystemBrowsers() { + List> found = new ArrayList<>(); + for (Path candidate : BrowserLauncher.systemBrowserCandidates()) { + if (Files.exists(candidate)) { + Map entry = new LinkedHashMap<>(); + entry.put("path", candidate.toString()); + entry.put("executable", Files.isExecutable(candidate)); + found.add(entry); + } + } + if (found.isEmpty()) { + return new Finding("system.browsers", Status.WARN, + "No system Chrome / Edge / Brave found on well-known paths", + Map.of("scanned", BrowserLauncher.systemBrowserCandidates().stream().map(Path::toString).toList()), + installBrowserAdvice()); + } + return new Finding("system.browsers", Status.OK, + "Found " + found.size() + " system browser(s)", Map.of("found", found), null); + } + + private Finding inspectPlaywrightCache() { + Path cacheDir = playwrightCacheDir(); + Map data = new LinkedHashMap<>(); + data.put("cacheDir", cacheDir.toString()); + if (!Files.isDirectory(cacheDir)) { + return new Finding("playwright.cache", Status.WARN, + "Playwright browser cache not found (bundled chromium unavailable)", data, + "Run `mvn exec:java -e -Dexec.mainClass=\"com.microsoft.playwright.CLI\" -Dexec.args=\"install chromium\"` " + + "or rely on system Chrome (recommended)."); + } + try (var stream = Files.list(cacheDir)) { + List entries = stream.map(p -> p.getFileName().toString()).filter(n -> n.contains("chromium")).toList(); + data.put("chromiumBuilds", entries); + if (entries.isEmpty()) { + return new Finding("playwright.cache", Status.WARN, + "Playwright cache has no chromium build", data, + "Run playwright install chromium or use system Chrome."); + } + return new Finding("playwright.cache", Status.OK, + "Playwright bundled chromium available (" + entries.size() + " build(s))", data, null); + } catch (IOException e) { + data.put("error", e.getMessage()); + return new Finding("playwright.cache", Status.WARN, + "Failed to read Playwright cache: " + e.getMessage(), data, null); + } + } + + private Finding inspectLinuxLibs() { + // Pick the first available system browser to ldd-check. + Path binary = BrowserLauncher.systemBrowserCandidates().stream() + .filter(Files::exists).findFirst().orElse(null); + if (binary == null) { + return new Finding("linux.libs", Status.INFO, "No system browser to ldd-check", Map.of(), null); + } + try { + Process p = new ProcessBuilder("ldd", binary.toString()) + .redirectErrorStream(true).start(); + StringBuilder out = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + out.append(line).append('\n'); + } + } + p.waitFor(5, TimeUnit.SECONDS); + String dump = out.toString(); + List missing = new ArrayList<>(); + for (String line : dump.split("\n")) { + if (line.contains("not found")) { + missing.add(line.trim()); + } + } + if (!missing.isEmpty()) { + Map data = new LinkedHashMap<>(); + data.put("binary", binary.toString()); + data.put("missing", missing); + return new Finding("linux.libs", Status.ERROR, + "Chromium shared libraries missing — browser will fail to start", data, + "apt-get install -y " + String.join(" ", REQUIRED_LINUX_LIBS.stream().map(l -> l + "-dev").toList()) + + " (or your distro's equivalent)"); + } + return new Finding("linux.libs", Status.OK, "All required shared libraries resolved", + Map.of("binary", binary.toString()), null); + } catch (Exception e) { + return new Finding("linux.libs", Status.INFO, + "ldd probe failed: " + e.getMessage(), Map.of(), null); + } + } + + // ==================== Helpers ==================== + + private static Path playwrightCacheDir() { + String override = System.getenv("PLAYWRIGHT_BROWSERS_PATH"); + if (override != null && !override.isBlank() && !"0".equals(override)) { + return Path.of(override); + } + String home = System.getProperty("user.home"); + if (IS_WINDOWS) { + String local = System.getenv("LOCALAPPDATA"); + if (local != null && !local.isBlank()) { + return Path.of(local, "ms-playwright"); + } + return Path.of(home, "AppData", "Local", "ms-playwright"); + } + if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac")) { + return Path.of(home, "Library", "Caches", "ms-playwright"); + } + return Path.of(home, ".cache", "ms-playwright"); + } + + private static String stripTrailing(String url) { + String s = url.trim(); + while (s.endsWith("/")) s = s.substring(0, s.length() - 1); + return s; + } + + private static String port(String url) { + int colon = url.lastIndexOf(':'); + if (colon < 0) return "?"; + String tail = url.substring(colon + 1); + int slash = tail.indexOf('/'); + return slash > 0 ? tail.substring(0, slash) : tail; + } + + private static String installBrowserAdvice() { + if (IS_WINDOWS) { + return "Install Chrome (https://www.google.com/chrome/) or Edge, or set mateclaw.browser.chrome-path."; + } + if (IS_LINUX) { + return "Install Chrome: `wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && apt install google-chrome-stable` or `apt install chromium`."; + } + return "Install Chrome or Edge, or set mateclaw.browser.chrome-path to point at a browser binary."; + } + + private static String deriveOverall(List findings) { + boolean hasError = findings.stream().anyMatch(f -> f.status == Status.ERROR); + boolean hasWarn = findings.stream().anyMatch(f -> f.status == Status.WARN); + boolean canLaunch = findings.stream().anyMatch( + f -> f.status == Status.OK && (f.id.equals("system.browsers") + || f.id.equals("config.cdp-url") || f.id.equals("config.chrome-path") + || f.id.equals("playwright.cache"))); + if (canLaunch && !hasError) return "healthy"; + if (canLaunch) return "warning"; + if (hasError || !canLaunch) return "error"; + return hasWarn ? "warning" : "healthy"; + } + + private static List deriveAdvice(List findings) { + List out = new ArrayList<>(); + for (Finding f : findings) { + if (f.advice != null && (f.status == Status.ERROR || f.status == Status.WARN)) { + out.add("[" + f.id + "] " + f.advice); + } + } + if (out.isEmpty()) { + out.add("Browser stack looks healthy."); + } + return out; + } + + // ==================== Records ==================== + + public enum Status { OK, WARN, ERROR, INFO } + + public record Finding(String id, Status status, String message, Map data, String advice) {} + + public record Report(String overall, List findings, List advice) {} + + /** Summarise the report as a short string suitable for logs / tool responses. */ + public static String summarise(Report r) { + StringBuilder sb = new StringBuilder(); + sb.append("Browser diagnostics: ").append(r.overall).append('\n'); + for (Finding f : r.findings) { + sb.append(" [").append(f.status).append("] ").append(f.id).append(" — ").append(f.message).append('\n'); + } + if (!r.advice.isEmpty()) { + sb.append("Advice:\n"); + for (String a : r.advice) sb.append(" - ").append(a).append('\n'); + } + return sb.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java new file mode 100644 index 00000000..a1c37837 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java @@ -0,0 +1,29 @@ +package vip.mate.tool.browser; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; + +/** + * Browser self-check endpoint. Call this when the browser tool fails — the response + * tells you exactly what's broken (missing binary, missing libs, broken CDP, etc.) + * and how to fix it, without needing to inspect server logs. + */ +@Tag(name = "System Health") +@RestController +@RequestMapping("/api/v1/system") +@RequiredArgsConstructor +public class BrowserHealthController { + + private final BrowserDiagnosticsService diagnostics; + + @Operation(summary = "Browser launch diagnostics") + @GetMapping("/browser-health") + public R getBrowserHealth() { + return R.ok(diagnostics.run()); + } +} 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 new file mode 100644 index 00000000..de14bb4f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -0,0 +1,516 @@ +package vip.mate.tool.browser; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.PlaywrightException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +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.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Multi-strategy browser launcher. Tries, in order: an existing CDP endpoint, a + * user-configured executable, a Playwright channel, auto-detected system Chrome / + * Edge / Brave, Playwright's bundled Chromium, and finally self-launching a system + * browser with {@code --remote-debugging-port=0} and attaching over CDP (the same + * pattern openfang uses). + * + *

Each attempt is recorded with its outcome so diagnostics can surface exactly + * what failed and how the user can fix it. + */ +@Slf4j +@Component +public class BrowserLauncher { + + private static final boolean IS_WINDOWS = System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT).contains("win"); + private static final boolean IS_MAC = System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT).contains("mac"); + + private final BrowserProperties props; + + public BrowserLauncher(BrowserProperties props) { + this.props = props; + } + + public BrowserProperties properties() { + return props; + } + + /** + * Launch a browser session. Tries every available strategy until one succeeds. + * The returned result always contains an {@code attempts} trace, even on success, + * so callers can surface "what we ended up using". + */ + public Result launch(Playwright pw, boolean headed) { + List trace = new ArrayList<>(); + + // 1. Explicit CDP endpoint — user manages the Chrome process + String cdpUrl = props.getCdpUrl(); + if (cdpUrl != null && !cdpUrl.isBlank()) { + Result r = tryCdp(pw, cdpUrl, trace, Strategy.CONFIG_CDP); + if (r != null) return r; + } + + // 2. Explicit executable path (property or env var) + String explicitPath = firstNonBlank(props.getChromePath(), System.getenv("CHROME_PATH")); + if (explicitPath != null) { + Result r = tryExecutablePath(pw, explicitPath, headed, trace, Strategy.CONFIG_PATH); + if (r != null) return r; + } + + // 3. Explicit channel (chrome / msedge / etc.) + String channel = props.getChannel(); + if (channel != null && !channel.isBlank()) { + Result r = tryChannel(pw, channel, headed, trace, Strategy.CONFIG_CHANNEL); + if (r != null) return r; + } + + // 4. Prefer system browser via channel auto-detection (chrome, then msedge) + if (props.isPreferSystem()) { + for (String autoChannel : new String[]{"chrome", "msedge"}) { + Result r = tryChannel(pw, autoChannel, headed, trace, Strategy.AUTO_CHANNEL); + if (r != null) return r; + } + + // 5. Scan well-known install paths and launch via executablePath + for (Path candidate : systemBrowserCandidates()) { + Result r = tryExecutablePath(pw, candidate.toString(), headed, trace, Strategy.AUTO_PATH); + if (r != null) return r; + } + } + + // 6. Playwright's bundled Chromium (requires `playwright install`) + Result bundled = tryBundled(pw, headed, trace); + if (bundled != null) return bundled; + + // 7. Last resort: spawn system chrome with --remote-debugging-port=0 and attach via CDP. + // This bypasses Playwright's Node launcher entirely — useful when Playwright install is broken. + if (props.isAllowExternalCdpFallback()) { + Result external = tryExternalCdpLaunch(pw, headed, trace); + if (external != null) return external; + } + + // All strategies failed + log.warn("[BrowserLauncher] All launch strategies failed. Trace:\n{}", formatTrace(trace)); + return Result.failure(trace, summariseFailure(trace)); + } + + // ==================== Strategy implementations ==================== + + private Result tryCdp(Playwright pw, String url, List trace, Strategy strategy) { + String normalized = normalizeCdpUrl(url); + long t0 = System.currentTimeMillis(); + try { + Browser browser = pw.chromium().connectOverCDP(normalized); + BrowserContext context; + Page page; + List contexts = browser.contexts(); + if (!contexts.isEmpty()) { + context = contexts.get(0); + List pages = context.pages(); + page = pages.isEmpty() ? context.newPage() : pages.get(0); + } else { + context = browser.newContext(); + page = context.newPage(); + } + long elapsed = System.currentTimeMillis() - t0; + trace.add(Attempt.ok(strategy, "connectOverCDP(" + normalized + ")", elapsed)); + return Result.success(browser, context, page, true, normalized, strategy, trace); + } catch (Exception e) { + trace.add(Attempt.fail(strategy, "connectOverCDP(" + normalized + ")", + System.currentTimeMillis() - t0, e.getMessage())); + return null; + } + } + + private Result tryExecutablePath(Playwright pw, String path, boolean headed, + List trace, Strategy strategy) { + if (!Files.exists(Path.of(path))) { + trace.add(Attempt.fail(strategy, "executablePath=" + path, 0, "file not found")); + return null; + } + long t0 = System.currentTimeMillis(); + try { + BrowserType.LaunchOptions opts = baseLaunchOptions(headed) + .setExecutablePath(Path.of(path)); + Browser browser = pw.chromium().launch(opts); + Result r = wrapLocalBrowser(browser, strategy, "executablePath=" + path, + System.currentTimeMillis() - t0, trace); + return r; + } catch (PlaywrightException e) { + trace.add(Attempt.fail(strategy, "executablePath=" + path, + System.currentTimeMillis() - t0, e.getMessage())); + return null; + } + } + + private Result tryChannel(Playwright pw, String channel, boolean headed, + List trace, Strategy strategy) { + long t0 = System.currentTimeMillis(); + try { + BrowserType.LaunchOptions opts = baseLaunchOptions(headed).setChannel(channel); + Browser browser = pw.chromium().launch(opts); + return wrapLocalBrowser(browser, strategy, "channel=" + channel, + System.currentTimeMillis() - t0, trace); + } catch (PlaywrightException e) { + trace.add(Attempt.fail(strategy, "channel=" + channel, + System.currentTimeMillis() - t0, e.getMessage())); + return null; + } + } + + private Result tryBundled(Playwright pw, boolean headed, List trace) { + long t0 = System.currentTimeMillis(); + try { + Browser browser = pw.chromium().launch(baseLaunchOptions(headed)); + return wrapLocalBrowser(browser, Strategy.BUNDLED, "playwright-bundled-chromium", + System.currentTimeMillis() - t0, trace); + } catch (PlaywrightException e) { + trace.add(Attempt.fail(Strategy.BUNDLED, "playwright-bundled-chromium", + System.currentTimeMillis() - t0, e.getMessage())); + return null; + } + } + + /** + * Spawn a system browser ourselves with {@code --remote-debugging-port=0}, parse stderr + * to recover the actual DevTools WebSocket URL, then attach via Playwright's CDP client. + * This is the openfang pattern — it sidesteps Playwright's Node-based launcher entirely, + * so it still works when `playwright install` has not been run or Node is flaky. + */ + private Result tryExternalCdpLaunch(Playwright pw, boolean headed, List trace) { + long t0 = System.currentTimeMillis(); + Path browserBin = null; + for (Path candidate : systemBrowserCandidates()) { + if (Files.exists(candidate)) { + browserBin = candidate; + break; + } + } + if (browserBin == null) { + trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "external-chrome-spawn", + System.currentTimeMillis() - t0, "no system browser executable found")); + return null; + } + + List command = new ArrayList<>(); + command.add(browserBin.toString()); + command.add("--remote-debugging-port=0"); + command.add("--no-first-run"); + command.add("--no-default-browser-check"); + command.add("--disable-extensions"); + command.add("--disable-background-networking"); + if (props.isHeadless() && !headed) { + command.add("--headless=new"); + } + if (isRunningAsRoot() || IS_WINDOWS) { + command.add("--no-sandbox"); + } + if (isRunningInContainer()) { + command.add("--disable-dev-shm-usage"); + } + command.add("about:blank"); + + ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(false); + // SECURITY: don't leak the parent process's secrets (API keys, etc.) into chrome. + // Keep only the vars Chrome actually needs to run. openfang does the same via env_clear. + java.util.Map env = pb.environment(); + java.util.Map keep = new java.util.LinkedHashMap<>(); + for (String key : new String[]{"PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", + "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "DISPLAY", "WAYLAND_DISPLAY"}) { + String v = env.get(key); + if (v != null) keep.put(key, v); + } + env.clear(); + env.putAll(keep); + + Process proc; + try { + proc = pb.start(); + } catch (Exception e) { + trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin + " --remote-debugging-port", + System.currentTimeMillis() - t0, "spawn failed: " + e.getMessage())); + return null; + } + + String wsUrl; + try { + wsUrl = readDevToolsUrl(proc, props.getCdpTimeoutSeconds()); + } catch (Exception e) { + proc.destroyForcibly(); + trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin.toString(), + System.currentTimeMillis() - t0, e.getMessage())); + return null; + } + + // Derive http base — Playwright's connectOverCDP accepts ws:// directly, but http:// is safer. + String cdpBase = wsUrl.replaceFirst("^ws://", "http://").replaceFirst("/devtools/.*", ""); + try { + Browser browser = pw.chromium().connectOverCDP(cdpBase); + BrowserContext context = browser.contexts().isEmpty() + ? browser.newContext() + : browser.contexts().get(0); + Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0); + long elapsed = System.currentTimeMillis() - t0; + trace.add(Attempt.ok(Strategy.EXTERNAL_CDP, browserBin + " + connectOverCDP(" + cdpBase + ")", elapsed)); + return Result.success(browser, context, page, true, cdpBase, Strategy.EXTERNAL_CDP, trace); + } catch (Exception e) { + proc.destroyForcibly(); + trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "connectOverCDP(" + cdpBase + ")", + System.currentTimeMillis() - t0, e.getMessage())); + return null; + } + } + + // ==================== Helpers ==================== + + private BrowserType.LaunchOptions baseLaunchOptions(boolean headed) { + BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions().setHeadless(!headed); + List args = chromiumLaunchArgs(); + if (!args.isEmpty()) { + opts.setArgs(args); + } + return opts; + } + + private Result wrapLocalBrowser(Browser browser, Strategy strategy, String desc, + long elapsedMs, List trace) { + BrowserContext context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(props.getViewportWidth(), props.getViewportHeight()) + .setLocale("zh-CN")); + Page page = context.newPage(); + trace.add(Attempt.ok(strategy, desc, elapsedMs)); + return Result.success(browser, context, page, false, null, strategy, trace); + } + + public static List chromiumLaunchArgs() { + List args = new ArrayList<>(); + boolean inContainer = isRunningInContainer(); + boolean asRoot = isRunningAsRoot(); + if (IS_WINDOWS || inContainer || asRoot) { + args.add("--no-sandbox"); + } + if (inContainer) { + args.add("--disable-dev-shm-usage"); + } + if (IS_WINDOWS) { + args.add("--disable-gpu"); + } + return args; + } + + /** Platform-specific candidate paths — same list openfang uses. */ + public static List systemBrowserCandidates() { + List paths = new ArrayList<>(); + if (IS_WINDOWS) { + String pf = System.getenv("ProgramFiles"); + String pf86 = System.getenv("ProgramFiles(x86)"); + String local = System.getenv("LOCALAPPDATA"); + for (String root : new String[]{pf, pf86}) { + if (root == null || root.isBlank()) continue; + paths.add(Path.of(root, "Google", "Chrome", "Application", "chrome.exe")); + paths.add(Path.of(root, "Microsoft", "Edge", "Application", "msedge.exe")); + paths.add(Path.of(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")); + } + if (local != null && !local.isBlank()) { + paths.add(Path.of(local, "Google", "Chrome", "Application", "chrome.exe")); + paths.add(Path.of(local, "Microsoft", "Edge", "Application", "msedge.exe")); + } + } else if (IS_MAC) { + paths.add(Path.of("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")); + paths.add(Path.of("/Applications/Chromium.app/Contents/MacOS/Chromium")); + paths.add(Path.of("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge")); + paths.add(Path.of("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")); + } else { + // Linux + paths.add(Path.of("/usr/bin/google-chrome")); + paths.add(Path.of("/usr/bin/google-chrome-stable")); + paths.add(Path.of("/usr/bin/chromium")); + paths.add(Path.of("/usr/bin/chromium-browser")); + paths.add(Path.of("/snap/bin/chromium")); + paths.add(Path.of("/usr/bin/microsoft-edge")); + paths.add(Path.of("/usr/bin/microsoft-edge-stable")); + paths.add(Path.of("/usr/bin/brave-browser")); + } + return paths; + } + + public static boolean isRunningInContainer() { + try { + if (Files.exists(Path.of("/.dockerenv"))) return true; + Path cgroup = Path.of("/proc/1/cgroup"); + if (Files.exists(cgroup)) { + String content = Files.readString(cgroup); + return content.contains("docker") || content.contains("kubepods") || content.contains("containerd"); + } + } catch (Exception ignored) {} + return false; + } + + public static boolean isRunningAsRoot() { + if (IS_WINDOWS) return false; + try { + Path self = Path.of("/proc/self/status"); + if (Files.exists(self)) { + for (String line : Files.readAllLines(self)) { + if (line.startsWith("Uid:")) { + String[] parts = line.split("\\s+"); + return parts.length > 1 && "0".equals(parts[1]); + } + } + } + String userName = System.getProperty("user.name", ""); + return "root".equals(userName); + } catch (Exception ignored) { + return false; + } + } + + private static String normalizeCdpUrl(String url) { + String s = url.trim(); + if (!s.startsWith("http")) { + s = "http://" + s; + } + s = s.replace("://localhost:", "://127.0.0.1:"); + s = s.replace("://localhost/", "://127.0.0.1/"); + if (s.endsWith("://localhost")) { + s = s.replace("://localhost", "://127.0.0.1"); + } + return s; + } + + private static String firstNonBlank(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; + } + + private static String readDevToolsUrl(Process proc, int timeoutSeconds) throws Exception { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(proc.getErrorStream(), StandardCharsets.UTF_8))) { + StringBuilder accumulated = new StringBuilder(); + String line; + while (System.currentTimeMillis() < deadline) { + if (!reader.ready()) { + if (!proc.isAlive()) { + throw new IllegalStateException( + "Chromium exited before printing DevTools URL. stderr=" + accumulated); + } + Thread.sleep(50); + continue; + } + line = reader.readLine(); + if (line == null) break; + accumulated.append(line).append('\n'); + int idx = line.indexOf("DevTools listening on "); + if (idx >= 0) { + return line.substring(idx + "DevTools listening on ".length()).trim(); + } + } + } + throw new IllegalStateException( + "Timed out (" + timeoutSeconds + "s) waiting for 'DevTools listening on' from chromium stderr"); + } + + public static String formatTrace(List trace) { + StringBuilder sb = new StringBuilder(); + for (Attempt a : trace) { + sb.append(String.format(" [%s] %s %-7s %dms %s%n", + a.strategy(), a.ok() ? "\u2713" : "\u2717", a.strategy().name(), + a.elapsedMs(), a.ok() ? a.detail() : (a.detail() + " \u2014 " + a.error()))); + } + return sb.toString(); + } + + private static String summariseFailure(List trace) { + StringBuilder sb = new StringBuilder("Browser launch failed. Tried: "); + for (int i = 0; i < trace.size(); i++) { + if (i > 0) sb.append("; "); + Attempt a = trace.get(i); + sb.append(a.strategy().name()).append(" ").append(a.ok() ? "ok" : "(" + brief(a.error()) + ")"); + } + return sb.toString(); + } + + private static String brief(String err) { + if (err == null) return "unknown"; + String first = err.lines().findFirst().orElse(err); + return first.length() > 120 ? first.substring(0, 120) + "..." : first; + } + + // ==================== Types ==================== + + public enum Strategy { + /** User-configured CDP endpoint (mateclaw.browser.cdp-url). */ + CONFIG_CDP, + /** User-configured executable path (mateclaw.browser.chrome-path or CHROME_PATH env). */ + CONFIG_PATH, + /** User-configured channel (mateclaw.browser.channel). */ + CONFIG_CHANNEL, + /** Auto-detected Playwright channel (chrome, msedge). */ + AUTO_CHANNEL, + /** Auto-detected system browser on well-known install paths. */ + AUTO_PATH, + /** Playwright's bundled Chromium (requires `playwright install`). */ + BUNDLED, + /** Spawn system chrome with --remote-debugging-port=0 and attach via CDP. */ + EXTERNAL_CDP + } + + public record Attempt(Strategy strategy, String detail, long elapsedMs, boolean ok, String error) { + static Attempt ok(Strategy s, String d, long ms) { return new Attempt(s, d, ms, true, null); } + static Attempt fail(Strategy s, String d, long ms, String e) { return new Attempt(s, d, ms, false, e); } + } + + @Getter + public static final class Result { + private final Browser browser; + private final BrowserContext context; + private final Page page; + private final boolean connectedViaCdp; + private final String cdpUrl; + private final Strategy strategy; + private final List attempts; + private final boolean success; + private final String failureSummary; + + private Result(Browser browser, BrowserContext context, Page page, + boolean connectedViaCdp, String cdpUrl, Strategy strategy, + List attempts, boolean success, String failureSummary) { + this.browser = browser; + this.context = context; + this.page = page; + this.connectedViaCdp = connectedViaCdp; + this.cdpUrl = cdpUrl; + this.strategy = strategy; + this.attempts = attempts; + this.success = success; + this.failureSummary = failureSummary; + } + + static Result success(Browser browser, BrowserContext context, Page page, + boolean cdp, String cdpUrl, Strategy strategy, List attempts) { + return new Result(browser, context, page, cdp, cdpUrl, strategy, attempts, true, null); + } + + static Result failure(List attempts, String summary) { + return new Result(null, null, null, false, null, null, attempts, false, summary); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java new file mode 100644 index 00000000..c51c16bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java @@ -0,0 +1,60 @@ +package vip.mate.tool.browser; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Browser launch configuration. Supports multiple fallback strategies so we can + * launch a browser on machines where Playwright's bundled Chromium download is + * unavailable (offline CI, corporate firewalls, minimal containers). + * + *

Precedence when launching (highest first): + *

    + *
  1. {@link #cdpUrl} — connect to an already-running Chrome via DevTools Protocol
  2. + *
  3. {@link #chromePath} or {@code CHROME_PATH} env — explicit executable
  4. + *
  5. {@link #channel} — Playwright channel ("chrome", "msedge", ...)
  6. + *
  7. Auto-detect system Chrome/Edge/Brave on well-known paths
  8. + *
  9. Playwright's bundled Chromium (requires {@code playwright install})
  10. + *
  11. External-process CDP launch (run system chrome with --remote-debugging-port and attach)
  12. + *
+ */ +@Data +@Component +@ConfigurationProperties(prefix = "mateclaw.browser") +public class BrowserProperties { + + /** Pre-started Chrome CDP endpoint (e.g. http://127.0.0.1:9222). Highest priority when set. */ + private String cdpUrl = ""; + + /** Absolute path to chrome.exe / google-chrome / msedge. Overrides channel/auto-detect. */ + private String chromePath = ""; + + /** Playwright channel: chrome | msedge | chrome-beta | chrome-dev | msedge-beta | msedge-dev. */ + private String channel = ""; + + /** Try system-installed browsers (channel + path scan) before Playwright's bundled Chromium. */ + private boolean preferSystem = true; + + /** Default headless for auto-started sessions. {@code action=start headed=true} overrides. */ + private boolean headless = true; + + /** Enable the last-resort strategy: spawn chrome --remote-debugging-port=0 and connect via CDP. */ + private boolean allowExternalCdpFallback = true; + + /** Connect timeout (seconds) for CDP / external-CDP attach. */ + private int cdpTimeoutSeconds = 20; + + /** Maximum concurrent browser sessions across all agents. Prevents runaway memory usage. */ + private int maxSessions = 5; + + /** Block navigations to loopback, private, link-local and cloud-metadata hosts. */ + private boolean ssrfCheckEnabled = true; + + /** Viewport width (px) for launched browsers. */ + private int viewportWidth = 1280; + + /** Viewport height (px) for launched browsers. */ + private int viewportHeight = 800; +} + diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java new file mode 100644 index 00000000..bed67935 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java @@ -0,0 +1,82 @@ +package vip.mate.tool.browser; + +import java.net.InetAddress; +import java.net.URI; +import java.util.Set; + +/** + * SSRF guard — rejects URLs that resolve to loopback, link-local, private, or + * known cloud-metadata endpoints. Mirrors openfang's {@code check_ssrf} behaviour. + * + *

Call this before passing any user-controlled URL to the browser or to an + * outbound HTTP client. + */ +public final class UrlSafetyChecker { + + /** Hostnames that must never be reachable via user-supplied URLs. */ + private static final Set BLOCKED_HOSTNAMES = Set.of( + "localhost", + "ip6-localhost", + "metadata.google.internal", + "metadata.aws.internal", + "instance-data", + "169.254.169.254", // AWS / Azure / GCP IMDS + "100.100.100.200", // Alibaba Cloud IMDS + "192.0.0.192", // Azure IMDS alternative + "0.0.0.0", + "::1" + ); + + private UrlSafetyChecker() {} + + /** + * Throw {@link SecurityException} if the URL is unsafe. Accepts http:// and https:// only. + */ + public static void check(String url) { + if (url == null || url.isBlank()) { + throw new SecurityException("URL is required"); + } + URI uri; + try { + uri = URI.create(url.trim()); + } catch (IllegalArgumentException e) { + throw new SecurityException("Malformed URL: " + url); + } + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + throw new SecurityException("Only http:// and https:// URLs are allowed (got: " + scheme + ")"); + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new SecurityException("URL must have a host"); + } + String hostname = host.startsWith("[") && host.endsWith("]") + ? host.substring(1, host.length() - 1) + : host; + if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) { + throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname"); + } + try { + for (InetAddress addr : InetAddress.getAllByName(hostname)) { + if (addr.isLoopbackAddress() || addr.isAnyLocalAddress() + || addr.isLinkLocalAddress() || addr.isSiteLocalAddress() + || addr.isMulticastAddress() || isMetadataIp(addr)) { + throw new SecurityException("SSRF blocked: " + hostname + + " resolves to restricted address " + addr.getHostAddress()); + } + } + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + // DNS resolution failure — let the caller deal with it (browser will show its own error). + } + } + + private static boolean isMetadataIp(InetAddress addr) { + String ip = addr.getHostAddress(); + return "169.254.169.254".equals(ip) + || "100.100.100.200".equals(ip) + || "192.0.0.192".equals(ip) + || "fd00:ec2::254".equalsIgnoreCase(ip); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java index d2880408..87c27d78 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java @@ -4,20 +4,27 @@ import cn.hutool.http.HttpUtil; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; -import com.microsoft.playwright.*; +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.PlaywrightException; import com.microsoft.playwright.options.LoadState; import jakarta.annotation.PreDestroy; 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.tool.browser.BrowserDiagnosticsService; +import vip.mate.tool.browser.BrowserLauncher; +import vip.mate.tool.browser.UrlSafetyChecker; import java.net.Socket; import java.nio.file.Paths; -import java.util.ArrayList; import java.util.Base64; import java.util.List; -import java.util.Locale; import java.util.concurrent.*; /** @@ -34,14 +41,17 @@ public class BrowserUseTool { private static final int CDP_SCAN_PORT_MIN = 9000; private static final int CDP_SCAN_PORT_MAX = 10000; - private static final boolean IS_WINDOWS = System.getProperty("os.name", "") - .toLowerCase(Locale.ROOT).contains("win"); - - /** SSE 推送器(用于将浏览器操作实时推送到前端) */ + /** SSE broadcaster for pushing browser actions to the frontend in real time. */ private final vip.mate.channel.web.ChatStreamTracker streamTracker; + private final BrowserLauncher launcher; + private final BrowserDiagnosticsService diagnostics; - public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker) { + public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker, + BrowserLauncher launcher, + BrowserDiagnosticsService diagnostics) { this.streamTracker = streamTracker; + this.launcher = launcher; + this.diagnostics = diagnostics; } /** @@ -53,6 +63,15 @@ public class BrowserUseTool { private final Object playwrightLock = new Object(); private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + /** + * RFC-063r §2.5 transition: ToolContext for the current invocation, set + * at the @Tool entry point and read by {@link #broadcastBrowserEvent}. + * Tool calls are serialized per ToolExecutionExecutor instance so this + * volatile field is safe; the field is read-only inside the action + * handlers. + */ + private volatile ToolContext currentToolContext; private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "browser-idle-watchdog"); t.setDaemon(true); @@ -60,12 +79,15 @@ public class BrowserUseTool { }); @Tool(description = """ - Control a browser (Playwright). Default is headless. Use headed=true with action=start for a visible window. + Control a browser (Playwright with multi-strategy launch: system Chrome/Edge channel, explicit path, bundled, or external CDP). + Default is headless. Use headed=true with action=start for a visible window. Typical flow: start → open(url) → snapshot → click/type → stop. - For CDP: connect_cdp(url="http://localhost:9222") to attach to an existing Chrome, or list_cdp_targets to scan. + If start fails, run action=diagnose for a full report of what's missing and how to fix it. + When web_search is unavailable (no Serper/Tavily API key), use this tool to fetch content directly: + e.g. action=open url=https://news.google.com/search?q=... then action=snapshot to read the page. Supported actions: - - start: Launch a new browser. Optional headed=true for visible window. + - start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true. - stop: Close browser. If connected via CDP, only disconnects (Chrome keeps running). - open: Navigate to a URL. Requires url parameter. Auto-starts browser if not running. - snapshot: Get page text content, interactive elements, and title. @@ -76,17 +98,25 @@ public class BrowserUseTool { - connect_cdp: Connect to an existing Chrome via CDP. Requires url (e.g. "http://localhost:9222"). - list_cdp_targets: Scan local ports (9000-10000) for CDP endpoints. Optional cdpPort for single port. - navigate_back: Go back in browser history. + - diagnose: Run a self-check — reports which launch strategies are available and what to install if none are. """) public String browser_use( - @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back") String action, + @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action, @ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url, @ToolParam(description = "CSS selector for target element (for click/type)", required = false) String selector, @ToolParam(description = "Text to type (for action=type)", required = false) String text, @ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code, @ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path, @ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed, - @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort + @ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort, + // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. + @Nullable ToolContext ctx ) { + // The conversationId resolution lives in broadcastBrowserEvent below; + // capture the ctx into a field so the helper can read it without + // passing it down every action handler. Race-free because tool calls + // are serialized per executor. + this.currentToolContext = ctx; if (action == null || action.isBlank()) { return error("action is required"); } @@ -107,7 +137,8 @@ public class BrowserUseTool { case "connect_cdp" -> doConnectCdp(sessionKey, url); case "list_cdp_targets" -> doListCdpTargets(cdpPort); case "navigate_back" -> doNavigateBack(sessionKey); - default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back"); + case "diagnose" -> doDiagnose(); + default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back, diagnose"); }; } catch (PlaywrightException e) { log.error("[BrowserUse] Playwright error: {}", e.getMessage()); @@ -150,7 +181,7 @@ public class BrowserUseTool { */ private void broadcastBrowserEvent(String action, boolean success, String url, String title, String screenshot, long durationMs) { - String conversationId = ToolExecutionContext.conversationId(); + String conversationId = ToolExecutionContext.conversationId(currentToolContext); if (conversationId == null || streamTracker == null) { return; } @@ -181,34 +212,40 @@ public class BrowserUseTool { doStop(sessionKey); } - log.info("[BrowserUse] Starting browser (headed={})", headed); + int max = launcher.properties().getMaxSessions(); + if (max > 0 && sessions.size() >= max) { + return error("Maximum browser sessions reached (" + max + + "). Stop an existing session first or raise mateclaw.browser.max-sessions."); + } + + log.info("[BrowserUse] Starting browser via launcher (headed={})", headed); long startTime = System.currentTimeMillis(); Playwright pw = getOrCreatePlaywright(); - BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions() - .setHeadless(!headed); + BrowserLauncher.Result r = launcher.launch(pw, headed); - // 平台特定启动参数 - List extraArgs = chromiumLaunchArgs(); - if (!extraArgs.isEmpty()) { - launchOptions.setArgs(extraArgs); - log.debug("[BrowserUse] Chromium extra args: {}", extraArgs); + if (!r.isSuccess()) { + log.warn("[BrowserUse] All launch strategies failed:\n{}", + BrowserLauncher.formatTrace(r.getAttempts())); + broadcastBrowserEvent("start", false, null, null, null, + System.currentTimeMillis() - startTime); + JSONObject result = new JSONObject(); + result.set("ok", false); + result.set("error", r.getFailureSummary()); + result.set("hint", "Run action=diagnose for a detailed report and fix suggestions."); + return JSONUtil.toJsonPrettyStr(result); } - Browser browser = pw.chromium().launch(launchOptions); - BrowserContext context = browser.newContext(new Browser.NewContextOptions() - .setViewportSize(1280, 800) - .setLocale("zh-CN")); - Page page = context.newPage(); - - BrowserSession session = new BrowserSession(browser, context, page, headed, false, null); + BrowserSession session = new BrowserSession(r.getBrowser(), r.getContext(), r.getPage(), + headed, r.isConnectedViaCdp(), r.getCdpUrl()); sessions.put(sessionKey, session); scheduleIdleCheck(sessionKey); long elapsed = System.currentTimeMillis() - startTime; - log.info("[BrowserUse] Browser started successfully (headed={}) in {}ms", headed, elapsed); + log.info("[BrowserUse] Browser started via {} in {}ms", r.getStrategy(), elapsed); broadcastBrowserEvent("start", true, null, null, null, elapsed); - return ok("Browser started (headed=" + headed + ") in " + elapsed + "ms. Use action=open with url to navigate."); + return ok("Browser started via " + r.getStrategy() + " (headed=" + headed + ") in " + + elapsed + "ms. Use action=open with url to navigate."); } private String doConnectCdp(String sessionKey, String cdpUrl) { @@ -216,60 +253,78 @@ public class BrowserUseTool { return error("url is required for action=connect_cdp (e.g. http://127.0.0.1:9222)"); } - // Stop existing session if any BrowserSession existing = sessions.get(sessionKey); if (existing != null) { doStop(sessionKey); } - // Normalize CDP URL and force IPv4 to avoid ECONNREFUSED ::1 on macOS - String normalizedCdpUrl = cdpUrl.trim(); - if (!normalizedCdpUrl.startsWith("http")) { - normalizedCdpUrl = "http://" + normalizedCdpUrl; - } - normalizedCdpUrl = normalizedCdpUrl.replace("://localhost:", "://127.0.0.1:"); - normalizedCdpUrl = normalizedCdpUrl.replace("://localhost/", "://127.0.0.1/"); - if (normalizedCdpUrl.endsWith("://localhost")) { - normalizedCdpUrl = normalizedCdpUrl.replace("://localhost", "://127.0.0.1"); - } - - log.info("[BrowserUse] Connecting to CDP at: {}", normalizedCdpUrl); + // Delegate to the launcher with the user-provided URL injected as a one-shot override. + // The launcher handles URL normalisation (localhost → 127.0.0.1, protocol prefix). long startTime = System.currentTimeMillis(); - Playwright pw = getOrCreatePlaywright(); - Browser browser = pw.chromium().connectOverCDP(normalizedCdpUrl); - - // Get existing contexts and pages - List contexts = browser.contexts(); - BrowserContext context; - Page page; - - if (!contexts.isEmpty()) { - context = contexts.get(0); - List pages = context.pages(); - page = pages.isEmpty() ? context.newPage() : pages.get(0); - } else { - context = browser.newContext(); - page = context.newPage(); + String priorCdp = launcher.properties().getCdpUrl(); + launcher.properties().setCdpUrl(cdpUrl); + BrowserLauncher.Result r; + try { + r = launcher.launch(pw, true); + } finally { + launcher.properties().setCdpUrl(priorCdp); } - BrowserSession session = new BrowserSession(browser, context, page, true, true, normalizedCdpUrl); + if (!r.isSuccess() || !r.isConnectedViaCdp()) { + log.warn("[BrowserUse] CDP connect failed. Trace:\n{}", + BrowserLauncher.formatTrace(r.getAttempts())); + return error("Failed to connect to CDP at " + cdpUrl + ": " + r.getFailureSummary()); + } + + BrowserSession session = new BrowserSession(r.getBrowser(), r.getContext(), r.getPage(), + true, true, r.getCdpUrl()); sessions.put(sessionKey, session); scheduleIdleCheck(sessionKey); - String title = page.title(); - String currentUrl = page.url(); long elapsed = System.currentTimeMillis() - startTime; - - log.info("[BrowserUse] Connected to CDP at {} in {}ms (page: {} - {})", normalizedCdpUrl, elapsed, currentUrl, title); + String title = r.getPage().title(); + String currentUrl = r.getPage().url(); + log.info("[BrowserUse] Connected to CDP at {} in {}ms (page: {} - {})", + r.getCdpUrl(), elapsed, currentUrl, title); JSONObject result = new JSONObject(); result.set("ok", true); - result.set("cdpUrl", normalizedCdpUrl); + result.set("cdpUrl", r.getCdpUrl()); result.set("currentUrl", currentUrl); result.set("currentTitle", title); - result.set("pagesCount", context.pages().size()); - result.set("message", "Connected to Chrome via CDP at " + normalizedCdpUrl + ". Current page: " + title); + result.set("pagesCount", r.getContext().pages().size()); + result.set("message", "Connected to Chrome via CDP at " + r.getCdpUrl() + ". Current page: " + title); + return JSONUtil.toJsonPrettyStr(result); + } + + private String doDiagnose() { + BrowserDiagnosticsService.Report report = diagnostics.run(); + JSONObject result = new JSONObject(); + result.set("ok", "healthy".equals(report.overall()) || "warning".equals(report.overall())); + result.set("overall", report.overall()); + + // Hutool's JSONUtil reflects on JavaBean-style getters and does not recognise + // Java record accessors (r.id() vs r.getId()), so toJsonStr(record) yields {}. + // Build the array by hand to keep the payload useful to the LLM. + JSONArray findingsArr = new JSONArray(); + for (BrowserDiagnosticsService.Finding f : report.findings()) { + JSONObject fo = new JSONObject(); + fo.set("id", f.id()); + fo.set("status", f.status() != null ? f.status().name() : null); + fo.set("message", f.message()); + if (f.data() != null && !f.data().isEmpty()) { + fo.set("data", f.data()); + } + if (f.advice() != null) { + fo.set("advice", f.advice()); + } + findingsArr.add(fo); + } + result.set("findings", findingsArr); + + result.set("advice", report.advice()); + result.set("summary", BrowserDiagnosticsService.summarise(report)); return JSONUtil.toJsonPrettyStr(result); } @@ -342,20 +397,32 @@ public class BrowserUseTool { return error("url is required for action=open"); } - BrowserSession session = getSession(sessionKey); - if (session == null) { - doStart(sessionKey, false); - session = getSession(sessionKey); - } - - session.touch(); - Page page = session.page; - String normalizedUrl = url.trim(); if (!normalizedUrl.matches("^https?://.*")) { normalizedUrl = "https://" + normalizedUrl; } + if (launcher.properties().isSsrfCheckEnabled()) { + try { + UrlSafetyChecker.check(normalizedUrl); + } catch (SecurityException se) { + log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage()); + return error(se.getMessage()); + } + } + + BrowserSession session = getSession(sessionKey); + if (session == null) { + String startResp = doStart(sessionKey, false); + session = getSession(sessionKey); + if (session == null) { + return startResp; + } + } + + session.touch(); + Page page = session.page; + page.navigate(normalizedUrl); page.waitForLoadState(LoadState.DOMCONTENTLOADED); @@ -580,49 +647,6 @@ public class BrowserUseTool { return JSONUtil.toJsonPrettyStr(result); } - // ==================== Platform Helpers ==================== - - /** - * 返回 Chromium 在当前平台下需要的额外启动参数。 - *

- * Windows: --no-sandbox(沙箱兼容性)+ --disable-gpu(GPU 硬件加速问题) - * 容器环境: --no-sandbox + --disable-dev-shm-usage(共享内存不足) - */ - private static List chromiumLaunchArgs() { - List args = new ArrayList<>(); - boolean inContainer = isRunningInContainer(); - - if (IS_WINDOWS || inContainer) { - args.add("--no-sandbox"); - } - if (inContainer) { - args.add("--disable-dev-shm-usage"); - } - if (IS_WINDOWS) { - args.add("--disable-gpu"); - } - return args; - } - - /** - * 检测是否运行在 Docker/容器环境中。 - */ - private static boolean isRunningInContainer() { - try { - // Docker 容器中通常存在 /.dockerenv 文件 - if (java.nio.file.Files.exists(java.nio.file.Path.of("/.dockerenv"))) { - return true; - } - // 或者 /proc/1/cgroup 包含 docker/kubepods - java.nio.file.Path cgroup = java.nio.file.Path.of("/proc/1/cgroup"); - if (java.nio.file.Files.exists(cgroup)) { - String content = java.nio.file.Files.readString(cgroup); - return content.contains("docker") || content.contains("kubepods") || content.contains("containerd"); - } - } catch (Exception ignored) {} - return false; - } - // ==================== CDP Helpers ==================== private boolean isPortOpen(int port) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java new file mode 100644 index 00000000..96a2abf2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -0,0 +1,78 @@ +package vip.mate.tool.builtin; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Resolves a user-supplied file path against the current conversation's chat-upload + * directory ({@code data/chat-uploads/{conversationId}/}). + *

+ * Chat attachments are stored as {@code {timestamp}_{safeFilename}} where + * {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with + * {@code _}. This means a file uploaded as {@code 人人有虾.docx} is stored on disk + * as e.g. {@code 1777391026594_____.docx}. The LLM only ever sees the original + * filename in the rendered "[附件] foo.docx" prefix, so when a tool gets called + * with the original name it won't match anything on disk via direct lookup. + *

+ * This helper rescues such calls by matching basenames inside the conversation's + * upload directory. Used by both {@link ReadFileTool} and {@link DocumentExtractTool}. + */ +@Slf4j +final class ChatUploadResolver { + + static final Path CHAT_UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + + private ChatUploadResolver() {} + + /** + * @return absolute path of the matched attachment, or {@code null} if no match + */ + static Path resolve(String rawPath) { + if (rawPath == null || rawPath.isBlank()) { + return null; + } + String conversationId = ToolExecutionContext.conversationId(); + if (conversationId == null || conversationId.isBlank()) { + return null; + } + Path uploadDir = CHAT_UPLOAD_ROOT.resolve(conversationId).toAbsolutePath().normalize(); + if (!Files.isDirectory(uploadDir)) { + return null; + } + + String basename; + try { + Path requested = Paths.get(rawPath).getFileName(); + basename = requested != null ? requested.toString() : null; + } catch (Exception e) { + return null; + } + if (basename == null || basename.isBlank()) { + return null; + } + + Path direct = uploadDir.resolve(basename); + if (Files.isRegularFile(direct)) { + return direct; + } + + // Stored as "{millis}_{safeFilename}" where safeFilename replaces non-ASCII + // characters with underscores; match by sanitized basename suffix. + String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_"); + String suffix = "_" + safeBasename; + try (var stream = Files.list(uploadDir)) { + return stream + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(suffix)) + .findFirst() + .orElse(null); + } catch (IOException e) { + log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", uploadDir, e.getMessage()); + return null; + } + } +} 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 ca6c1b7c..0837f59b 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 @@ -5,9 +5,12 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; 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.agent.context.ChatOrigin; import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.service.CronJobService; @@ -30,6 +33,7 @@ public class CronJobTool { private final CronJobService cronJobService; + @vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name") @Tool(description = "Create a scheduled task (cron job). The task will run automatically at the specified time " + "and send the trigger message to the current agent. Use 5-field cron expressions: minute hour day month weekday. " + "Examples: '0 9 * * *' = daily at 9am, '0 9 * * 1-5' = weekdays at 9am, '*/30 * * * *' = every 30 minutes.") @@ -37,12 +41,30 @@ public class CronJobTool { @ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name, @ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression, @ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage, - @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone) { + @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone, + // RFC-063r §2.4: ToolContext is *not* exposed to the LLM — + // JsonSchemaGenerator skips it (Spring AI 1.1 framework convention) + @Nullable ToolContext ctx) { try { - // Resolve current agent ID from conversation context - String conversationId = ToolExecutionContext.conversationId(); - Long agentId = resolveAgentId(conversationId); + // RFC-063r §2.5: the ChatOrigin must carry agentId — buildInitialState + // injects it from the agent that owns the StateGraph. If it's missing + // here, something upstream broke (no holder set, KeyStrategyFactory + // dropped CHAT_ORIGIN, etc.) — fail loudly rather than silently + // binding to agent #1, which could be disabled / non-existent / + // user-renamed and would surface as "scheduled but never runs". + ChatOrigin origin = ChatOrigin.from(ctx); + String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty() + ? origin.conversationId() + : ToolExecutionContext.conversationId(); + Long agentId = origin.agentId(); + if (agentId == null) { + log.warn("[CronJobTool] create_cron_job invoked without an agentId in ChatOrigin " + + "(conv={}); refusing to silently bind to a default agent.", conversationId); + return errorResult("Cannot create cron job: agent context unavailable. " + + "This is an internal wiring bug — the originating agent id was not threaded " + + "through ToolContext. Re-issue the request; if it persists, see RFC-063r §2.5."); + } CronJobDTO dto = new CronJobDTO(); dto.setName(name); @@ -53,7 +75,17 @@ public class CronJobTool { dto.setTaskType("text"); dto.setEnabled(true); - CronJobDTO created = cronJobService.create(dto); + // RFC-063r §2.4 / PR-2: when the originating context carries a + // channelId, the cron job inherits the binding so its results can + // be delivered back to the same channel. Fields are wired via + // reflection until PR-2 adds them to CronJobDTO + CronJobEntity. + propagateChannelBinding(dto, origin); + + // RFC-083: stamp workspace from the originating ChatOrigin so the + // cron job is created in the agent's current workspace; fall back + // to the default workspace when origin is unscoped (legacy paths). + Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L; + CronJobDTO created = cronJobService.create(dto, workspaceId); JSONObject result = new JSONObject(); result.set("success", true); @@ -73,9 +105,12 @@ public class CronJobTool { @Tool(description = "List all scheduled tasks (cron jobs) for the current agent. " + "Returns task name, cron expression, next run time, enabled status, and last run time.") - public String list_cron_jobs() { + public String list_cron_jobs(@Nullable ToolContext ctx) { try { - List jobs = cronJobService.list(); + // RFC-083: scope to the originating workspace so an agent only + // sees the cron jobs of the workspace it's running in. + Long workspaceId = workspaceFromContext(ctx); + List jobs = cronJobService.list(workspaceId); JSONArray arr = new JSONArray(); for (CronJobDTO job : jobs) { JSONObject obj = new JSONObject(); @@ -99,14 +134,18 @@ public class CronJobTool { } } + @vip.mate.tool.ConcurrencyUnsafe("toggles row state in mate_cron_job; serialize to keep enabled/disabled deterministic") @Tool(description = "Enable or disable a scheduled task by its job ID. " + "Use list_cron_jobs first to find the job ID.") public String toggle_cron_job( @ToolParam(description = "Job ID (number)") Long jobId, - @ToolParam(description = "true to enable, false to disable") Boolean enabled) { + @ToolParam(description = "true to enable, false to disable") Boolean enabled, + @Nullable ToolContext ctx) { try { - cronJobService.toggle(jobId, enabled); - CronJobDTO updated = cronJobService.getById(jobId); + // RFC-083: scope toggle to the originating workspace. + 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); @@ -120,14 +159,18 @@ public class CronJobTool { } } + @vip.mate.tool.ConcurrencyUnsafe("destructive — removes row from mate_cron_job") @Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. " + "Use list_cron_jobs first to find the job ID.") public String delete_cron_job( - @ToolParam(description = "Job ID (number) to delete") Long jobId) { + @ToolParam(description = "Job ID (number) to delete") Long jobId, + @Nullable ToolContext ctx) { try { - CronJobDTO job = cronJobService.getById(jobId); + // RFC-083: scope delete to the originating workspace. + Long workspaceId = workspaceFromContext(ctx); + CronJobDTO job = cronJobService.getById(jobId, workspaceId); String jobName = job.getName(); - cronJobService.delete(jobId); + cronJobService.delete(jobId, workspaceId); JSONObject result = new JSONObject(); result.set("success", true); result.set("deleted", jobName); @@ -138,29 +181,34 @@ public class CronJobTool { } } - /** - * Resolve agent ID from conversation ID. - * Convention: cron conversations use "cron:{jobId}", normal chats use "{agentId}:{uuid}". - */ - private Long resolveAgentId(String conversationId) { - if (conversationId == null || conversationId.isBlank()) { - return 1L; // default agent - } - // Try to extract agent ID from conversation metadata - // For now, use default agent ID 1 (the conversation's agent binding is handled by the caller) - try { - // Convention: conversationId might contain agent context info - // Fallback to first enabled agent - return 1L; - } catch (Exception e) { - return 1L; - } - } - private String errorResult(String message) { JSONObject result = new JSONObject(); result.set("success", false); result.set("error", message); return JSONUtil.toJsonPrettyStr(result); } + + /** + * RFC-083: resolve the workspace ID from the originating ChatOrigin so + * cron-tool reads/writes are scoped to the agent's current workspace. + * Falls back to the default workspace (1) when origin is unscoped — same + * behaviour as the controller-layer {@code resolve()} helper. + */ + private Long workspaceFromContext(@Nullable ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L; + } + + /** + * RFC-063r §2.4: propagate the originating channel binding into the cron + * job DTO so PR-3's delivery dispatcher can route results back to the + * originating channel. + */ + private void propagateChannelBinding(CronJobDTO dto, ChatOrigin origin) { + if (origin == null || origin.channelId() == null) return; + dto.setChannelId(origin.channelId()); + if (origin.channelTarget() != null) { + dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from(origin.channelTarget())); + } + } } 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 c2e12e30..e3793a80 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 @@ -5,10 +5,13 @@ import com.fasterxml.jackson.core.type.TypeReference; 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.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.channel.web.ChatStreamTracker; @@ -19,16 +22,17 @@ import java.util.concurrent.*; import java.util.stream.Collectors; /** - * 内置工具:Agent 委派(多 Agent 协作) + * Built-in tool: Agent delegation (multi-agent collaboration). *

- * 支持两种模式: + * Two modes: *

    - *
  • {@link #delegateToAgent} — 单任务委派(串行)
  • - *
  • {@link #delegateParallel} — 多任务并行委派(最多 3 个子 Agent 同时执行)
  • + *
  • {@link #delegateToAgent} — single-task serial delegation
  • + *
  • {@link #delegateParallel} — parallel delegation to up to 3 child agents simultaneously
  • *
- * 被委派的 Agent 在独立子会话中运行(记录父子关系), - * 执行期间通过 SSE 事件 relay 向父会话实时推送进度。 - * 子 Agent 的工具集自动收窄——禁止递归委派和 Agent 发现工具。 + * Each delegated agent runs in an isolated child conversation (parent-child relationship is + * persisted). Progress is relayed to the parent session via SSE events in real time. + * Child agents have a narrowed tool set — recursive delegation and agent-discovery tools are + * blocked. * * @author MateClaw Team */ @@ -40,16 +44,22 @@ public class DelegateAgentTool { private static final int MAX_DELEGATION_DEPTH = 3; private static final int MAX_RESULT_LENGTH = 4000; private static final int MAX_PARALLEL_CHILDREN = 3; - private static final int PARALLEL_TIMEOUT_SECONDS = 300; // 5 分钟 + /** + * Per-child timeout — raised from 60 s to 120 s so that slow LLM models + * (kimi-code observed p99 ≈ 91 s) can complete before the parent gives up. + * The previous 60 s limit was structurally impossible to satisfy once any + * child called an LLM-backed tool. + */ + private static final int PARALLEL_TIMEOUT_SECONDS = 120; - /** 子 Agent 禁用的工具:防递归 + 防副作用 */ + /** Tools blocked for child agents — prevents recursion and side effects. */ private static final Set CHILD_DENIED_TOOLS = Set.of( - "delegateToAgent", // 禁止递归委派 - "delegateParallel", // 禁止并行递归 - "listAvailableAgents" // 子 Agent 不需要发现其他 Agent + "delegateToAgent", // no recursive serial delegation + "delegateParallel", // no recursive parallel delegation + "listAvailableAgents" // child agents do not need to discover other agents ); - /** 并行委派执行器:JDK 21 虚拟线程,每个子 Agent 一个轻量级虚拟线程 */ + /** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */ private static final ExecutorService DELEGATION_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -59,8 +69,9 @@ public class DelegateAgentTool { private final ConversationService conversationService; private final ObjectMapper objectMapper; - // ==================== 单任务委派 ==================== + // ==================== Single-task delegation ==================== + @vip.mate.tool.ConcurrencyUnsafe("spawns a child agent session and writes to mate_conversation; serialize to keep session graph deterministic") @Tool(description = """ Delegate a task to another Agent for multi-agent collaboration. \ Target Agent executes in an independent session and returns its final reply. \ @@ -68,7 +79,12 @@ public class DelegateAgentTool { For multiple parallel tasks, use delegateParallel instead.""") public String delegateToAgent( @ToolParam(description = "Target Agent name (exact match)") String agentName, - @ToolParam(description = "Task description with complete context information") String task) { + @ToolParam(description = "Task description with complete context information") String task, + // RFC-063r §2.5 改动点 5: parent ChatOrigin (channel binding / + // workspace) propagates into the delegated child so a sub-agent + // creating a cron job still binds back to the originating channel. + // Hidden from the LLM by JsonSchemaGenerator. + @Nullable ToolContext ctx) { if (agentName == null || agentName.isBlank()) { return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint(); @@ -90,10 +106,10 @@ public class DelegateAgentTool { String parentConversationId = resolveParentConversationId(); String childConversationId = createChildConv(target, parentConversationId); - log.info("Agent 委派: depth={}, target={}({}), childConv={}, parentConv={}", + log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}", depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId); - // SSE 广播 + relay + // Broadcast delegation_start + register event relay to parent session boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId); if (hasParent) { streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of( @@ -103,10 +119,13 @@ public class DelegateAgentTool { } Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null; - // 执行 - ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId); + // Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent + // ChatOrigin and only swap the agentId, so channel binding / + // workspace / requester all flow into the child. + ChatOrigin parentOrigin = ChatOrigin.from(ctx); + ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId, parentOrigin); - // 清理 + 广播结果 + // Cleanup relay, then broadcast final result if (stopRelay != null) stopRelay.run(); if (hasParent) { broadcastEnd(parentConversationId, childConversationId, target.getName(), result); @@ -115,8 +134,9 @@ public class DelegateAgentTool { return result.toToolResponse(target.getName()); } - // ==================== 并行委派 ==================== + // ==================== Parallel delegation ==================== + @vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize") @Tool(description = """ Delegate multiple tasks to different Agents in parallel (max 3). \ Each task runs concurrently in an independent child session. \ @@ -124,9 +144,11 @@ public class DelegateAgentTool { Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""") public String delegateParallel( @ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]") - String tasksJson) { + String tasksJson, + // RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children. + @Nullable ToolContext ctx) { - // 1. 解析任务列表 + // 1. Parse task list List> tasks; try { tasks = objectMapper.readValue(tasksJson, new TypeReference<>() {}); @@ -149,7 +171,7 @@ public class DelegateAgentTool { String parentConversationId = resolveParentConversationId(); boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId); - // 2. 主线程:校验所有 Agent + 创建子会话 + 注册 relay + // 2. Main thread: validate agents, create child conversations, register relays record PreparedChild(int index, AgentEntity agent, String task, String childConvId, Runnable stopRelay) {} List prepared = new ArrayList<>(); List errors = new ArrayList<>(); @@ -179,9 +201,9 @@ public class DelegateAgentTool { return "[错误] 所有任务校验失败:\n" + String.join("\n", errors); } - log.info("并行委派: {} 个任务, parentConv={}", prepared.size(), parentConversationId); + log.info("Parallel delegation: {} tasks, parentConv={}", prepared.size(), parentConversationId); - // 3. 广播 delegation_start(并行模式) + // 3. Broadcast delegation_start (parallel mode) if (hasParent) { List> childrenInfo = prepared.stream().map(p -> Map.of( "childConversationId", p.childConvId, @@ -193,29 +215,66 @@ public class DelegateAgentTool { "children", childrenInfo)); } - // 4. 并行执行 + // 4. Fan out — execute children in parallel long startTime = System.currentTimeMillis(); Map> futures = new LinkedHashMap<>(); + // RFC-063r §2.5 改动点 5: capture parent origin once on this thread, + // then hand it to each child future — the worker virtual threads + // can't re-read the ToolContext (no parameter scope), so we close + // over the captured origin. + ChatOrigin parentOriginParallel = ChatOrigin.from(ctx); for (PreparedChild p : prepared) { CompletableFuture future = CompletableFuture.supplyAsync( - () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId), + () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, parentOriginParallel), DELEGATION_EXECUTOR); + + // Broadcast per-child completion as soon as each child finishes + // — frontend can update that child's status without waiting for all children. + // Guard: skip CancellationException (fired when the timeout loop calls cancel(true)) + // because the timeout result is already handled in the collection loop below and + // emitting here first would race-replace the correct "timeout" error before delegation_end + // has a chance to patch remaining running segments. + if (hasParent) { + final String parentConvIdFinal = parentConversationId; + future.whenComplete((result, ex) -> { + if (ex instanceof java.util.concurrent.CancellationException) return; + if (!streamTracker.isRunning(parentConvIdFinal)) return; + ChildResult r = (result != null) ? result + : ChildResult.ofError(p.index, p.agent.getName(), + ex != null ? ex.getMessage() : "Unknown error"); + Map payload = new java.util.LinkedHashMap<>(); + payload.put("taskIndex", r.taskIndex); + payload.put("childConversationId", p.childConvId); + payload.put("childAgentName", r.agentName); + payload.put("success", r.success); + payload.put("outcome", r.outcome); + payload.put("rawLength", r.rawLength); + payload.put("trimmedLength", r.trimmedLength); + payload.put("blank", r.isBlank()); + payload.put("durationMs", r.durationMs); + payload.put("resultPreview", r.success + ? truncate(r.result, 400) + : (r.error != null ? r.error : "error")); + streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", payload); + }); + } + futures.put(p.index, future); } - // 5. 等待全部完成(带超时) + // 5. Wait for all children (with timeout) List results = new ArrayList<>(); try { CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])) .get(PARALLEL_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (TimeoutException e) { - log.warn("并行委派超时 ({}s),收集已完成的结果", PARALLEL_TIMEOUT_SECONDS); + log.warn("Parallel delegation timed out ({}s), collecting completed results", PARALLEL_TIMEOUT_SECONDS); } catch (Exception e) { - log.error("并行委派异常: {}", e.getMessage()); + log.error("Parallel delegation error: {}", e.getMessage()); } - // 收集结果(已完成的 + 超时的) + // Collect results — completed futures get their value; unfinished ones are cancelled and recorded as timeout for (var entry : futures.entrySet()) { int idx = entry.getKey(); CompletableFuture f = entry.getValue(); @@ -226,87 +285,233 @@ public class DelegateAgentTool { try { results.add(f.get()); } catch (Exception ex) { - results.add(ChildResult.error(idx, agentName, ex.getMessage())); + results.add(ChildResult.ofError(idx, agentName, ex.getMessage())); } } else { f.cancel(true); - results.add(ChildResult.error(idx, agentName, "超时 (" + PARALLEL_TIMEOUT_SECONDS + "s)")); + // Use ofTimeout so outcome="timeout" is explicit and distinct from "error". + results.add(ChildResult.ofTimeout(idx, agentName, PARALLEL_TIMEOUT_SECONDS)); } } long totalDurationMs = System.currentTimeMillis() - startTime; - // 6. 清理 relay + // 6. Stop all relays for (PreparedChild p : prepared) { if (p.stopRelay != null) p.stopRelay.run(); } - // 7. 广播 delegation_end + // 7. Broadcast delegation_end with per-child structured summary if (hasParent) { + List> childResults = results.stream().map(r -> { + Map m = new java.util.LinkedHashMap<>(); + m.put("taskIndex", r.taskIndex); + m.put("agentName", r.agentName); + m.put("success", r.success); + m.put("outcome", r.outcome); // "success"|"blank_success"|"timeout"|"error" + m.put("rawLength", r.rawLength); // chars before truncation + m.put("trimmedLength", r.trimmedLength); + m.put("blank", r.isBlank()); + m.put("durationMs", r.durationMs); + // childConversationId for stable frontend segment lookup + prepared.stream() + .filter(p -> p.index == r.taskIndex) + .findFirst() + .ifPresent(p -> m.put("childConversationId", p.childConvId)); + if (!r.success && r.error != null) m.put("error", r.error); + return m; + }).toList(); streamTracker.broadcastObject(parentConversationId, "delegation_end", Map.of( "parallel", true, "totalDurationMs", totalDurationMs, "success", results.stream().allMatch(r -> r.success), "completedCount", results.stream().filter(r -> r.success).count(), - "totalCount", results.size())); + "blankCount", results.stream().filter(ChildResult::isBlank).count(), + "totalCount", results.size(), + "childResults", childResults)); } - // 8. 构建返回结果 + // 8. Build return text — structured so the parent LLM cannot misread current results + // using memory of past timeouts. The machine-readable header line is the source of truth. results.sort(Comparator.comparingInt(r -> r.taskIndex)); + long successCount = results.stream().filter(r -> r.success && !r.isBlank()).count(); + long blankCount = results.stream().filter(ChildResult::isBlank).count(); + long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count(); + long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count(); + StringBuilder sb = new StringBuilder(); + + // Machine-readable summary line (highest priority, appears first). + // Explicit blank/timeout/error counts prevent the parent agent from misreading a + // successful run as a timeout even when historical memory says "this agent often times out". + sb.append("[PARALLEL_DELEGATION_RESULT]") + .append(" total=").append(results.size()) + .append(" success=").append(successCount) + .append(" blank_success=").append(blankCount) + .append(" timeout=").append(timeoutCount) + .append(" error=").append(errorCount) + .append(" durationMs=").append(totalDurationMs) + .append("\n\n"); + + // Important: this result is from the current execution. Any timeout entries in the + // conversation history were from previous runs and must not be applied to this result. + sb.append("⚠ 注意:本次结果基于当前执行,与历史对话中出现的超时记录无关。\n\n"); + if (!errors.isEmpty()) { - sb.append("⚠️ 部分任务未执行:\n"); + sb.append("⚠️ 部分任务未执行(Agent 未找到或参数错误):\n"); errors.forEach(e -> sb.append(" ").append(e).append("\n")); sb.append("\n"); } - sb.append("并行执行 ").append(results.size()).append(" 个任务(总耗时 ") - .append(totalDurationMs / 1000).append("s):\n\n"); + + sb.append("## 各子任务执行结果\n\n"); for (ChildResult r : results) { - sb.append("---\n### [任务 ").append(r.taskIndex + 1).append("] Agent「").append(r.agentName).append("」"); - sb.append(r.success ? " ✓" : " ✗").append(" (").append(r.durationMs / 1000).append("s)\n\n"); - sb.append(r.success ? r.result : "[错误] " + r.error).append("\n\n"); + sb.append("### [任务 ").append(r.taskIndex + 1).append("] ").append(r.agentName).append("\n"); + // Per-row machine-readable status — impossible to confuse with a different outcome + sb.append("outcome=").append(r.outcome) + .append(" | contentLength=").append(r.trimmedLength).append("chars") + .append(" | rawLength=").append(r.rawLength).append("chars") + .append(" | duration=").append(r.durationMs / 1000).append("s") + .append("\n\n"); + + switch (r.outcome) { + case "success" -> { + sb.append("✅ 执行成功,有实质内容(").append(r.trimmedLength).append(" 字符)\n\n"); + sb.append(r.result); + } + case "blank_success" -> { + sb.append("⚠ 执行成功,但返回内容为空(rawLength=").append(r.rawLength) + .append(",trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n"); + } + case "timeout" -> + sb.append("❌ 超时(").append(PARALLEL_TIMEOUT_SECONDS).append("s 内未返回)\n"); + default -> + sb.append("❌ 失败:").append(r.error).append("\n"); + } + sb.append("\n"); } return truncate(sb.toString(), MAX_RESULT_LENGTH * 2); // 并行结果允许更长 } - // ==================== 子 Agent 执行(单/并行共用) ==================== + // ==================== Child agent execution (shared by single and parallel paths) ==================== /** - * 执行单个子 Agent。在子线程内独立设置 DelegationContext,解决 ThreadLocal 并行问题。 + * Runs a single child agent. Sets up {@link DelegationContext} independently per virtual thread + * so that parallel children do not share ThreadLocal state. + *

+ * Raw result length must be measured before calling {@code truncate()}, otherwise + * {@link ChildResult#rawLength} and {@link ChildResult#trimmedLength} would always reflect the + * truncated length, making "blank_success" detection unreliable. */ private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, - String parentConversationId, String childConversationId) { + String parentConversationId, String childConversationId, + ChatOrigin parentOrigin) { DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS); try { long startTime = System.currentTimeMillis(); - String result = agentService.chat(target.getId(), task, childConversationId); + // RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId + // so child reads correct identity from ToolContext while keeping + // channelId / channelTarget / workspace context intact. + ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY) + .withAgent(target.getId()) + .withConversationId(childConversationId); + String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin); long durationMs = System.currentTimeMillis() - startTime; - return ChildResult.success(taskIndex, target.getName(), truncate(result, MAX_RESULT_LENGTH), durationMs); + // Measure lengths before truncation so ChildResult carries accurate metadata. + return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs, + MAX_RESULT_LENGTH); } catch (Exception e) { - log.error("子 Agent 执行失败: taskIndex={}, agent={}, error={}", + log.error("Child agent failed: taskIndex={}, agent={}, error={}", taskIndex, target.getName(), e.getMessage()); - return ChildResult.error(taskIndex, target.getName(), e.getMessage()); + return ChildResult.ofError(taskIndex, target.getName(), e.getMessage()); } finally { DelegationContext.exit(); } } - /** 子 Agent 执行结果 */ - private record ChildResult(int taskIndex, String agentName, boolean success, - String result, String error, long durationMs) { + /** + * Result carrier for a single child agent execution. + * + *

{@code outcome} values: + *

    + *
  • {@code "success"} — completed successfully with non-empty content (trimmedLength > 0)
  • + *
  • {@code "blank_success"} — completed successfully but returned empty content (trimmedLength == 0)
  • + *
  • {@code "timeout"} — did not complete within the parallel wait window
  • + *
  • {@code "error"} — threw an exception during execution
  • + *
+ * + *

{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the + * true content length. + */ + private record ChildResult( + int taskIndex, String agentName, boolean success, + String result, String error, long durationMs, + /** "success" | "blank_success" | "timeout" | "error" */ + String outcome, + int rawLength, int trimmedLength) { + + /** Whether the child returned no usable content (blank_success). */ + boolean isBlank() { return "blank_success".equals(outcome); } + + /** + * Factory for a successful child execution. + * Measures lengths from the raw result before applying the truncation limit. + */ + static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen) { + String safe = rawResult != null ? rawResult : ""; + String trimmed = safe.trim(); + boolean blank = trimmed.isEmpty(); + return new ChildResult( + idx, name, true, + truncate(safe, maxLen), + null, ms, + blank ? "blank_success" : "success", + safe.length(), trimmed.length()); + } + + /** + * Factory for a child that failed (exception or timeout). + * Detects timeout by inspecting the error message so callers don't need to branch. + */ + static ChildResult ofError(int idx, String name, String err) { + String msg = err != null ? err : "Unknown error"; + boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout"); + return new ChildResult(idx, name, false, null, msg, 0, + isTimeout ? "timeout" : "error", 0, 0); + } + + /** Factory for an explicit timeout (parallel window exceeded). */ + static ChildResult ofTimeout(int idx, String name, int timeoutSec) { + String msg = "超时 (" + timeoutSec + "s)"; + return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L, + "timeout", 0, 0); + } + + // Legacy shims — kept for callers that pre-date the factory methods static ChildResult success(int idx, String name, String result, long ms) { - return new ChildResult(idx, name, true, result, null, ms); + // result may already be truncated at call site — lengths will be approximate + String safe = result != null ? result : ""; + String trimmed = safe.trim(); + boolean blank = trimmed.isEmpty(); + return new ChildResult(idx, name, true, safe, null, ms, + blank ? "blank_success" : "success", safe.length(), trimmed.length()); } static ChildResult error(int idx, String name, String err) { - return new ChildResult(idx, name, false, null, err != null ? err : "Unknown error", 0); + return ofError(idx, name, err); } + String toToolResponse(String agentName) { - if (success) return "[Agent「" + agentName + "」的回复]\n\n" + result; + if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : ""); return "[错误] Agent「" + agentName + "」执行失败: " + error; } + + private static String truncate(String text, int maxLength) { + if (text == null) return ""; + if (text.length() <= maxLength) return text; + return text.substring(0, maxLength) + "\n... [截断,原文 " + text.length() + " 字符]"; + } } - // ==================== 辅助方法 ==================== + // ==================== Helper methods ==================== @Tool(description = "List all available Agents (enabled), including name, type, and description.") public String listAvailableAgents() { @@ -348,11 +553,20 @@ public class DelegateAgentTool { return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> { if ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) { try { + // Parse jsonData into a plain Object so the frontend receives a proper + // JSON object under "data", not a string containing serialized JSON. + // If parsing fails (e.g. plain text payload), fall back to the raw string. + Object parsedData; + try { + parsedData = objectMapper.readValue(jsonData, Object.class); + } catch (Exception ignored) { + parsedData = jsonData; + } streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of( "childConversationId", childConvId, "childAgentName", childAgentName, "originalEvent", eventName, - "data", jsonData)); + "data", parsedData)); } catch (Exception e) { log.debug("Relay error: {}", e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index 840b2fa6..c0b81b10 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -1,62 +1,65 @@ package vip.mate.tool.builtin; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.Set; /** - * 跟踪 Agent 委派调用的上下文信息,防止无限递归并传递父会话信息。 + * Tracks Agent delegation call context to prevent infinite recursion and carry parent session info. *

- * 使用 ThreadLocal 存储当前线程的委派层级、父会话 ID 和子 Agent 禁用工具集。 - * 每次 {@link DelegateAgentTool} 发起委派时调用 enter(),返回后调用 exit()。 + * Uses a ThreadLocal stack so that nested delegations correctly restore the previous layer's + * parentConversationId and childDeniedTools on exit. + * Each {@link DelegateAgentTool} delegation calls enter() before and exit() after execution. * * @author MateClaw Team */ public final class DelegationContext { - private static final ThreadLocal DEPTH = ThreadLocal.withInitial(() -> 0); - private static final ThreadLocal PARENT_CONVERSATION_ID = new ThreadLocal<>(); - private static final ThreadLocal> CHILD_DENIED_TOOLS = new ThreadLocal<>(); + /** + * Snapshot of one delegation layer's state. + */ + private record Frame(String parentConversationId, Set childDeniedTools) {} + + private static final ThreadLocal> STACK = ThreadLocal.withInitial(ArrayDeque::new); private DelegationContext() {} - /** 获取当前委派深度(0 = 顶层调用) */ + /** Current delegation depth (0 = top-level call, not inside any delegation) */ public static int currentDepth() { - return DEPTH.get(); + return STACK.get().size(); } - /** 获取父会话 ID(用于事件 relay) */ + /** Parent conversation ID for event relay (from the current frame) */ public static String parentConversationId() { - return PARENT_CONVERSATION_ID.get(); + Frame top = STACK.get().peek(); + return top != null ? top.parentConversationId : null; } - /** 获取子 Agent 禁用的工具集 */ + /** Denied tools set for the child Agent (from the current frame) */ public static Set childDeniedTools() { - Set denied = CHILD_DENIED_TOOLS.get(); - return denied != null ? denied : Set.of(); + Frame top = STACK.get().peek(); + return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of(); } - /** 进入下一层委派(带父会话 ID 和子 Agent 工具限制) */ + /** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */ public static void enter(String parentConversationId, Set deniedTools) { - DEPTH.set(DEPTH.get() + 1); - PARENT_CONVERSATION_ID.set(parentConversationId); - if (deniedTools != null) { - CHILD_DENIED_TOOLS.set(deniedTools); - } + STACK.get().push(new Frame(parentConversationId, deniedTools)); } - /** 进入下一层委派(兼容旧调用) */ + /** Enter the next delegation layer (backward-compatible overload) */ public static void enter() { enter(null, null); } - /** 退出当前委派层 */ + /** Exit the current delegation layer, restoring the previous layer's context */ public static void exit() { - int current = DEPTH.get(); - if (current <= 1) { - DEPTH.remove(); - PARENT_CONVERSATION_ID.remove(); - CHILD_DENIED_TOOLS.remove(); - } else { - DEPTH.set(current - 1); + Deque stack = STACK.get(); + if (!stack.isEmpty()) { + stack.pop(); + } + // Clean up ThreadLocal entirely when the stack is empty to prevent memory leaks + if (stack.isEmpty()) { + STACK.remove(); } } } 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 5c4cd5bc..8587b89b 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 @@ -32,7 +32,7 @@ import java.util.zip.ZipInputStream; public class DocumentExtractTool { private static final int COMMAND_TIMEOUT_SECONDS = 30; - private static final int MAX_OUTPUT_LENGTH = 100000; // 100KB 限制 + private static final int MAX_OUTPUT_LENGTH = 500000; // 500KB — CLOB column has no size limit private static final boolean IS_WINDOWS = System.getProperty("os.name", "") .toLowerCase(Locale.ROOT).contains("win"); @@ -45,20 +45,26 @@ public class DocumentExtractTool { - Excel (.xlsx, .xls) - 提取为文本表格 - PowerPoint (.pptx, .ppt) - 提取策略(自动选择最优方式): + 提取策略(默认自动选择最优方式): 1. 优先使用系统命令(pdftotext, textutil, pandoc 等) 2. 系统命令不可用时使用纯 Java 实现 - 3. 返回详细的提取过程和元数据 + 3. PDF 扫描版进入 OCR + 4. 全部失败前用 Apache Tika 兜底(覆盖 SmartArt、共享字符串表等盲区) + 5. 返回详细的提取过程和元数据 参数 options 可包含: - pages: 指定页码范围(如 "1-5" 或 "1,3,5") - preserveLayout: 是否保留布局(默认 true) + - method: 强制指定提取器,跳过自动 fallback 链。当前支持: + * "auto"(默认)—— 走完整 fallback 链 + * "tika" —— 直接用 Apache Tika 抽取,适合 Windows 上没装 + Poppler/Python 的环境,或验证 Tika 单独是否能解开 如果提取失败,会返回详细的尝试过程和错误信息 """) public String extract_document_text( @ToolParam(description = "文件的绝对路径或相对路径") String filePath, - @ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"preserveLayout\": true}", required = false) String options) { + @ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options) { JSONObject result = new JSONObject(); result.set("filePath", filePath); @@ -68,13 +74,55 @@ public class DocumentExtractTool { Path path = Paths.get(filePath).toAbsolutePath().normalize(); if (!Files.exists(path)) { - return errorResult(filePath, "文件不存在: " + path, attempts); + // The user-uploaded chat attachment is rendered to the LLM as + // "[附件] foo.docx" without its stored path, and Chinese / non-ASCII + // filenames are sanitized at upload time (see ChatController#upload), + // so the LLM-supplied path won't match anything on disk. Fall back to + // basename matching inside the conversation's chat-upload directory. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, "文件不存在: " + path, attempts); + } + log.info("[DocumentExtract] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment); + path = attachment; } // 解析文件类型 String mimeType = detectMimeType(path); result.set("mimeType", mimeType); + // RFC-051: method=tika 短路 —— 跳过整条 fallback 链,直接调 Tika。 + // 用于:1) 测试 Tika 集成是否健康;2) 用户明知系统命令不可用、想免去 + // 那一长串失败日志的场景。结果里仍然带 attempts 数组,告知"应用户要求跳过自动链"。 + String forcedMethod = extractOption(options, "method"); + if ("tika".equalsIgnoreCase(forcedMethod)) { + long t = System.currentTimeMillis(); + String text = TikaExtractor.extract(path); + attempts.add("user-forced method=tika: skipped automatic fallback chain"); + if (text == null || text.isBlank()) { + attempts.add("tika: 失败或不可用 (" + (System.currentTimeMillis() - t) + "ms)"); + return errorResult(filePath, "Tika 抽取无文本(可能格式不支持或文件损坏)", attempts); + } + attempts.add("tika: 成功 (" + (System.currentTimeMillis() - t) + "ms)"); + + String capped = text; + boolean trunc = false; + if (capped.length() > MAX_OUTPUT_LENGTH) { + capped = capped.substring(0, MAX_OUTPUT_LENGTH) + + "\n\n... [内容已截断,总长度: " + text.length() + " 字符]"; + trunc = true; + } + result.set("text", capped); + result.set("method", "tika"); + result.set("pages", estimatePages(text)); + result.set("attempts", attempts); + result.set("truncated", trunc); + result.set("success", true); + log.info("[DocumentExtract] {} 使用 method=tika 强制提取成功,{} 字符", + filePath, text.length()); + return JSONUtil.toJsonPrettyStr(result); + } + // 根据类型选择提取器 ExtractedContent content; if (mimeType.contains("pdf")) { @@ -222,15 +270,26 @@ public class DocumentExtractTool { } // attempts 已由 tryOcrExtract 内部记录失败原因 + // 5. Tika 兜底(RFC-051 §5.2):所有命令行 / Python / PDFBox / OCR 都失败时 + // 用 Java 内置的 Tika 再试一次。主要服务于 Windows 没装 Poppler / Python 的桌面用户。 + long t4 = System.currentTimeMillis(); + content = TikaExtractor.extract(path); + if (content != null && !content.isBlank()) { + attempts.add("tika: 成功 (" + (System.currentTimeMillis() - t4) + "ms)"); + int pages = realPageCount > 0 ? realPageCount : estimatePages(content); + return new ExtractedContent(content, "tika", pages); + } + attempts.add("tika: 失败或不可用"); + // 返回之前级别的部分结果(如果有) if (bestContent != null) { - log.warn("[DocumentExtract] OCR 不可用,返回部分文本结果: method={}, length={}", + log.warn("[DocumentExtract] OCR/Tika 不可用,返回部分文本结果: method={}, length={}", bestMethod, bestContent.strip().length()); int pages = realPageCount > 0 ? realPageCount : estimatePages(bestContent); return new ExtractedContent(bestContent, bestMethod + "_partial", pages); } - throw new Exception("所有 PDF 提取方法都失败(包括 OCR)"); + throw new Exception("所有 PDF 提取方法都失败(包括 OCR 与 Tika)"); } /** @@ -556,7 +615,17 @@ public class DocumentExtractTool { } attempts.add("java_zip_xml: 失败"); - throw new Exception("所有 DOCX 提取方法都失败"); + // 5. Tika 兜底(RFC-051 §5.2)—— 当 textutil/pandoc/libreoffice/ZIP-XML 全失败时。 + // Tika 的 Microsoft 模块覆盖到 .docx 内嵌 SmartArt、批注、复杂表格等场景,正好填补 + // 我们手写的 ZIP XML 解析器的盲区。 + content = TikaExtractor.extract(path); + if (content != null && !content.isBlank()) { + attempts.add("tika: 成功"); + return new ExtractedContent(content, "tika", 0); + } + attempts.add("tika: 失败或不可用"); + + throw new Exception("所有 DOCX 提取方法都失败(包括 Tika)"); } private String tryTextutil(Path path) { @@ -687,6 +756,17 @@ public class DocumentExtractTool { } } + // Our ZIP-XML extractor only reads tags and skips the shared-strings table, + // so cells full of text labels look "empty". When that happens, fall through to + // Tika which knows how to resolve the shared-strings indirection. + if (text.toString().replaceAll("---.*?---", "").strip().isEmpty()) { + String fallback = TikaExtractor.extract(path); + if (fallback != null && !fallback.isBlank()) { + attempts.add("tika: 成功(ZIP-XML 仅有数字 / 共享字符串未解析)"); + return new ExtractedContent(fallback, "tika", 0); + } + } + attempts.add("java_zip_xml: 成功"); return new ExtractedContent(text.toString(), "java_zip_xml", 0); } @@ -721,6 +801,17 @@ public class DocumentExtractTool { } } + // Slide layouts with text inside SmartArt / charts / grouped shapes don't surface + // through the simple grep — Tika walks the full DrawingML graph and pulls + // them out. Only invoke when our walker produced nothing useful. + if (text.toString().replaceAll("---.*?---", "").strip().isEmpty()) { + String fallback = TikaExtractor.extract(path); + if (fallback != null && !fallback.isBlank()) { + attempts.add("tika: 成功(ZIP-XML 未抓到正文,可能是 SmartArt / 图表)"); + return new ExtractedContent(fallback, "tika", Math.max(0, slideNum - 1)); + } + } + attempts.add("java_zip_xml: 成功"); return new ExtractedContent(text.toString(), "java_zip_xml", Math.max(0, slideNum - 1)); } 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 new file mode 100644 index 00000000..63975cee --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java @@ -0,0 +1,312 @@ +package vip.mate.tool.builtin; + +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.stereotype.Component; +import vip.mate.tool.document.GeneratedFileCache; +import vip.mate.tool.document.MarkdownDocxRenderer; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Render a brand-new .docx from Markdown without ever forking a process. + * + *

The previous path forwarded these requests to {@code skills/docx} which + * runs {@code npm install docx} on first use (3-5 minutes). For "create new + * document" intents that subprocess is wholly unnecessary; this tool produces + * the bytes in the JVM, stashes them in {@link GeneratedFileCache}, and + * returns a Markdown link the user can click to download. + * + *

The skill workflow is still authoritative for editing existing .docx, + * tracked changes, and other XML-level operations. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DocxRenderTool { + + private static final String DOCX_MIME = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + private final MarkdownDocxRenderer renderer; + private final GeneratedFileCache cache; + + @Tool(description = """ + Render a new .docx file from Markdown text and return a one-time download URL. + Use for creating NEW documents: reports, memos, contracts, letters, resumes. + Supports: headings (# ## ###), bold (**text**), bullet lists (- item), + numbered lists (1. item), tables (| col | col |), plain paragraphs, + images (![alt](path/to/file.png|jpg|gif|bmp|svg)) — SVG is rasterized + to PNG; image lines must contain only the image syntax. + + For markdown bodies larger than ~5 KB, prefer renderDocxFromFile (read from + disk) — passing huge markdown as a tool argument burns LLM tokens needlessly. + + Do NOT use for: + - Editing an existing .docx file (use run_skill_script with unpack/edit/pack) + - Adding tracked changes or comments (use run_skill_script) + - GB/T 9704 official documents (use writeGongwen tool, BmacClaw only) + + Returns a markdown link the user can click to download the file. + The link is valid for 10 minutes. + """) + public String renderDocx( + @ToolParam(description = "Document content in Markdown format") + String markdown, + @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) { + + if (markdown == null || markdown.isBlank()) { + return "错误:markdown 参数为空,无法生成文档。"; + } + + String safeName = sanitizeFilename(filename); + String displayName = safeName + ".docx"; + String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim(); + + try { + long t0 = System.currentTimeMillis(); + byte[] bytes = renderer.render(markdown, size); + String id = cache.put(bytes, displayName, DOCX_MIME); + long elapsed = System.currentTimeMillis() - t0; + log.info("[DocxRender] generated {} ({} bytes, {}ms, id={})", + displayName, bytes.length, elapsed, id); + + String url = "/api/v1/files/generated/" + id; + // Explicit instruction to suppress LLM hallucinating an absolute host. + // DeepSeek/Claude have been observed prepending placeholder domains + // (e.g. https://ai-tools-system.com) when echoing the URL back to the user, + // breaking the download link. Repeat the path verbatim with no host. + return "文档已生成:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n" + + "重要:回答用户时**必须**使用上述相对路径 `" + url + "`," + + "**不要**添加任何 https://、http:// 域名前缀,前端会自动拼接当前主机。"; + } catch (Exception e) { + log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e); + return "渲染失败:" + e.getMessage(); + } + } + + /** + * File-based renderer — reads markdown from disk instead of taking it as a + * tool argument. Bypasses the LLM token-cost cliff: a 80 KB markdown body + * would otherwise be streamed through the chat completion as part of + * {@code renderDocx.markdown} args (≈ 20 K tokens, several minutes of + * generation just to repeat back content the LLM already wrote to disk). + *

+ * Workflow: agent uses {@code write_file} / {@code edit_file} to assemble + * the markdown locally → calls this tool with the file path → docx is + * rendered from disk in one IO call. Token cost ≈ 50 (just the path). + */ + @Tool(description = """ + Render a .docx file from a markdown FILE on disk and return a one-time download URL. + Use this instead of `renderDocx` when the markdown body is large (>5 KB) — the + LLM does not need to repeat its own previous output as a tool argument. + + Typical workflow: + 1. write_file(path="report.md", content="# Report\\n...") // assemble markdown + 2. renderDocxFromFile(filePath="report.md", filename="monthly-report") + 3. return the download link to the user + + The markdown file is read with UTF-8. Path resolution honors the workspace + boundary (same rules as read_file / write_file). + + Same supported markdown subset as renderDocx (headings, bold, lists, tables, + images). Image references ![alt](path) are rendered when path resolves to a + readable file in the workspace. SVG sources are rasterized to PNG via Batik; + PNG/JPG/GIF/BMP are embedded directly. + """) + public String renderDocxFromFile( + @ToolParam(description = "Absolute or workspace-relative path to a markdown file") + String filePath, + @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) { + + if (filePath == null || filePath.isBlank()) { + return "Error: filePath parameter is empty."; + } + + Path resolved; + try { + resolved = WorkspacePathGuard.validatePath(filePath); + } catch (Exception e) { + return "Error: path validation failed — " + e.getMessage(); + } + if (!Files.exists(resolved)) { + return "Error: file not found at " + resolved; + } + if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) { + return "Error: path is not a readable regular file " + resolved; + } + + String markdown; + long mdBytes; + try { + mdBytes = Files.size(resolved); + markdown = Files.readString(resolved, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("[DocxRender] read markdown failed for {}: {}", resolved, e.getMessage(), e); + return "Error: failed to read markdown — " + e.getMessage(); + } + if (markdown.isBlank()) { + return "Error: markdown file is empty " + resolved; + } + + String safeName = sanitizeFilename(filename); + String displayName = safeName + ".docx"; + String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim(); + + try { + long t0 = System.currentTimeMillis(); + byte[] bytes = renderer.render(markdown, size); + String id = cache.put(bytes, displayName, DOCX_MIME); + long elapsed = System.currentTimeMillis() - t0; + log.info("[DocxRender] generated {} ({} bytes from {} bytes md, {}ms, id={})", + displayName, bytes.length, mdBytes, elapsed, id); + + String url = "/api/v1/files/generated/" + id; + return "Document generated: [" + displayName + "](" + url + ") (link valid for 10 minutes).\n" + + "IMPORTANT: when replying to the user you **must** use the relative path `" + + url + "` verbatim. Do **not** prepend any https://, http:// or domain — " + + "the frontend will resolve the current host automatically."; + } catch (Exception e) { + log.error("[DocxRender] render failed for {} (source: {}): {}", + displayName, resolved, e.getMessage(), e); + return "Render failed: " + e.getMessage(); + } + } + + /** + * Multi-file renderer — read several markdown files in order and concatenate + * them into one docx. Lets the agent split a long report into chapters + * (cover.md, intro.md, ch1.md, ...) and render the whole thing in one call, + * so a 30-page deliverable does not need to live in a single source file. + *

+ * Files are joined with a blank line so heading hierarchy and paragraph + * structure carry over cleanly; no extra separator markup is injected. + * Empty / missing files abort the render with a clear error so the agent + * can fix its file list before retrying. + */ + @Tool(description = """ + Render a .docx by concatenating MULTIPLE markdown files in order and return a + download URL. Use when a report is split into chapters / sections, or when the + agent assembled the document piece by piece (cover, table of contents, body, + appendix) across several files. + + Typical workflow: + 1. write_file(path="cover.md", content="# Title\\n...") + 2. write_file(path="ch1.md", content="## Chapter 1\\n...") + 3. write_file(path="ch2.md", content="## Chapter 2\\n...") + 4. renderDocxFromFiles(filePaths=["cover.md","ch1.md","ch2.md"], + filename="quarterly-report") + + Files are read with UTF-8, joined with one blank line between them, and + rendered with the same markdown subset as renderDocx (headings, bold, + lists, tables). All paths must pass the workspace boundary check. + """) + public String renderDocxFromFiles( + @ToolParam(description = "List of markdown file paths in render order") + List filePaths, + @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) { + + if (filePaths == null || filePaths.isEmpty()) { + return "Error: filePaths is empty."; + } + + StringBuilder combined = new StringBuilder(); + long totalBytes = 0; + List resolvedPaths = new ArrayList<>(); + for (int idx = 0; idx < filePaths.size(); idx++) { + String raw = filePaths.get(idx); + if (raw == null || raw.isBlank()) { + return "Error: filePaths[" + idx + "] is empty."; + } + Path resolved; + try { + resolved = WorkspacePathGuard.validatePath(raw); + } catch (Exception e) { + return "Error: filePaths[" + idx + "] validation failed — " + e.getMessage(); + } + if (!Files.exists(resolved)) { + return "Error: filePaths[" + idx + "] not found at " + resolved; + } + if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) { + return "Error: filePaths[" + idx + "] is not a readable regular file " + resolved; + } + String content; + try { + totalBytes += Files.size(resolved); + content = Files.readString(resolved, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("[DocxRender] read failed for {}: {}", resolved, e.getMessage(), e); + return "Error: read failed for " + resolved + " — " + e.getMessage(); + } + if (content.isBlank()) { + return "Error: filePaths[" + idx + "] is blank " + resolved; + } + if (combined.length() > 0) combined.append("\n\n"); + combined.append(content); + resolvedPaths.add(resolved.toString()); + } + + String safeName = sanitizeFilename(filename); + String displayName = safeName + ".docx"; + String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim(); + + try { + long t0 = System.currentTimeMillis(); + byte[] bytes = renderer.render(combined.toString(), size); + String id = cache.put(bytes, displayName, DOCX_MIME); + long elapsed = System.currentTimeMillis() - t0; + log.info("[DocxRender] generated {} ({} bytes from {} files / {} bytes md, {}ms, id={})", + displayName, bytes.length, resolvedPaths.size(), totalBytes, elapsed, id); + + String url = "/api/v1/files/generated/" + id; + return "Document generated from " + resolvedPaths.size() + " files: [" + + displayName + "](" + url + ") (link valid for 10 minutes).\n" + + "IMPORTANT: when replying to the user you **must** use the relative path `" + + url + "` verbatim. Do **not** prepend any https://, http:// or domain — " + + "the frontend will resolve the current host automatically."; + } catch (Exception e) { + log.error("[DocxRender] render failed for {} (sources: {}): {}", + displayName, resolvedPaths, e.getMessage(), e); + return "Render failed: " + e.getMessage(); + } + } + + /** + * Strip path separators and other unsafe characters from a user-supplied + * filename. Falls back to a generic name when nothing usable remains. + */ + private String sanitizeFilename(String name) { + if (name == null) return "document"; + String trimmed = name.trim(); + if (trimmed.toLowerCase().endsWith(".docx")) { + trimmed = trimmed.substring(0, trimmed.length() - 5); + } + StringBuilder sb = new StringBuilder(trimmed.length()); + for (char c : trimmed.toCharArray()) { + if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' + || c == '"' || c == '<' || c == '>' || c == '|' || c < 0x20) { + sb.append('_'); + } else { + sb.append(c); + } + } + String cleaned = sb.toString().strip(); + return cleaned.isEmpty() ? "document" : cleaned; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java index d57d5621..ca1fbe45 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java @@ -33,6 +33,7 @@ public class EditFileTool { private final vip.mate.i18n.I18nService i18n; + @vip.mate.tool.ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path") @Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. " + "Returns structured JSON with filePath, replacements count. " + "Requires user approval. Replaces first occurrence by default; set replaceAll=true for all.") diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java index 938c0fae..b423e6be 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java @@ -50,7 +50,14 @@ public class FileTypeDetectorTool { Path path = Paths.get(filePath).toAbsolutePath().normalize(); if (!Files.exists(path)) { - return errorResult(filePath, "文件不存在: " + path); + // Fall back to chat-upload basename matching for filenames that were + // sanitized at upload time (e.g. Chinese characters → underscores). + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, "文件不存在: " + path); + } + log.info("[FileTypeDetector] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment); + path = attachment; } if (Files.isDirectory(path)) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java index 0a8d31b8..a15c4538 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ImageGenerateTool.java @@ -2,8 +2,10 @@ 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.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; @@ -29,6 +31,7 @@ public class ImageGenerateTool { private final SystemSettingService systemSettingService; private final AsyncTaskService asyncTaskService; + @vip.mate.tool.ConcurrencyUnsafe("creates async tasks and persists generated artifacts; provider rate limits also forbid parallel calls") @Tool(description = "Image generation tool. Supports actions: generate (default), list (show available providers), " + "status (check task status). Some providers are async (30s-2min), results auto-displayed in conversation.") public String image_generate( @@ -38,14 +41,16 @@ public class ImageGenerateTool { @ToolParam(description = "Aspect ratio: 1:1 / 16:9 / 9:16, default 1:1", required = false) String aspectRatio, @ToolParam(description = "Generation count (1-4), default 1", required = false) Integer count, @ToolParam(description = "Model name (optional)", required = false) String model, - @ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId + @ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId, + // RFC-063r §2.5: ToolContext is hidden from the LLM by JsonSchemaGenerator. + @Nullable ToolContext ctx ) { String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase(); return switch (normalizedAction) { case "list" -> handleListAction(); - case "status" -> handleStatusAction(taskId); - default -> handleGenerateAction(prompt, size, aspectRatio, count, model); + case "status" -> handleStatusAction(taskId, ctx); + default -> handleGenerateAction(prompt, size, aspectRatio, count, model, ctx); }; } @@ -83,8 +88,8 @@ public class ImageGenerateTool { // ==================== action=status ==================== - private String handleStatusAction(String taskId) { - String conversationId = ToolExecutionContext.conversationId(); + private String handleStatusAction(String taskId, @Nullable ToolContext ctx) { + String conversationId = ToolExecutionContext.conversationId(ctx); if (taskId != null && !taskId.isBlank()) { AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId); @@ -116,9 +121,9 @@ public class ImageGenerateTool { // ==================== action=generate ==================== private String handleGenerateAction(String prompt, String size, String aspectRatio, - Integer count, String model) { - String conversationId = ToolExecutionContext.conversationId(); - String username = ToolExecutionContext.username(); + Integer count, String model, @Nullable ToolContext ctx) { + String conversationId = ToolExecutionContext.conversationId(ctx); + String username = ToolExecutionContext.username(ctx); if (conversationId == null || conversationId.isBlank()) { return "错误:无法获取当前会话信息,请重试"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java index e3bf30f9..6c582826 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java @@ -3,16 +3,16 @@ package vip.mate.tool.builtin; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; 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 java.io.BufferedReader; import java.io.IOException; import java.nio.charset.StandardCharsets; 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.Set; @@ -54,7 +54,9 @@ public class ReadFileTool { public String read_file( @ToolParam(description = "Absolute or relative file path") String filePath, @ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine, - @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine) { + @ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine, + // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. + @Nullable ToolContext ctx) { JSONObject result = new JSONObject(); result.set("filePath", filePath); @@ -62,14 +64,34 @@ public class ReadFileTool { try { Path path; try { - path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath); + // RFC-063r §2.5: forward ToolContext so workspace boundary + // honors ChatOrigin.workspaceBasePath when available. + path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx); } catch (IllegalArgumentException e) { - return errorResult(filePath, e.getMessage()); + // Sandbox rejected the literal path. The LLM may have hallucinated + // a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that + // actually lives under data/chat-uploads/{conversationId}/. Retry + // by basename before surfacing the boundary error. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, e.getMessage()); + } + path = attachment; } // 文件存在性和类型校验 if (!Files.exists(path)) { - return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path)); + // The user-uploaded chat attachment is rendered to the LLM as + // "[附件] foo.txt" without its stored path, so LLMs often pass + // just the basename or a guessed absolute path. Fall back to + // looking up the basename inside the current conversation's + // chat-upload directory before reporting not-found. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path)); + } + log.info("[ReadFile] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment); + path = attachment; } if (Files.isDirectory(path)) { return errorResult(filePath, i18n.msg("tool.read_file.error.is_directory", path)); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index 9b0e85b0..2f24aed5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -43,6 +43,7 @@ public class ShellExecuteTool { private static final boolean IS_WINDOWS = System.getProperty("os.name", "") .toLowerCase(Locale.ROOT).contains("win"); + @vip.mate.tool.ConcurrencyUnsafe("shell command execution can mutate global state in ways the executor can't reason about") @Tool(description = "Execute a shell command on the local server. For running system commands, viewing files, running scripts. " + "Uses cmd.exe on Windows, /bin/sh on Linux/macOS. " + "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.") @@ -151,14 +152,32 @@ public class ShellExecuteTool { } /** - * 将命令中的嵌入换行符替换为空格。 - * LLM 在 JSON tool_call 中产生的 \n 解码后变成真实换行, - * 在 Windows cmd.exe 中会导致命令被截断,在 Unix sh 中可能被误解为命令分隔符。 + * Collapse embedded newlines for Windows cmd.exe (where they break parsing), + * but **leave them alone on Unix**. + *

+ * The original implementation collapsed on every platform under the worry + * that a stray newline could be misread as a command separator on POSIX + * shells. In practice that worry is wrong for two common idioms the LLM + * actually uses to write files: heredocs (`cat <<EOF\nbody\nEOF`) and + * `python <<EOF` invocations. Both depend on real line breaks to + * delimit the body from the closing tag — collapsing newlines turns + * `cat <<EOF\nbody\nEOF` into `cat <<EOF body EOF`, which the + * shell reads as "open heredoc, immediately close, write 0 bytes." The + * symptom: every chapter file produced by the agent ends up 0-byte. + *

+ * Unix shell already separates commands with `;` or `&&`, not + * unquoted newlines, so leaving newlines in is actually safer — and + * heredocs / multi-line commands now behave as the LLM expects. Windows + * cmd.exe still gets the collapse because there it really does break. */ private static String collapseEmbeddedNewlines(String command) { if (command == null || !command.contains("\n")) { return command; } + if (!IS_WINDOWS) { + // POSIX shell handles newlines correctly within heredocs / scripts + return command; + } return command.replace("\r\n", " ").replace("\n", " "); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index 261c76f6..7340272c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -43,6 +43,7 @@ public class SkillManageTool { /** Skill 内容最大长度(~25K tokens) */ private static final int MAX_CONTENT_CHARS = 100_000; + @vip.mate.tool.ConcurrencyUnsafe("create/edit/patch/delete on the shared skill registry; concurrent ops on the same skill name race") @Tool(description = """ Manage reusable skills: create, edit, patch, or delete skill procedures (SKILL.md format). 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 603853cd..ab7d1d67 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 @@ -28,6 +28,7 @@ public class SkillScriptTool { private final SkillFileAccessPolicy accessPolicy; private final SkillScriptExecutionService executionService; + @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. Use this when you need to run skill-provided automation or utilities. diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/TikaExtractor.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/TikaExtractor.java new file mode 100644 index 00000000..1965adc0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/TikaExtractor.java @@ -0,0 +1,91 @@ +package vip.mate.tool.builtin; + +import lombok.extern.slf4j.Slf4j; +import org.apache.tika.exception.WriteLimitReachedException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.AutoDetectParser; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.sax.BodyContentHandler; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * RFC-051 §5.2: Apache Tika as the last-resort document extractor. + *

+ * Used by {@link DocumentExtractTool} only after every other path + * (pdftotext / pdfplumber / pdfbox / OCR for PDFs, and the system-command + * + ZIP-XML chain for Office formats) has failed. Tika ships its own + * PDFBox + POI internals, so it works on Windows installs without Python + * or Poppler — which is the actual scenario the RFC §13.1 pointed to. + * + *

Safety

+ *
    + *
  • {@link BodyContentHandler} caps output at {@code maxChars}; when the + * cap is hit Tika throws {@link WriteLimitReachedException}, which we + * treat as a successful (truncated) extract rather than a failure.
  • + *
  • Tika 3.x has built-in zip-bomb defenses on its zip readers (POI's + * {@code ZipSecureFile}); we don't disable them.
  • + *
  • Any other parse failure returns {@code null} so the caller can fall + * through to its existing structured-error path.
  • + *
+ * + * The extractor is deliberately stateless and synchronous: callers drive + * concurrency externally. + */ +@Slf4j +public final class TikaExtractor { + + /** + * Reasonable default for a single-document parse. 5MB of text is + * well above any source we'd actually feed into the wiki pipeline, + * and well below what would OOM a typical desktop install. + */ + public static final int DEFAULT_MAX_CHARS = 5_000_000; + + private TikaExtractor() {} + + /** Extract with the default cap. */ + public static String extract(Path path) { + return extract(path, DEFAULT_MAX_CHARS); + } + + /** + * Extract text from {@code path} using Tika's {@link AutoDetectParser}, + * capping output at {@code maxChars}. Returns the extracted text on + * success (possibly truncated), or {@code null} on any failure. + */ + public static String extract(Path path, int maxChars) { + if (path == null) return null; + if (!Files.isRegularFile(path)) { + log.debug("[Tika] Path is not a regular file: {}", path); + return null; + } + int cap = maxChars <= 0 ? DEFAULT_MAX_CHARS : maxChars; + + BodyContentHandler handler = new BodyContentHandler(cap); + AutoDetectParser parser = new AutoDetectParser(); + Metadata metadata = new Metadata(); + ParseContext context = new ParseContext(); + + try (InputStream is = Files.newInputStream(path)) { + parser.parse(is, handler, metadata, context); + return handler.toString(); + } catch (WriteLimitReachedException truncated) { + // Cap hit — Tika filled the handler before parsing finished. The + // partial text is still useful, especially since callers will chunk + // anyway and only want the leading prose for routing/embedding. + String partial = handler.toString(); + log.info("[Tika] Output cap reached at {} chars for {}; returning partial", + partial.length(), path.getFileName()); + return partial.isBlank() ? null : partial; + } catch (Throwable t) { + // Catching Throwable on purpose: Tika can throw NoClassDefFoundError / + // LinkageError when an obscure transitive parser is missing on a + // minimal classpath, and that should not crash the extract chain. + log.warn("[Tika] Parse failed for {}: {}", path.getFileName(), t.getMessage()); + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java index 6e64553c..1f199b09 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java @@ -1,11 +1,21 @@ package vip.mate.tool.builtin; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; +import vip.mate.agent.context.ChatOrigin; + /** * 工具执行上下文 — 通过 ThreadLocal 向 @Tool 方法传递执行环境信息 *

* 在 ToolExecutionExecutor.executeSingleTool() 中 set,在 finally 中 clear。 * 视频生成等需要知道 conversationId 的工具从此处获取。 * + *

RFC-063r §2.5 兼容期:执行器会同时填充本 ThreadLocal 和 Spring AI 的 + * {@link ToolContext}(携带 {@link ChatOrigin})。优先读 ToolContext 的工具 + * 调用 {@link #conversationId(ToolContext)} / {@link #username(ToolContext)} + * / {@link #workspaceBasePath(ToolContext)} 等三参重载即可——传入 ctx 不为 + * null 时优先返回 origin 的字段,否则回退到 ThreadLocal。 + * * @author MateClaw Team */ public final class ToolExecutionContext { @@ -47,4 +57,34 @@ public final class ToolExecutionContext { USERNAME.remove(); WORKSPACE_BASE_PATH.remove(); } + + // ===== RFC-063r §2.5: ToolContext-aware accessors ===== + // + // Preferred over the parameter-less variants: read from the explicit + // Spring AI ToolContext (carries ChatOrigin) when available, otherwise + // fall back to the legacy ThreadLocal so legacy paths keep working. + + public static String conversationId(@Nullable ToolContext ctx) { + if (ctx != null) { + String v = ChatOrigin.from(ctx).conversationId(); + if (v != null && !v.isEmpty()) return v; + } + return CONVERSATION_ID.get(); + } + + public static String username(@Nullable ToolContext ctx) { + if (ctx != null) { + String v = ChatOrigin.from(ctx).requesterId(); + if (v != null && !v.isEmpty()) return v; + } + return USERNAME.get(); + } + + public static String workspaceBasePath(@Nullable ToolContext ctx) { + if (ctx != null) { + String v = ChatOrigin.from(ctx).workspaceBasePath(); + if (v != null && !v.isBlank()) return v; + } + return WORKSPACE_BASE_PATH.get(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java index e26e9381..7363f682 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/VideoGenerateTool.java @@ -2,8 +2,10 @@ 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.system.model.SystemSettingsDTO; import vip.mate.system.service.SystemSettingService; @@ -32,6 +34,7 @@ public class VideoGenerateTool { private final SystemSettingService systemSettingService; private final AsyncTaskService asyncTaskService; + @vip.mate.tool.ConcurrencyUnsafe("creates async tasks and persists generated artifacts; provider rate limits also forbid parallel calls") @Tool(description = "视频生成工具,支持以下 action:\n" + "- generate(默认):生成视频。提供 prompt 描述视频内容,可选 aspectRatio/duration/imageUrl/model\n" + "- list:列出所有可用的视频 Provider 及其支持的模型和能力\n" @@ -44,15 +47,18 @@ public class VideoGenerateTool { @ToolParam(description = "视频时长(秒),如 5 或 10,默认 5", required = false) Integer duration, @ToolParam(description = "参考图片 URL(图生视频模式)", required = false) String imageUrl, @ToolParam(description = "指定模型名称(可选)", required = false) String model, - @ToolParam(description = "查询指定任务 ID 的状态(status 模式时使用)", required = false) String taskId + @ToolParam(description = "查询指定任务 ID 的状态(status 模式时使用)", required = false) String taskId, + // RFC-063r §2.5: ToolContext is auto-injected by Spring AI MethodToolCallback + // and explicitly skipped by JsonSchemaGenerator — never visible to the LLM. + @Nullable ToolContext ctx ) { // 路由 action String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase(); return switch (normalizedAction) { case "list" -> handleListAction(); - case "status" -> handleStatusAction(taskId); - default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model); + case "status" -> handleStatusAction(taskId, ctx); + default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model, ctx); }; } @@ -91,8 +97,8 @@ public class VideoGenerateTool { // ==================== action=status ==================== - private String handleStatusAction(String taskId) { - String conversationId = ToolExecutionContext.conversationId(); + private String handleStatusAction(String taskId, @Nullable ToolContext ctx) { + String conversationId = ToolExecutionContext.conversationId(ctx); // 指定 taskId 查询 if (taskId != null && !taskId.isBlank()) { @@ -123,9 +129,9 @@ public class VideoGenerateTool { // ==================== action=generate ==================== private String handleGenerateAction(String prompt, String aspectRatio, Integer duration, - String imageUrl, String model) { - String conversationId = ToolExecutionContext.conversationId(); - String username = ToolExecutionContext.username(); + String imageUrl, String model, @Nullable ToolContext ctx) { + String conversationId = ToolExecutionContext.conversationId(ctx); + String username = ToolExecutionContext.username(ctx); if (conversationId == null || conversationId.isBlank()) { return "错误:无法获取当前会话信息,请重试"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index 34b18559..4083b2f4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -100,6 +100,7 @@ public class WorkspaceMemoryTool { return JSONUtil.toJsonPrettyStr(result); } + @vip.mate.tool.ConcurrencyUnsafe("workspace memory write — concurrent writes to the same file would clobber each other") @Tool(description = """ 创建或覆写指定 Agent 的数据库工作区记忆文件。 适用于把提炼后的长期记忆写入 MEMORY.md,或把原始事件写入 memory/YYYY-MM-DD.md。 @@ -133,6 +134,7 @@ public class WorkspaceMemoryTool { return JSONUtil.toJsonPrettyStr(result); } + @vip.mate.tool.ConcurrencyUnsafe("workspace memory edit — find/replace must serialize per file") @Tool(description = """ 通过精确查找替换编辑指定 Agent 的数据库工作区记忆文件。 适用于在 MEMORY.md 的某个 section 中做增量更新,避免整篇重写。 diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java index 6c3de390..352cda98 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java @@ -33,6 +33,7 @@ public class WriteFileTool { private final vip.mate.i18n.I18nService i18n; + @vip.mate.tool.ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths") @Tool(description = "Write content to a file. Overwrites if exists, creates if not (auto-creates parent directories). " + "Returns structured JSON with filePath, bytesWritten. " + "Requires user approval.") 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 new file mode 100644 index 00000000..e7bd8547 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -0,0 +1,69 @@ +package vip.mate.tool.document; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory cache of bytes produced by tools (e.g. {@code DocxRenderTool}) and + * served by {@link GeneratedFileController}. Entries expire after {@link #TTL} + * and are evicted lazily on every {@link #put} call. + * + *

The cache is process-local and intentionally not persisted: a JVM restart + * invalidates all outstanding download links. The download URL embeds a random + * {@link UUID}, which acts as the only access credential. + */ +@Slf4j +@Component +public class GeneratedFileCache { + + public static final Duration TTL = Duration.ofMinutes(10); + + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + public record Entry(byte[] bytes, String filename, String mimeType, long expireAt) { + + public boolean expired() { + return System.currentTimeMillis() > expireAt; + } + } + + /** + * Store the given bytes and return a fresh, unguessable identifier. + * Callers should embed the id in a URL of the form + * {@code /api/v1/files/generated/{id}}. + */ + public String put(byte[] bytes, String filename, String mimeType) { + evictExpired(); + String id = UUID.randomUUID().toString(); + long expireAt = System.currentTimeMillis() + TTL.toMillis(); + entries.put(id, new Entry(bytes, filename, mimeType, expireAt)); + log.debug("Cached generated file id={} filename={} bytes={}", id, filename, bytes.length); + return id; + } + + /** + * Look up an entry. Returns {@link Optional#empty()} if missing or expired + * (expired entries are removed as a side-effect). + */ + public Optional get(String id) { + Entry entry = entries.get(id); + if (entry == null) { + return Optional.empty(); + } + if (entry.expired()) { + entries.remove(id, entry); + return Optional.empty(); + } + return Optional.of(entry); + } + + private void evictExpired() { + long now = System.currentTimeMillis(); + entries.entrySet().removeIf(e -> e.getValue().expireAt() <= now); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java new file mode 100644 index 00000000..acd1e3d0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -0,0 +1,59 @@ +package vip.mate.tool.document; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * Serves bytes produced by tools and stashed in {@link GeneratedFileCache}. + * + *

Endpoint is intentionally unauthenticated; the UUID in the URL is the only + * access credential. Entries expire after {@link GeneratedFileCache#TTL}. + */ +@Tag(name = "Generated Files") +@RestController +@RequestMapping("/api/v1/files/generated") +@RequiredArgsConstructor +public class GeneratedFileController { + + private final GeneratedFileCache cache; + + @Operation(summary = "Download a tool-generated file by its one-time id") + @GetMapping("/{id}") + public ResponseEntity download(@PathVariable String id) { + return cache.get(id) + .>map(entry -> { + String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8) + .replace("+", "%20"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(entry.mimeType())); + // RFC 5987 filename* lets non-ASCII names round-trip in browsers. + headers.add(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + sanitizeAscii(entry.filename()) + + "\"; filename*=UTF-8''" + encodedName); + headers.setContentLength(entry.bytes().length); + return ResponseEntity.ok().headers(headers).body(entry.bytes()); + }) + .orElseGet(() -> ResponseEntity.status(404) + .body(Map.of("error", "File not found or expired"))); + } + + private String sanitizeAscii(String name) { + StringBuilder sb = new StringBuilder(name.length()); + for (char c : name.toCharArray()) { + sb.append(c < 0x20 || c >= 0x7F || c == '"' || c == '\\' ? '_' : c); + } + return sb.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownDocxRenderer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownDocxRenderer.java new file mode 100644 index 00000000..3d957f68 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/MarkdownDocxRenderer.java @@ -0,0 +1,487 @@ +package vip.mate.tool.document; + +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.UnderlinePatterns; +import org.apache.poi.xwpf.usermodel.XWPFAbstractNum; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFNumbering; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrGeneral; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd; +import org.apache.batik.transcoder.TranscoderInput; +import org.apache.batik.transcoder.TranscoderOutput; +import org.apache.batik.transcoder.image.PNGTranscoder; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.Document; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +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.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Render a Markdown string into a Word .docx byte array using Apache POI, + * entirely in-process. Replaces the docx-js Node.js subprocess used by + * {@code skills/docx} for the "create new document" code path. + * + *

Supported elements: ATX headings (# / ## / ###), bold (**...**), + * bullet lists (- / *), numbered lists (1. / 2. ...), pipe-style tables, and + * plain paragraphs. Empty Markdown lines are preserved as empty paragraphs. + * + *

Inline parsing is intentionally minimal: only **bold** is recognized. + * For more advanced layouts (images, headers/footers, exact OOXML edits), the + * {@code skills/docx} unpack/edit/pack workflow remains the right choice. + */ +@Slf4j +@Component +public class MarkdownDocxRenderer { + + /** Matches **bold** spans (non-greedy, refuses empty content). */ + private static final Pattern BOLD = Pattern.compile("\\*\\*(.+?)\\*\\*"); + + private static final Pattern UNORDERED_ITEM = Pattern.compile("^\\s*[-*]\\s+(.*)$"); + private static final Pattern ORDERED_ITEM = Pattern.compile("^\\s*\\d+\\.\\s+(.*)$"); + private static final Pattern TABLE_SEPARATOR = Pattern.compile("^\\s*\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?\\s*$"); + + /** + * Image-only line, e.g. {@code ![alt text](path/to/file.png)}. Whitespace around + * the syntax is allowed but inline images mixed with other text in the same + * paragraph are intentionally NOT recognized — they would require splitting + * a single paragraph into multiple POI runs with image positioning that the + * markdown subset doesn't otherwise support. + */ + private static final Pattern IMAGE_LINE = Pattern.compile( + "^\\s*!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)\\s*$"); + + /** + * Page width in EMU after default A4 margins (page width 11906 twips - left + * 1800 - right 1800 = 8306 twips ≈ 5.77 inches). POI's image API works in EMU + * (1 inch = 914400 EMU); precomputing the maximum width keeps oversized images + * from spilling outside the printable area while still allowing small images + * to render at native size. + */ + private static final int MAX_IMAGE_WIDTH_EMU = Units.toEMU(5.77 * 72); + + private static final String LATIN_FONT = "Arial"; + private static final String CJK_BODY_FONT = "FangSong"; // 仿宋 + private static final String CJK_HEADING_FONT = "SimHei"; // 黑体 + + public byte[] render(String markdown, String pageSize) throws IOException { + try (XWPFDocument doc = new XWPFDocument()) { + configurePageSize(doc, pageSize); + BigInteger bulletNumId = configureNumbering(doc, true); + BigInteger decimalNumId = configureNumbering(doc, false); + + List lines = splitLines(markdown == null ? "" : markdown); + int i = 0; + while (i < lines.size()) { + String line = lines.get(i); + String stripped = line.strip(); + + // Table block: header line + separator + body rows + if (stripped.startsWith("|") && i + 1 < lines.size() + && TABLE_SEPARATOR.matcher(lines.get(i + 1)).matches()) { + int end = i + 2; + while (end < lines.size() && lines.get(end).strip().startsWith("|")) { + end++; + } + renderTable(doc, lines.subList(i, end)); + i = end; + continue; + } + + Matcher imageMatch = IMAGE_LINE.matcher(line); + if (imageMatch.matches()) { + renderImage(doc, imageMatch.group(1), imageMatch.group(2)); + } else if (stripped.startsWith("### ")) { + renderHeading(doc, stripped.substring(4), 3); + } else if (stripped.startsWith("## ")) { + renderHeading(doc, stripped.substring(3), 2); + } else if (stripped.startsWith("# ")) { + renderHeading(doc, stripped.substring(2), 1); + } else { + Matcher ul = UNORDERED_ITEM.matcher(line); + Matcher ol = ORDERED_ITEM.matcher(line); + if (ul.matches()) { + renderListItem(doc, ul.group(1), bulletNumId); + } else if (ol.matches()) { + renderListItem(doc, ol.group(1), decimalNumId); + } else if (stripped.isEmpty()) { + doc.createParagraph(); + } else { + renderParagraph(doc, line); + } + } + i++; + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.write(baos); + return baos.toByteArray(); + } + } + + // ==================== page setup ==================== + + private void configurePageSize(XWPFDocument doc, String pageSize) { + CTSectPr sectPr = doc.getDocument().getBody().isSetSectPr() + ? doc.getDocument().getBody().getSectPr() + : doc.getDocument().getBody().addNewSectPr(); + + CTPageSz pgSz = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz(); + boolean letter = pageSize != null && pageSize.equalsIgnoreCase("LETTER"); + if (letter) { + pgSz.setW(BigInteger.valueOf(12240)); + pgSz.setH(BigInteger.valueOf(15840)); + } else { + // A4 default + pgSz.setW(BigInteger.valueOf(11906)); + pgSz.setH(BigInteger.valueOf(16838)); + } + + CTPageMar pgMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); + pgMar.setTop(BigInteger.valueOf(1440)); + pgMar.setBottom(BigInteger.valueOf(1440)); + pgMar.setLeft(BigInteger.valueOf(1800)); + pgMar.setRight(BigInteger.valueOf(1800)); + pgMar.setHeader(BigInteger.valueOf(720)); + pgMar.setFooter(BigInteger.valueOf(720)); + pgMar.setGutter(BigInteger.ZERO); + } + + // ==================== numbering ==================== + + private BigInteger configureNumbering(XWPFDocument doc, boolean bullet) { + XWPFNumbering numbering = doc.createNumbering(); + CTAbstractNum abstractNum = CTAbstractNum.Factory.newInstance(); + // Temporary id; XWPFAbstractNum will assign the real one when added. + abstractNum.setAbstractNumId(BigInteger.ZERO); + + CTLvl lvl = abstractNum.addNewLvl(); + lvl.setIlvl(BigInteger.ZERO); + lvl.addNewStart().setVal(BigInteger.ONE); + if (bullet) { + lvl.addNewNumFmt().setVal(STNumberFormat.BULLET); + lvl.addNewLvlText().setVal("•"); + } else { + lvl.addNewNumFmt().setVal(STNumberFormat.DECIMAL); + lvl.addNewLvlText().setVal("%1."); + } + CTPPrGeneral ppr = lvl.addNewPPr(); + CTInd ind = ppr.addNewInd(); + ind.setLeft(BigInteger.valueOf(720)); + ind.setHanging(BigInteger.valueOf(360)); + + XWPFAbstractNum xwpfAbstractNum = new XWPFAbstractNum(abstractNum); + BigInteger absNumId = numbering.addAbstractNum(xwpfAbstractNum); + return numbering.addNum(absNumId); + } + + // ==================== headings & paragraphs ==================== + + private void renderHeading(XWPFDocument doc, String text, int level) { + XWPFParagraph p = doc.createParagraph(); + p.setStyle("Heading" + level); + // Spacing before/after, in twentieths of a point. + switch (level) { + case 1 -> { p.setSpacingBefore(240); p.setSpacingAfter(120); } + case 2 -> { p.setSpacingBefore(160); p.setSpacingAfter(80); } + default -> { p.setSpacingBefore(120); p.setSpacingAfter(60); } + } + renderInline(p, text, true, level); + } + + private void renderParagraph(XWPFDocument doc, String text) { + XWPFParagraph p = doc.createParagraph(); + p.setAlignment(ParagraphAlignment.LEFT); + renderInline(p, text, false, 0); + } + + private void renderListItem(XWPFDocument doc, String text, BigInteger numId) { + XWPFParagraph p = doc.createParagraph(); + p.setNumID(numId); + renderInline(p, text, false, 0); + } + + // ==================== images ==================== + + /** + * Render an {@code ![alt](path)} line as an embedded image. Falls back to + * showing the alt text in italics on any failure (file missing, unsupported + * format, SVG conversion error) so the rest of the document still renders. + *

+ * Path resolution: the markdown is treated as living in the workspace root, + * so a path like {@code assets/x.png} resolves relative to the JVM working + * directory. Absolute paths are accepted as-is. {@code .svg} files are + * rasterized to PNG via Apache Batik before embedding because OOXML images + * must be a raster format. + */ + private void renderImage(XWPFDocument doc, String alt, String rawPath) { + XWPFParagraph p = doc.createParagraph(); + p.setAlignment(ParagraphAlignment.CENTER); + XWPFRun run = p.createRun(); + + Path path; + try { + path = Paths.get(rawPath); + if (!path.isAbsolute()) { + path = Paths.get(".").resolve(rawPath).normalize(); + } + } catch (Exception e) { + renderImageFallback(run, alt, "invalid path: " + e.getMessage()); + return; + } + + if (!Files.exists(path) || !Files.isReadable(path)) { + renderImageFallback(run, alt, "file not found: " + path); + return; + } + + String lower = path.getFileName().toString().toLowerCase(Locale.ROOT); + int format; + byte[] imageBytes; + try { + if (lower.endsWith(".svg")) { + imageBytes = svgToPng(Files.readAllBytes(path)); + format = Document.PICTURE_TYPE_PNG; + } else if (lower.endsWith(".png")) { + imageBytes = Files.readAllBytes(path); + format = Document.PICTURE_TYPE_PNG; + } else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { + imageBytes = Files.readAllBytes(path); + format = Document.PICTURE_TYPE_JPEG; + } else if (lower.endsWith(".gif")) { + imageBytes = Files.readAllBytes(path); + format = Document.PICTURE_TYPE_GIF; + } else if (lower.endsWith(".bmp")) { + imageBytes = Files.readAllBytes(path); + format = Document.PICTURE_TYPE_BMP; + } else { + renderImageFallback(run, alt, "unsupported image format: " + lower); + return; + } + } catch (Exception e) { + log.warn("[MarkdownDocxRenderer] failed to read image {}: {}", path, e.getMessage()); + renderImageFallback(run, alt, "read failed: " + e.getMessage()); + return; + } + + // Choose width: scale to MAX_IMAGE_WIDTH_EMU. POI's addPicture expects + // EMU; we don't know the source image's intrinsic size cheaply, so + // pin width and let height scale proportionally via height=0 → POI + // does not infer height for us, so use a reasonable height ratio + // (4:3 default) to avoid stretching extremely wide diagrams. + int width = MAX_IMAGE_WIDTH_EMU; + int height = (int) (MAX_IMAGE_WIDTH_EMU * 0.6); + try (ByteArrayInputStream in = new ByteArrayInputStream(imageBytes)) { + run.addPicture(in, format, path.getFileName().toString(), width, height); + } catch (Exception e) { + log.warn("[MarkdownDocxRenderer] addPicture failed for {}: {}", + path, e.getMessage()); + renderImageFallback(run, alt, "embed failed: " + e.getMessage()); + } + } + + /** + * Convert an SVG byte array to PNG using Batik's PNGTranscoder. Width is + * pinned so the rasterized output matches the docx page-width target; + * height scales proportionally per the SVG's own viewBox. + */ + private byte[] svgToPng(byte[] svgBytes) throws IOException { + PNGTranscoder t = new PNGTranscoder(); + // Roughly 1400px wide → renders crisply at our docx target width. + t.addTranscodingHint(PNGTranscoder.KEY_WIDTH, 1400f); + TranscoderInput input = new TranscoderInput(new ByteArrayInputStream(svgBytes)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + TranscoderOutput output = new TranscoderOutput(out); + try { + t.transcode(input, output); + } catch (Exception e) { + throw new IOException("SVG transcode failed: " + e.getMessage(), e); + } + return out.toByteArray(); + } + + private void renderImageFallback(XWPFRun run, String alt, String reason) { + run.setItalic(true); + run.setText("[image: " + (alt == null || alt.isBlank() ? "(no alt)" : alt) + + " — " + reason + "]"); + } + + // ==================== inline (bold) ==================== + + private void renderInline(XWPFParagraph p, String text, boolean heading, int headingLevel) { + if (text == null || text.isEmpty()) { + // Make sure even empty headings still produce a run so style applies. + createRun(p, "", heading, headingLevel, false); + return; + } + Matcher m = BOLD.matcher(text); + int last = 0; + while (m.find()) { + if (m.start() > last) { + createRun(p, text.substring(last, m.start()), heading, headingLevel, false); + } + createRun(p, m.group(1), heading, headingLevel, true); + last = m.end(); + } + if (last < text.length()) { + createRun(p, text.substring(last), heading, headingLevel, false); + } + } + + private void createRun(XWPFParagraph p, String text, boolean heading, int headingLevel, boolean bold) { + XWPFRun run = p.createRun(); + run.setText(text); + run.setUnderline(UnderlinePatterns.NONE); + + // Font sizes per RFC §4.2. + int halfPoints; + if (heading) { + halfPoints = switch (headingLevel) { + case 1 -> 40; // 20pt + case 2 -> 32; // 16pt + default -> 28; // 14pt + }; + run.setBold(true); + } else { + halfPoints = 24; // 12pt + run.setBold(bold); + } + run.setFontSize(halfPoints / 2); + + // Latin + East-Asian fonts. Each run is freshly created, so we always + // append a brand new child rather than try to reuse one. + CTRPr rPr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); + CTFonts fonts = rPr.sizeOfRFontsArray() > 0 ? rPr.getRFontsArray(0) : rPr.addNewRFonts(); + fonts.setAscii(LATIN_FONT); + fonts.setHAnsi(LATIN_FONT); + fonts.setCs(LATIN_FONT); + fonts.setEastAsia(heading ? CJK_HEADING_FONT : CJK_BODY_FONT); + } + + // ==================== tables ==================== + + private void renderTable(XWPFDocument doc, List tableLines) { + List rows = new ArrayList<>(tableLines.size()); + for (int idx = 0; idx < tableLines.size(); idx++) { + if (idx == 1) continue; // skip the |---|---| separator + rows.add(parseTableRow(tableLines.get(idx))); + } + if (rows.isEmpty()) return; + + int cols = 0; + for (String[] row : rows) cols = Math.max(cols, row.length); + + XWPFTable table = doc.createTable(rows.size(), cols); + styleTableBorders(table); + + for (int r = 0; r < rows.size(); r++) { + String[] cells = rows.get(r); + XWPFTableRow row = table.getRow(r); + for (int c = 0; c < cols; c++) { + XWPFTableCell cell = row.getCell(c); + String value = c < cells.length ? cells[c] : ""; + + // POI auto-creates an empty paragraph in each new cell — reuse it. + cell.removeParagraph(0); + XWPFParagraph p = cell.addParagraph(); + renderInline(p, value, false, 0); + + if (r == 0) { + shadeHeaderCell(cell); + for (XWPFRun run : p.getRuns()) { + run.setBold(true); + } + } + } + } + } + + private String[] parseTableRow(String line) { + String trimmed = line.strip(); + if (trimmed.startsWith("|")) trimmed = trimmed.substring(1); + if (trimmed.endsWith("|")) trimmed = trimmed.substring(0, trimmed.length() - 1); + String[] parts = trimmed.split("\\|", -1); + for (int i = 0; i < parts.length; i++) { + parts[i] = parts[i].strip(); + } + return parts; + } + + private void styleTableBorders(XWPFTable table) { + CTTblPr tblPr = table.getCTTbl().getTblPr() != null + ? table.getCTTbl().getTblPr() + : table.getCTTbl().addNewTblPr(); + CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders(); + applyBorder(borders.isSetTop() ? borders.getTop() : borders.addNewTop()); + applyBorder(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom()); + applyBorder(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft()); + applyBorder(borders.isSetRight() ? borders.getRight() : borders.addNewRight()); + applyBorder(borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH()); + applyBorder(borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV()); + } + + private void applyBorder(CTBorder border) { + border.setVal(STBorder.SINGLE); + border.setSz(BigInteger.valueOf(4)); + border.setColor("999999"); + } + + private void shadeHeaderCell(XWPFTableCell cell) { + CTTcPr tcPr = cell.getCTTc().getTcPr() != null ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); + CTShd shd = tcPr.isSetShd() ? tcPr.getShd() : tcPr.addNewShd(); + shd.setVal(STShd.CLEAR); + shd.setColor("auto"); + shd.setFill("E0E0E0"); + } + + // ==================== utils ==================== + + private List splitLines(String text) { + List out = new ArrayList<>(); + int start = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\n') { + int end = i; + if (end > start && text.charAt(end - 1) == '\r') end--; + out.add(text.substring(start, end)); + start = i + 1; + } + } + if (start <= text.length()) { + out.add(text.substring(start)); + } + return out; + } +} 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 0b2d8b51..d6112d7d 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 @@ -1,6 +1,9 @@ package vip.mate.tool.guard; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; +import vip.mate.agent.context.ChatOrigin; import vip.mate.tool.builtin.ToolExecutionContext; import java.io.IOException; @@ -33,9 +36,19 @@ public final class WorkspacePathGuard { * @throws IllegalArgumentException 路径不在允许范围内 */ public static Path validatePath(String rawPath) { + return validatePath(rawPath, null); + } + + /** + * RFC-063r §2.5: ToolContext-aware overload. Reads the workspace base path + * from the explicit {@link ChatOrigin} when present; falls back to the + * legacy {@link ToolExecutionContext} ThreadLocal during the PR-1 + * transition window. + */ + public static Path validatePath(String rawPath, @Nullable ToolContext ctx) { Path normalized = Paths.get(rawPath).toAbsolutePath().normalize(); - String basePath = ToolExecutionContext.workspaceBasePath(); + String basePath = resolveBasePath(ctx); if (basePath == null || basePath.isBlank()) { return normalized; // 未配置活动目录,不限制 } @@ -72,10 +85,35 @@ public final class WorkspacePathGuard { * @return 活动目录 Path,未配置时返回 null */ public static Path getWorkingDirectory() { - String basePath = ToolExecutionContext.workspaceBasePath(); + return getWorkingDirectory(null); + } + + /** + * RFC-063r §2.5: ToolContext-aware variant — prefer the explicit + * {@link ChatOrigin} workspaceBasePath when available. + */ + public static Path getWorkingDirectory(@Nullable ToolContext ctx) { + String basePath = resolveBasePath(ctx); if (basePath == null || basePath.isBlank()) { return null; } return Paths.get(basePath).toAbsolutePath().normalize(); } + + /** + * Resolve the active workspace base path. Order of preference: + *

    + *
  1. ChatOrigin from ToolContext (RFC-063r §2.5)
  2. + *
  3. Legacy {@link ToolExecutionContext} ThreadLocal (PR-1 transition)
  4. + *
+ */ + private static String resolveBasePath(@Nullable ToolContext ctx) { + if (ctx != null) { + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) { + return origin.workspaceBasePath(); + } + } + return ToolExecutionContext.workspaceBasePath(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java index 68f3226b..38b5290d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java @@ -1,20 +1,25 @@ package vip.mate.tool.guard.guardian; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; import vip.mate.tool.guard.model.*; import java.util.List; import java.util.Set; /** - * 文件写入守卫 - *

- * 标记写文件/编辑文件操作为 MEDIUM 风险。 - * 最终是否需要审批由 ToolPolicyResolver 决定。 + * File-write guardian — historically marked every write_file / edit_file call + * as MEDIUM risk and forced an approval popup. Disabled (no @Component) + * because in-workspace writes are already path-bounded by + * {@code FilePathGuardian} + {@code WorkspacePathGuard.validatePath()}, and + * the per-call approval prompt drove operators to give up on multi-file + * workflows (a 22-chapter docx generation = 22 popups). The class is kept on + * disk for two reasons: (1) re-enabling guardian-level write approval is a + * one-line `@Component` change if a deployment really wants it, (2) it + * documents the historical behavior for anyone diffing why approval suddenly + * stopped firing on write_file. The mate_tool_guard_config row's + * guarded_tools_json was narrowed to {@code execute_shell_command} in V51. */ @Slf4j -@Component public class FileWriteGuardian implements ToolGuardGuardian { private static final Set FILE_WRITE_TOOL_NAMES = Set.of( diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java index 972fa661..debb6bf0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java @@ -33,6 +33,5 @@ public class ToolGuardAuditLogEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java index 7dd2cefb..7678bbe2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardRuleEntity.java @@ -36,6 +36,5 @@ public class ToolGuardRuleEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java index e3f61c96..371dc195 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/MiniMaxImageProvider.java @@ -20,9 +20,9 @@ import java.util.Set; * MiniMax 图片生成 Provider — image-01 模型 *

* 同步模式:返回 Base64 图片。 - * 复用视频生成中的 MiniMax API Key。 + * 复用视频生成中的 MiniMax API Key + region 设置({@code minimaxRegion})。 *

- * API: POST https://api.minimax.io/v1/image_generation + * API: POST {@code /v1/image_generation} —— host 由 region 决定。 * * @author MateClaw Team */ @@ -33,9 +33,30 @@ public class MiniMaxImageProvider implements ImageGenerationProvider { private final ObjectMapper objectMapper; - private static final String BASE_URL = "https://api.minimax.io"; + /** Global endpoint. */ + static final String BASE_URL_GLOBAL = "https://api.minimax.io"; + + /** China endpoint (mainland-CN low-latency host; same JSON shape). */ + static final String BASE_URL_CN = "https://api.minimaxi.com"; + + /** Region value selecting the CN endpoint. Anything else → global. */ + static final String REGION_CN = "cn"; + private static final String DEFAULT_MODEL = "image-01"; + /** + * Resolve MiniMax base URL from system settings region. Shared semantics + * with {@code MiniMaxVideoProvider.resolveBaseUrl} (single field controls + * both image + video routing because the API key is the same). + * Package-private for unit tests. + */ + static String resolveBaseUrl(SystemSettingsDTO config) { + if (config != null && REGION_CN.equalsIgnoreCase(config.getMinimaxRegion())) { + return BASE_URL_CN; + } + return BASE_URL_GLOBAL; + } + @Override public String id() { return "minimax"; @@ -96,7 +117,8 @@ public class MiniMaxImageProvider implements ImageGenerationProvider { body.put("aspect_ratio", request.getAspectRatio()); } - HttpResponse response = HttpRequest.post(BASE_URL + "/v1/image_generation") + String baseUrl = resolveBaseUrl(config); + HttpResponse response = HttpRequest.post(baseUrl + "/v1/image_generation") .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .body(body.toString()) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java index 21e46bc9..151f53b1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/OpenAiImageProvider.java @@ -17,11 +17,17 @@ import java.util.List; import java.util.Set; /** - * OpenAI 图片生成 Provider — 支持 DALL-E 3 / DALL-E 2 / gpt-image-1 + * OpenAI 图片生成 Provider + * — 支持 DALL-E 3 / DALL-E 2 / gpt-image-1 / gpt-image-2 (low/medium/high) *

- * 同步模式:直接返回图片 URL。 + * 同步模式:返回图片 URL(DALL-E 系列)或 base64 data URL(gpt-image-2 系列)。 * 复用已有的 OpenAI LLM provider 的 API Key。 * + *

gpt-image-2 三档质量做成 3 个虚拟 model ID(参考 hermes-agent + * plugins/image_gen/openai/__init__.py 的 model catalog 设计),让 picker + * 能直接选 fast/balanced/high。三档底层都打到 API model {@code "gpt-image-2"}, + * 区别仅在 {@code quality} 参数。 + * * @author MateClaw Team */ @Slf4j @@ -34,6 +40,20 @@ public class OpenAiImageProvider implements ImageGenerationProvider { private static final String DEFAULT_MODEL = "dall-e-3"; + /** + * gpt-image-2 真实 API model 名。三档虚拟 ID(gpt-image-2-low/medium/high) + * 在 submit 时全部打到这个 API model + 不同 quality 参数。 + */ + private static final String GPT_IMAGE_2_API_MODEL = "gpt-image-2"; + + /** gpt-image-2 系列虚拟 ID 列表 — 用于 capabilities 与分支判定。 */ + private static final List GPT_IMAGE_2_TIERS = + List.of("gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"); + + /** gpt-image-2 支持的尺寸(与 DALL-E 不同!1536x1024 / 1024x1024 / 1024x1536)。 */ + private static final List GPT_IMAGE_2_SIZES = + List.of("1024x1024", "1536x1024", "1024x1536"); + @Override public String id() { return "openai"; @@ -61,13 +81,28 @@ public class OpenAiImageProvider implements ImageGenerationProvider { @Override public ImageProviderCapabilities detailedCapabilities() { + // 合并 DALL-E 与 gpt-image-2 两套尺寸(去重)。运行时按选定 model + // 做尺寸校验,picker 只展示并集即可。 + List allSizes = new ArrayList<>(); + allSizes.add("1024x1024"); + allSizes.add("1024x1792"); // dall-e + allSizes.add("1792x1024"); // dall-e + allSizes.add("1024x1536"); // gpt-image-2 + allSizes.add("1536x1024"); // gpt-image-2 + + List models = new ArrayList<>(); + models.add("dall-e-3"); + models.add("dall-e-2"); + models.add("gpt-image-1"); + models.addAll(GPT_IMAGE_2_TIERS); // gpt-image-2-low/medium/high + return ImageProviderCapabilities.builder() .modes(capabilities()) - .supportedSizes(List.of("1024x1024", "1024x1792", "1792x1024")) + .supportedSizes(allSizes) .aspectRatios(List.of("1:1", "9:16", "16:9")) - .maxCount(1) // DALL-E 3 只支持 n=1 + .maxCount(1) // DALL-E 3 / gpt-image-2 都只支持 n=1 .defaultModel(DEFAULT_MODEL) - .models(List.of("dall-e-3", "dall-e-2", "gpt-image-1")) + .models(models) .build(); } @@ -89,15 +124,26 @@ public class OpenAiImageProvider implements ImageGenerationProvider { } try { - String model = request.getModel() != null && !request.getModel().isBlank() + String requestedModel = request.getModel() != null && !request.getModel().isBlank() ? request.getModel() : DEFAULT_MODEL; + // gpt-image-2 系列:三档虚拟 ID 全部打到 API model "gpt-image-2" + // + 对应 quality 参数;DALL-E 系列保留原行为。 + boolean isGptImage2 = GPT_IMAGE_2_TIERS.contains(requestedModel); ObjectNode body = objectMapper.createObjectNode(); - body.put("model", model); + body.put("model", isGptImage2 ? GPT_IMAGE_2_API_MODEL : requestedModel); body.put("prompt", request.getPrompt()); - body.put("size", normalizeSize(request.getSize(), request.getAspectRatio())); + body.put("size", normalizeSize(request.getSize(), request.getAspectRatio(), isGptImage2)); body.put("n", 1); - body.put("response_format", "url"); + + if (isGptImage2) { + // gpt-image-2 强制 b64_json,且 REJECT 任何 response_format 字段 + // (API 会以 unknown parameter 报错)。仅传 quality。 + body.put("quality", qualityForTier(requestedModel)); + } else { + // DALL-E 系列保留 URL 模式。 + body.put("response_format", "url"); + } String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/images/generations"; @@ -105,7 +151,8 @@ public class OpenAiImageProvider implements ImageGenerationProvider { .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .body(body.toString()) - .timeout(60_000) + // gpt-image-2 high 档官方文档约 ~2min;这里给到 180s 留余地 + .timeout(isGptImage2 ? 180_000 : 60_000) .execute(); JsonNode result = objectMapper.readTree(response.body()); @@ -113,15 +160,28 @@ public class OpenAiImageProvider implements ImageGenerationProvider { if (response.getStatus() == 200 && result.has("data")) { List imageUrls = new ArrayList<>(); for (JsonNode item : result.get("data")) { - String imageUrl = item.has("url") ? item.get("url").asText() : null; - if (imageUrl != null) { - imageUrls.add(imageUrl); + if (isGptImage2) { + // gpt-image-2 永远返回 b64_json。包成 data URL,交给前端 + // 直接 渲染,沿用 + // GoogleImagenProvider / MiniMaxImageProvider 的现成模式。 + String b64 = item.has("b64_json") ? item.get("b64_json").asText() : null; + if (b64 != null && !b64.isBlank()) { + imageUrls.add("data:image/png;base64," + b64); + } + } else { + String imageUrl = item.has("url") ? item.get("url").asText() : null; + if (imageUrl != null) { + imageUrls.add(imageUrl); + } } } if (imageUrls.isEmpty()) { - return ImageSubmitResult.failure(id(), "API 返回成功但未包含图片 URL"); + return ImageSubmitResult.failure(id(), + isGptImage2 ? "API 返回成功但未包含 b64_json 图片数据" + : "API 返回成功但未包含图片 URL"); } - log.info("[OpenAI Image] Generated {} image(s) (model={})", imageUrls.size(), model); + log.info("[OpenAI Image] Generated {} image(s) (model={})", + imageUrls.size(), requestedModel); return ImageSubmitResult.syncSuccess(id(), imageUrls); } else { String errMsg = result.has("error") @@ -136,6 +196,24 @@ public class OpenAiImageProvider implements ImageGenerationProvider { } } + /** Map gpt-image-2-{low|medium|high} → quality string sent to API. + * Package-private + static for unit testability. */ + static String qualityForTier(String tierModelId) { + return switch (tierModelId) { + case "gpt-image-2-low" -> "low"; + case "gpt-image-2-high" -> "high"; + default -> "medium"; // gpt-image-2-medium + 任何未来 tier 都默认 medium + }; + } + + /** Returns true if the given model id is a gpt-image-2 virtual tier. + * Package-private + static for unit testability. + *

Null-safe: {@code List.of(...).contains(null)} throws NPE, which we + * pre-empt with an explicit null check. */ + static boolean isGptImage2Tier(String modelId) { + return modelId != null && GPT_IMAGE_2_TIERS.contains(modelId); + } + private String getOpenAiApiKey() { try { var providerEntity = modelProviderService.getProviderConfig("openai"); @@ -154,14 +232,34 @@ public class OpenAiImageProvider implements ImageGenerationProvider { } } - private String normalizeSize(String size, String aspectRatio) { - // 优先使用 size - if (size != null && !size.isBlank()) { - List supported = List.of("1024x1024", "1024x1792", "1792x1024"); - if (supported.contains(size)) return size; + /** + * 按 model 选合适的尺寸集合: + *

    + *
  • DALL-E:1024x1024 / 1024x1792 / 1792x1024
  • + *
  • gpt-image-2:1024x1024 / 1024x1536 / 1536x1024(不一样!)
  • + *
+ */ + /** Package-private + static-ish for unit testability. Kept instance-method to + * stay close to the call site — no instance state is touched. */ + String normalizeSize(String size, String aspectRatio, boolean isGptImage2) { + List supported = isGptImage2 + ? GPT_IMAGE_2_SIZES + : List.of("1024x1024", "1024x1792", "1792x1024"); + + // 优先使用 size(如果在该 model 支持范围内) + if (size != null && !size.isBlank() && supported.contains(size)) { + return size; } - // 根据 aspectRatio 推断 + + // 根据 aspectRatio 推断(gpt-image-2 与 DALL-E 的竖图/横图尺寸不一样) if (aspectRatio != null) { + if (isGptImage2) { + return switch (aspectRatio) { + case "9:16", "2:3", "3:4" -> "1024x1536"; + case "16:9", "3:2", "4:3" -> "1536x1024"; + default -> "1024x1024"; + }; + } return switch (aspectRatio) { case "9:16" -> "1024x1792"; case "16:9" -> "1792x1024"; @@ -170,4 +268,10 @@ public class OpenAiImageProvider implements ImageGenerationProvider { } return "1024x1024"; } + + // 保留旧签名给可能存在的其它 caller(向后兼容)。新增 boolean 默认 false (DALL-E)。 + @SuppressWarnings("unused") + private String normalizeSize(String size, String aspectRatio) { + return normalizeSize(size, aspectRatio, false); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java index 9edce83d..1e0bd228 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java @@ -77,6 +77,5 @@ public class McpServerEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java new file mode 100644 index 00000000..6f83f4b9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java @@ -0,0 +1,52 @@ +package vip.mate.tool.mcp.runtime; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * RFC-052 §3.4 / PR-4: MCP tool return-direct opt-in list. + * + *

Tools listed here are wrapped in {@link ReturnDirectMcpToolCallback} so + * their results bypass the LLM context (see {@code ToolExecutionExecutor} and + * {@code ObservationDispatcher} for the routing). + * + *

Configuration ({@code application.yml}): + *

+ * mateclaw:
+ *   mcp:
+ *     return-direct:
+ *       tools:
+ *         - query_employee_salary
+ *         - read_medical_record
+ * 
+ * + *

Match is by tool name only (matching the upstream {@code ToolDefinition.name()}). + * Per-server scoping is intentionally out of scope for the first iteration; if + * the same tool name comes from two servers and only one should be direct, give + * one of them a name prefix at the MCP server config layer. + * + * @author MateClaw Team + */ +@Component +@ConfigurationProperties(prefix = "mateclaw.mcp.return-direct") +public class McpReturnDirectProperties { + + /** Tool names that should be treated as returnDirect. */ + private Set tools = Collections.emptySet(); + + public Set getTools() { + return tools; + } + + public void setTools(Set tools) { + this.tools = tools != null ? new LinkedHashSet<>(tools) : Collections.emptySet(); + } + + public boolean isReturnDirect(String toolName) { + return toolName != null && tools.contains(toolName); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java index 3a00897b..4a8496ea 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java @@ -6,6 +6,9 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; + /** * MCP 工具回调提供者 *

@@ -15,6 +18,9 @@ import org.springframework.stereotype.Component; * 每次调用 getToolCallbacks() 都会从 McpClientManager 获取最新的 active tools, * 因此新增/删除 MCP server 后无需重启即可生效。 * + *

RFC-052: tools listed in {@link McpReturnDirectProperties} are wrapped in + * {@link ReturnDirectMcpToolCallback} so their results bypass the LLM context. + * * @author MateClaw Team */ @Slf4j @@ -23,6 +29,7 @@ import org.springframework.stereotype.Component; public class McpToolCallbackProvider implements ToolCallbackProvider { private final McpClientManager mcpClientManager; + private final McpReturnDirectProperties returnDirectProperties; @Override public ToolCallback[] getToolCallbacks() { @@ -32,7 +39,21 @@ public class McpToolCallbackProvider implements ToolCallbackProvider { log.debug("McpToolCallbackProvider providing {} tools from {} active MCP servers", callbacks.size(), mcpClientManager.getActiveCount()); } - return callbacks.toArray(new ToolCallback[0]); + + // RFC-052: opt-in returnDirect wrapping. The decorator only changes + // ToolMetadata.returnDirect(); guard/approval/observability still + // see the original callback through the wrapper. + List wrapped = new ArrayList<>(callbacks.size()); + for (ToolCallback cb : callbacks) { + String name = cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null; + if (returnDirectProperties.isReturnDirect(name)) { + log.info("[McpToolCallbackProvider] wrapping MCP tool '{}' as returnDirect (RFC-052)", name); + wrapped.add(new ReturnDirectMcpToolCallback(cb)); + } else { + wrapped.add(cb); + } + } + return wrapped.toArray(new ToolCallback[0]); } catch (Exception e) { log.warn("Failed to collect MCP tool callbacks: {}", e.getMessage()); return new ToolCallback[0]; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java new file mode 100644 index 00000000..e321aa3f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java @@ -0,0 +1,62 @@ +package vip.mate.tool.mcp.runtime; + +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +/** + * RFC-052 §3.4: thin decorator that overrides {@link ToolCallback#getToolMetadata()} + * to report {@code returnDirect=true} for MCP tools. + * + *

Spring AI 1.1.4's {@code SyncMcpToolCallback} / {@code AsyncMcpToolCallback} + * never override {@code getToolMetadata()} (they inherit the framework default + * which yields {@code returnDirect=false}), and the upstream MCP protocol layer + * has no equivalent field. So MateClaw must wrap MCP callbacks at registration + * time when their server+tool config opts in via + * {@code mateclaw.mcp.return-direct.tools}. + * + *

Everything else (definition, invocation, exceptions) is delegated verbatim + * — guard, approval, observability, audit all see the original callback. + * + * @author MateClaw Team + */ +public final class ReturnDirectMcpToolCallback implements ToolCallback { + + private static final ToolMetadata RETURN_DIRECT_METADATA = + ToolMetadata.builder().returnDirect(true).build(); + + private final ToolCallback delegate; + + public ReturnDirectMcpToolCallback(ToolCallback delegate) { + if (delegate == null) { + throw new IllegalArgumentException("delegate must not be null"); + } + this.delegate = delegate; + } + + @Override + public ToolDefinition getToolDefinition() { + return delegate.getToolDefinition(); + } + + @Override + public ToolMetadata getToolMetadata() { + return RETURN_DIRECT_METADATA; + } + + @Override + public String call(String arguments) { + return delegate.call(arguments); + } + + @Override + public String call(String arguments, ToolContext toolContext) { + return delegate.call(arguments, toolContext); + } + + /** Test/diagnostic accessor — not part of the framework contract. */ + public ToolCallback getDelegate() { + return delegate; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java index f2705723..27f08e5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java @@ -55,6 +55,5 @@ public class ToolEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java index a02e48ef..e0e38498 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerateTool.java @@ -2,8 +2,10 @@ package vip.mate.tool.music; 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.tool.builtin.ToolExecutionContext; @@ -23,9 +25,11 @@ public class MusicGenerateTool { public String music_generate( @ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt, @ToolParam(description = "歌词文本(可选,不填则由 AI 生成或生成纯音乐)") String lyrics, - @ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental) { + @ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental, + // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. + @Nullable ToolContext ctx) { - String conversationId = ToolExecutionContext.conversationId(); + String conversationId = ToolExecutionContext.conversationId(ctx); if (conversationId == null) { return "无法获取会话 ID"; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java index ec3c73c1..aa05211e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java @@ -81,24 +81,42 @@ public class SearXNGSearchProvider implements SearchProvider { urlBuilder.append("&time_range=").append(searchQuery.freshness().toLowerCase()); } - String response = HttpUtil.createGet(urlBuilder.toString()) + var resp = HttpUtil.createGet(urlBuilder.toString()) .header("Accept", "application/json") .timeout(15000) - .execute() - .body(); + .execute(); + int status = resp.getStatus(); + String response = resp.body(); + String contentType = resp.header("Content-Type"); - log.debug("SearXNG result for '{}': length={}", searchQuery.query(), response != null ? response.length() : 0); - return parseResponse(response, searchQuery.resolvedCount()); + log.debug("SearXNG response for '{}': status={}, contentType={}, length={}", + searchQuery.query(), status, contentType, response != null ? response.length() : 0); + return parseResponse(response, status, contentType, searchQuery.resolvedCount(), urlBuilder.toString()); } - private List parseResponse(String response, int limit) { + private List parseResponse(String response, int status, String contentType, + int limit, String requestUrl) { List results = new ArrayList<>(); - if (response == null || response.isBlank()) return results; + if (response == null || response.isBlank()) { + log.warn("SearXNG returned empty body (status={}, url={})", status, requestUrl); + return results; + } + if (status >= 400) { + log.warn("SearXNG returned HTTP {} — preview: {}", status, preview(response)); + return results; + } + if (contentType != null && !contentType.contains("json")) { + // Most common cause: settings.yml has no `json` under search.formats + // or the Limiter plugin rewrote the response to HTML. + log.warn("SearXNG did not return JSON (contentType={}). Check settings.yml has search.formats including 'json' and server.limiter: false. Preview: {}", + contentType, preview(response)); + return results; + } try { JSONObject json = JSONUtil.parseObj(response); JSONArray items = json.getJSONArray("results"); - if (items == null) return results; + if (items == null || items.isEmpty()) return results; limit = Math.min(items.size(), limit); for (int i = 0; i < limit; i++) { @@ -114,11 +132,17 @@ public class SearXNGSearchProvider implements SearchProvider { .build()); } } catch (Exception e) { - log.warn("SearXNG 结果解析失败: {}", e.getMessage()); + log.warn("SearXNG parse failed: {} — preview: {}", e.getMessage(), preview(response)); } return results; } + private static String preview(String body) { + if (body == null) return ""; + String flat = body.replaceAll("\\s+", " ").trim(); + return flat.length() > 200 ? flat.substring(0, 200) + "..." : flat; + } + private String extractDomain(String url) { try { return URI.create(url).getHost(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java index e05195f7..2d57e7ff 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/video/provider/MiniMaxVideoProvider.java @@ -21,6 +21,13 @@ import java.util.Set; *

* API 文档: https://platform.minimaxi.com/document/video-generation * 鉴权: Bearer Token + *

+ * Region 切换:根据 {@link SystemSettingsDTO#getMinimaxRegion()} 选 host: + *

    + *
  • {@code "global"} (默认) → {@code https://api.minimax.io}
  • + *
  • {@code "cn"} → {@code https://api.minimaxi.com} (mainland-CN + * lower-latency endpoint; required for accounts registered in CN).
  • + *
* * @author MateClaw Team */ @@ -31,9 +38,44 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { private final ObjectMapper objectMapper; - private static final String BASE_URL = "https://api.minimax.io"; + /** Global endpoint (used by accounts on api.minimax.io). */ + static final String BASE_URL_GLOBAL = "https://api.minimax.io"; + + /** China endpoint (api.minimaxi.com — same JSON shape, different host). */ + static final String BASE_URL_CN = "https://api.minimaxi.com"; + + /** Region value selecting the CN endpoint. Anything else → global. */ + static final String REGION_CN = "cn"; + private static final String DEFAULT_MODEL = "MiniMax-Hailuo-2.3"; + /** + * Full MiniMax video model catalog (matches openclaw + * {@code extensions/minimax/provider-models.ts}). Includes both T2V + * (Hailuo family) and I2V (I2V-01-* family) entries. + */ + private static final List MODEL_CATALOG = List.of( + // T2V (text-to-video) + "MiniMax-Hailuo-2.3", + "MiniMax-Hailuo-2.3-Fast", + "MiniMax-Hailuo-02", + // I2V (image-to-video) + "I2V-01-Director", + "I2V-01-live", + "I2V-01" + ); + + /** + * Resolve the MiniMax base URL from the system settings region. Package-private + * for unit tests — the only branching point that needs verification. + */ + static String resolveBaseUrl(SystemSettingsDTO config) { + if (config != null && REGION_CN.equalsIgnoreCase(config.getMinimaxRegion())) { + return BASE_URL_CN; + } + return BASE_URL_GLOBAL; + } + @Override public String id() { return "minimax"; @@ -67,7 +109,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { .supportedDurations(List.of(6, 10)) .maxDurationSeconds(10) .defaultModel(DEFAULT_MODEL) - .models(List.of("MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast", "I2V-01-live")) + .models(MODEL_CATALOG) .build(); } @@ -80,6 +122,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { public VideoSubmitResult submit(VideoGenerationRequest request, SystemSettingsDTO config) { try { String apiKey = config.getMinimaxApiKey(); + String baseUrl = resolveBaseUrl(config); String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL; ObjectNode body = objectMapper.createObjectNode(); @@ -93,7 +136,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { body.put("duration", request.getDurationSeconds()); } - HttpResponse response = HttpRequest.post(BASE_URL + "/v1/video_generation") + HttpResponse response = HttpRequest.post(baseUrl + "/v1/video_generation") .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .body(body.toString()) @@ -106,11 +149,11 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { int statusCode = result.path("base_resp").path("status_code").asInt(-1); if (statusCode == 0 && result.has("task_id")) { String taskId = result.get("task_id").asText(); - log.info("[MiniMax] Submitted task: {} (model={})", taskId, model); + log.info("[MiniMax] Submitted task: {} (model={}, host={})", taskId, model, baseUrl); return VideoSubmitResult.success(taskId, id()); } else { String errMsg = result.path("base_resp").path("status_msg").asText("未知错误"); - log.warn("[MiniMax] Submit failed: {}", errMsg); + log.warn("[MiniMax] Submit failed (host={}): {}", baseUrl, errMsg); return VideoSubmitResult.failure(id(), errMsg); } } catch (Exception e) { @@ -123,9 +166,10 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) { try { String apiKey = config.getMinimaxApiKey(); + String baseUrl = resolveBaseUrl(config); HttpResponse response = HttpRequest.get( - BASE_URL + "/v1/query/video_generation?task_id=" + providerTaskId) + baseUrl + "/v1/query/video_generation?task_id=" + providerTaskId) .header("Authorization", "Bearer " + apiKey) .timeout(15_000) .execute(); @@ -138,7 +182,7 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { // 优先取 video_url,备选 file_id String videoUrl = result.has("video_url") ? result.get("video_url").asText(null) : null; if (videoUrl == null && result.has("file_id")) { - videoUrl = resolveFileUrl(result.get("file_id").asText(), apiKey); + videoUrl = resolveFileUrl(result.get("file_id").asText(), apiKey, baseUrl); } yield TaskPollResult.succeeded(videoUrl, null, result.toString()); } @@ -156,12 +200,13 @@ public class MiniMaxVideoProvider implements VideoGenerationProvider { } /** - * 通过 file_id 获取视频下载 URL + * 通过 file_id 获取视频下载 URL。Region must match the host that produced + * the file_id — otherwise the cross-host lookup 404s. */ - private String resolveFileUrl(String fileId, String apiKey) { + private String resolveFileUrl(String fileId, String apiKey, String baseUrl) { try { HttpResponse response = HttpRequest.get( - BASE_URL + "/v1/files/retrieve?file_id=" + fileId) + baseUrl + "/v1/files/retrieve?file_id=" + fileId) .header("Authorization", "Bearer " + apiKey) .timeout(10_000) .execute(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java index 1954237e..a01510db 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java @@ -1,14 +1,39 @@ package vip.mate.wiki; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import vip.mate.wiki.job.WikiProcessingJobService; +import vip.mate.wiki.service.WikiRawMaterialService; /** - * Wiki 知识库模块自动配置 + * Wiki module auto-configuration * * @author MateClaw Team */ +@Slf4j @Configuration @EnableConfigurationProperties(WikiProperties.class) +@RequiredArgsConstructor public class WikiAutoConfiguration { + + private final WikiProcessingJobService wikiProcessingJobService; + private final WikiRawMaterialService wikiRawMaterialService; + + /** + * Recover stuck wiki state on startup: + * 1. Job table: routing/*_running → queued (RFC-030) + * 2. Raw material table: processing → pending (avoids forever-spinning progress bars) + */ + @EventListener(ApplicationReadyEvent.class) + public void recoverWikiJobs(ApplicationReadyEvent event) { + wikiProcessingJobService.recoverOnStartup(); + int recovered = wikiRawMaterialService.recoverStuckRawMaterialsOnStartup(); + if (recovered > 0) { + log.info("[Wiki] Recovered {} stuck raw materials on startup", recovered); + } + } } 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 47f194ce..8a3df9f1 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -33,12 +33,15 @@ public class WikiProperties { private int maxParallelRawMaterials = 3; /** - * 单个材料内 chunk 的并行处理数上限。 + * Max parallel chunks within a single raw material. *

- * RFC-012 Change 1:从硬编码 3 提到 5,并暴露为配置项。默认总并发为 - * maxParallelRawMaterials × maxParallelChunks = 15,仍在常见 60 RPM 限额下。 + * RFC-047 P3: Changed from 5 to 1. Parallel chunks all share the same existingPagesIndex + * snapshot, so chunk N cannot see pages created by chunk N-1 — causing duplicate pages and + * stale-index collisions. Serializing chunks eliminates this class of bug. Document-level + * parallelism (maxParallelRawMaterials) is preserved, so overall throughput is unchanged + * for multi-document batches. */ - private int maxParallelChunks = 5; + private int maxParallelChunks = 1; /** * 单个 chunk 内 phase B 阶段的 page 并行处理数上限。 @@ -84,6 +87,25 @@ public class WikiProperties { */ private long llmMaxTotalDurationMs = 240_000; + /** + * RFC-047 P1: Max pages per BatchCreate LLM call. + * Pages planned by route are chunked into sub-batches of this size; + * a local liveIndex is updated between sub-batches so later pages can + * link to earlier ones created in the same chunk. + * Default 2: keeps total output tokens well under typical provider caps + * (~2k–3k completion tokens) so the FILE-block JSON doesn't get truncated + * mid-object. Raising this risks unparseable JSON skips on long content. + */ + private int batchCreatePageSize = 2; + + /** + * RFC-047: Minimum chunk length (chars) for the chunk-fallback mechanism. + * If route returns 0 create+update entries and the chunk exceeds this threshold, + * an overview page is auto-injected so no substantial content is silently dropped. + * Chunks shorter than this (e.g. TOC lines, blank pages) are allowed to produce nothing. + */ + private int chunkFallbackMinChars = 200; + /** * 是否启用两阶段消化(路由 → 逐页 merge)。 *

@@ -110,4 +132,95 @@ public class WikiProperties { /** 混合搜索默认模式:keyword / semantic / hybrid */ private String searchDefaultMode = "hybrid"; + + // ==================== RFC-031: Light processing tiers ==================== + + /** Whether to auto-dispatch a LIGHT_ENRICH job after heavy ingest completes */ + private boolean lightEnrichEnabled = true; + + /** Delay before light enrichment starts (ms) */ + private long lightEnrichDelayMs = 2000; + + /** + * Minimum ratio of enriched content length to original content length. + * If the LLM returns text shorter than this ratio, the enrichment is rejected. + */ + private double wikilinkMinContentRatio = 0.5; + + /** Maximum characters for local repair single-page regeneration */ + private int localRepairMaxChars = 8000; + + /** + * Whether to run a document-level analysis pass before routing. + * When enabled, a single LLM call produces a concept map (topics + key_concepts) + * that is injected into every chunk's route prompt, giving the router global + * awareness of the document structure and reducing concept omissions. + * Adds ~1 LLM call and 10-20s per raw material. + */ + private boolean useDocumentAnalysis = true; + + /** + * Max characters of document text fed to the analysis pass. + * Larger values improve coverage but increase prompt tokens. + * Default 15000 covers most documents while staying well within model limits. + */ + private int documentAnalysisSampleChars = 15000; + + /** + * RFC-051 PR-6b: route-phase output binding. When {@code true}, the route + * LLM call uses a Spring AI {@code BeanOutputConverter} — + * the format hint is injected into the user prompt and the response is + * parsed strictly into the DTO. Failures fall back to the legacy lenient + * JSON parser so a flaky model never blocks ingest. + *

+ * Default {@code false} keeps existing behavior on first upgrade. Flip on + * once you've validated the route prompt against the models you actually + * run (DashScope / OpenAI / Anthropic / DeepSeek tend to be fine; Ollama + * and weaker models may need the fallback). + */ + private boolean useStructuredRoute = false; + + /** + * RFC-051 follow-up: how many pages the enrich service packs into a single + * LLM call. {@code 1} (default) reproduces the legacy behavior of one + * call per page. {@code 5}–{@code 10} is reasonable for most chat models; + * weaker locally-served models may need to stay at 1. + *

+ * Larger batches reduce LLM cost roughly proportional to the batch size, + * but each batch's prompt grows linearly with the included page bodies, + * so very long pages still benefit from single-page mode. Pages exceeding + * {@link #enrichBatchPerPageMaxChars} are excluded from the batch and + * enriched individually. + */ + private int enrichBatchSize = 1; + + /** + * RFC-051 follow-up: per-page content cap when packing pages into an + * enrich batch. Pages whose body exceeds this size fall through to a + * single-page enrich call so the batch prompt stays bounded. The cap + * applies only to the prompt; the applier always sees full content. + */ + private int enrichBatchPerPageMaxChars = 3000; + + /** + * RFC-051 §9.4: replace the legacy flat 0.15 relation boost with a + * normalized score per query, scaled by {@link #relationBoostLambda}. + * Default {@code false} keeps the legacy ranking; flip on after + * validating against your retrieval test set. + *

+ * Why it matters: the flat 0.15 was bigger than typical RRF scores + * (~0.02–0.05), so boosted neighbors routinely leapfrogged real RRF + * hits. Normalization keeps boost on the same scale as fused scores. + */ + private boolean useNormalizedRelationBoost = false; + + /** + * RFC-051 §9.4: maximum boost contribution from the relation pass when + * {@link #useNormalizedRelationBoost} is on. Each boosted candidate + * gets {@code (rawRelationScore / maxRawRelationScore) * lambda} added + * to its fused score. Default {@code 0.05} is roughly the size of a + * top-3 RRF score, so a max-relation neighbor competes evenly with a + * top-3 RRF hit but doesn't dominate it. + */ + private double relationBoostLambda = 0.05; } 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 new file mode 100644 index 00000000..3ff9cde4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java @@ -0,0 +1,81 @@ +package vip.mate.wiki.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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.RestController; +import vip.mate.wiki.job.WikiChunkTokenBackfillJob; +import vip.mate.wiki.service.WikiOverviewService; +import vip.mate.wiki.service.WikiScaffoldService; + +import java.util.HashMap; +import java.util.Map; + +/** + * RFC-051 follow-up: small set of operator-facing endpoints for things the + * scheduled jobs / event hooks normally handle automatically. Useful when the + * cron hasn't fired yet (fresh upgrade), the auto-rebuild was skipped, or you + * just want to force-refresh during debugging. + * + *

All endpoints are idempotent and synchronous. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/wiki/admin") +@RequiredArgsConstructor +@Tag(name = "Wiki Admin", description = "Operator endpoints for system pages and backfill jobs") +public class WikiAdminController { + + private final WikiScaffoldService scaffoldService; + + /** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */ + @Autowired(required = false) + private WikiOverviewService overviewService; + + @Autowired(required = false) + private WikiChunkTokenBackfillJob backfillJob; + + @Operation(summary = "Ensure overview/log scaffold + rebuild overview stats now", + description = "Idempotent. Use after manual data imports or when stats look stale.") + @PostMapping("/kb/{kbId}/rebuild-overview") + public ResponseEntity> rebuildOverview(@PathVariable Long kbId) { + Map body = new HashMap<>(); + scaffoldService.ensureScaffold(kbId); + if (overviewService != null) { + overviewService.rebuild(kbId); + body.put("rebuilt", true); + } else { + body.put("rebuilt", false); + body.put("note", "Overview service not wired; only scaffold ensured"); + } + body.put("kbId", kbId); + return ResponseEntity.ok(body); + } + + @Operation(summary = "Force-run the token-count backfill batch now", + description = "Picks up to BATCH_SIZE chunks with token_count IS NULL and fills them. " + + "Returns the pending count after the batch so callers can poll.") + @PostMapping("/backfill-tokens") + public ResponseEntity> backfillTokens() { + Map body = new HashMap<>(); + if (backfillJob == null) { + body.put("ok", false); + body.put("note", "Backfill job not wired"); + return ResponseEntity.ok(body); + } + long beforePending = backfillJob.pendingCount(); + backfillJob.runOnce(); + long afterPending = backfillJob.pendingCount(); + body.put("ok", true); + body.put("pendingBefore", beforePending); + body.put("pendingAfter", afterPending); + body.put("filledThisBatch", Math.max(0, beforePending - afterPending)); + return ResponseEntity.ok(body); + } +} 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 fff1216c..9b6a6dd3 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 @@ -9,6 +9,7 @@ import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @@ -186,12 +187,33 @@ public class WikiController { // ==================== Raw Materials ==================== @RequireWorkspaceRole("viewer") - @Operation(summary = "获取原始材料列表") + @Operation(summary = "获取原始材料列表(含每条材料生成的页面数)") @GetMapping("/knowledge-bases/{kbId}/raw") - public R> listRaw(@PathVariable Long kbId, - @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + public R>> listRaw(@PathVariable Long kbId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); - return R.ok(rawService.listByKbId(kbId)); + List raws = rawService.listByKbId(kbId); + List> result = new java.util.ArrayList<>(raws.size()); + for (WikiRawMaterialEntity raw : raws) { + Map item = new LinkedHashMap<>(); + // Serialize all entity fields via Jackson-friendly approach + item.put("id", raw.getId()); + item.put("kbId", raw.getKbId()); + item.put("title", raw.getTitle()); + item.put("sourceType", raw.getSourceType()); + item.put("processingStatus", raw.getProcessingStatus()); + item.put("errorMessage", raw.getErrorMessage()); + item.put("progressPhase", raw.getProgressPhase()); + item.put("progressDone", raw.getProgressDone()); + item.put("progressTotal", raw.getProgressTotal()); + item.put("contentHash", raw.getContentHash()); + item.put("createTime", raw.getCreateTime()); + item.put("updateTime", raw.getUpdateTime()); + // Enriched field: page count derived from this raw material + item.put("pageCount", pageService.countBySourceRawId(kbId, raw.getId())); + result.add(item); + } + return R.ok(result); } @RequireWorkspaceRole("member") @@ -274,14 +296,90 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("viewer") + @Operation(summary = "下载原始材料") + @GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download") + public org.springframework.http.ResponseEntity downloadRaw( + @PathVariable Long kbId, + @PathVariable Long rawId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) throws IOException { + verifyKBWorkspace(kbId, workspaceId); + WikiRawMaterialEntity raw = rawService.getById(rawId); + if (raw == null || !kbId.equals(raw.getKbId())) { + return org.springframework.http.ResponseEntity.notFound().build(); + } + + String rawTitle = raw.getTitle(); + String filename = (rawTitle != null && !rawTitle.isBlank()) + ? rawTitle : ("source-" + rawId); + + org.springframework.core.io.Resource resource; + long contentLength; + org.springframework.http.MediaType mediaType; + String sourceType = raw.getSourceType(); + + if ("text".equals(sourceType)) { + // Text materials live in the DB column — re-encode the stored content as bytes. + String content = raw.getOriginalContent(); + if (content == null) { + return org.springframework.http.ResponseEntity.notFound().build(); + } + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + resource = new org.springframework.core.io.ByteArrayResource(bytes); + contentLength = bytes.length; + mediaType = org.springframework.http.MediaType.parseMediaType("text/plain;charset=UTF-8"); + // Manually-pasted text rows often have no extension on the title — give the + // download a sane suffix so the OS knows what to do with it. + if (!filename.contains(".")) filename = filename + ".txt"; + } else { + // Binary materials live on disk — sandbox to the configured upload dir so + // a tampered source_path can't escape and serve arbitrary files. + String sourcePath = raw.getSourcePath(); + if (sourcePath == null || sourcePath.isBlank()) { + return org.springframework.http.ResponseEntity.notFound().build(); + } + Path path = Paths.get(sourcePath).toAbsolutePath().normalize(); + Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize(); + if (!path.startsWith(uploadDir)) { + log.warn("[Wiki] Download rejected: rawId={} path={} outside uploadDir={}", + rawId, path, uploadDir); + return org.springframework.http.ResponseEntity + .status(org.springframework.http.HttpStatus.FORBIDDEN).build(); + } + if (!Files.isRegularFile(path)) { + return org.springframework.http.ResponseEntity.notFound().build(); + } + resource = new org.springframework.core.io.FileSystemResource(path); + contentLength = Files.size(path); + mediaType = org.springframework.http.MediaType.APPLICATION_OCTET_STREAM; + } + + // RFC 5987 — provide both ASCII-safe filename= (for old browsers) and + // UTF-8 filename*= so non-ASCII titles (e.g. 中医诊断学.docx) survive intact. + String asciiFallback = filename.replaceAll("[^\\x20-\\x7E]", "_") + .replace("\"", "_").replace("\\", "_"); + String encoded = java.net.URLEncoder.encode(filename, StandardCharsets.UTF_8) + .replace("+", "%20"); + String contentDisposition = "attachment; filename=\"" + asciiFallback + + "\"; filename*=UTF-8''" + encoded; + + return org.springframework.http.ResponseEntity.ok() + .contentType(mediaType) + .contentLength(contentLength) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, contentDisposition) + .body(resource); + } + // ==================== Wiki Pages ==================== @RequireWorkspaceRole("viewer") - @Operation(summary = "获取 Wiki 页面列表") + @Operation(summary = "获取 Wiki 页面列表(可按原始材料过滤)") @GetMapping("/knowledge-bases/{kbId}/pages") public R> listPages(@PathVariable Long kbId, + @RequestParam(required = false) Long rawId, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); + if (rawId != null) return R.ok(pageService.listBySourceRawId(kbId, rawId)); return R.ok(pageService.listByKbId(kbId)); } @@ -338,6 +436,40 @@ public class WikiController { return R.ok(pageService.getBacklinks(kbId, slug)); } + // RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI + // needs a dedicated endpoint to enumerate archived pages and a way to flip the + // flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already + // exist, but the admin UI shouldn't have to go through agent plumbing). + + @RequireWorkspaceRole("viewer") + @Operation(summary = "列出知识库中所有 archived=1 的页面(不含 content)") + @GetMapping("/knowledge-bases/{kbId}/pages/archived") + public R> listArchivedPages(@PathVariable Long kbId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + return R.ok(pageService.listArchivedByKbId(kbId)); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "归档单个页面(软归档;可恢复)") + @PostMapping("/knowledge-bases/{kbId}/pages/{slug}/archive") + public R> archivePage(@PathVariable Long kbId, @PathVariable String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + boolean changed = pageService.setArchived(kbId, slug, true); + return R.ok(Map.of("slug", slug, "archived", true, "changed", changed)); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "取消归档") + @PostMapping("/knowledge-bases/{kbId}/pages/{slug}/unarchive") + public R> unarchivePage(@PathVariable Long kbId, @PathVariable String slug, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + boolean changed = pageService.setArchived(kbId, slug, false); + return R.ok(Map.of("slug", slug, "archived", false, "changed", changed)); + } + // ==================== Processing ==================== @RequireWorkspaceRole("member") @@ -415,7 +547,8 @@ public class WikiController { public SseEmitter subscribeProgress(@PathVariable Long kbId, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); - SseEmitter emitter = new SseEmitter(30L * 60 * 1000); // 30min + // RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码 + SseEmitter emitter = new Utf8SseEmitter(30L * 60 * 1000); // 30min progressBus.subscribe(kbId, emitter); emitter.onCompletion(() -> { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java new file mode 100644 index 00000000..854d9d3a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiRelationController.java @@ -0,0 +1,164 @@ +package vip.mate.wiki.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.web.bind.annotation.*; +import vip.mate.wiki.dto.*; +import vip.mate.wiki.job.WikiProcessingJobService; +import vip.mate.wiki.job.event.WikiJobCreatedEvent; +import vip.mate.wiki.repository.WikiProcessingJobMapper; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.service.*; + +import java.util.List; +import java.util.Map; + +/** + * RFC-029/030/031/032/033: REST endpoints for wiki relations, jobs, enrichment, and search. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/wiki") +@RequiredArgsConstructor +public class WikiRelationController { + + private final WikiRelationService relationService; + private final WikiProcessingJobService jobService; + private final WikiProcessingJobMapper jobMapper; + private final WikiPageService pageService; + private final WikiPageCitationMapper citationMapper; + private final HybridRetriever hybridRetriever; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + // ==================== RFC-029: Relations ==================== + + @GetMapping("/kb/{kbId}/pages/{slug}/related") + public List relatedPages( + @PathVariable Long kbId, + @PathVariable String slug, + @RequestParam(defaultValue = "5") int topK) { + return relationService.relatedPages(kbId, slug, Math.min(topK, 20)); + } + + @GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}") + public RelationExplanation explainRelation( + @PathVariable Long kbId, + @PathVariable String slugA, + @PathVariable String slugB) { + return relationService.explain(kbId, slugA, slugB); + } + + @GetMapping("/raw/{rawId}/pages") + public List pagesByRawId(@PathVariable Long rawId) { + return relationService.pagesByRawId(rawId); + } + + @GetMapping("/chunks/{chunkId}/pages") + public List pagesByChunkId(@PathVariable Long chunkId) { + return relationService.pagesByChunkId(chunkId); + } + + // ==================== RFC-029: Citations ==================== + + @GetMapping("/kb/{kbId}/pages/{pageId}/citations") + public List pageCitations( + @PathVariable Long kbId, + @PathVariable Long pageId) { + return citationMapper.listWithRawByPageId(pageId); + } + + // ==================== RFC-030: Jobs ==================== + + @GetMapping("/kb/{kbId}/jobs") + public List getJobs( + @PathVariable Long kbId, + @RequestParam(required = false) Long rawId) { + if (rawId != null) { + return jobMapper.findLatestByRawId(rawId) + .map(List::of).orElse(List.of()); + } + return jobMapper.listQueued(kbId, 20); + } + + // ==================== RFC-030/033: KB Stats ==================== + + @GetMapping("/kb/{kbId}/stats") + public Map kbStats(@PathVariable Long kbId) { + int pageCount = pageService.countByKbId(kbId); + // Count enriched pages (those containing [[wikilinks]]) + long enrichedCount = pageService.listByKbIdWithContent(kbId).stream() + .filter(p -> p.getContent() != null && p.getContent().contains("[[")) + .count(); + // Use listByKbId (all statuses) instead of listQueued (queued-only) + var allJobs = jobMapper.listByKbId(kbId, 200); + int failedJobCount = (int) allJobs.stream() + .filter(j -> "failed".equals(j.getStatus())) + .count(); + int runningJobCount = (int) allJobs.stream() + .filter(j -> "running".equals(j.getStatus())) + .count(); + + return Map.of( + "pageCount", pageCount, + "enrichedPageCount", enrichedCount, + "failedJobCount", failedJobCount, + "runningJobCount", runningJobCount + ); + } + + // ==================== RFC-031: Enrichment & Repair ==================== + + @PostMapping("/kb/{kbId}/pages/{slug}/enrich") + public Map enrichPage(@PathVariable Long kbId, @PathVariable String slug) { + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) return Map.of("error", "Page not found: " + slug); + + Long rawId = 0L; + try { + List rawIds = objectMapper.readValue( + page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]", + new TypeReference>() {}); + if (!rawIds.isEmpty()) rawId = rawIds.get(0); + } catch (Exception ignored) {} + + WikiProcessingJobEntity job = jobService.createLightEnrich(kbId, rawId); + eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId())); + return Map.of("jobId", job.getId()); + } + + @PostMapping("/kb/{kbId}/pages/{slug}/repair") + public Map repairPage(@PathVariable Long kbId, @PathVariable String slug) { + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) return Map.of("error", "Page not found: " + slug); + + Long rawId = 0L; + try { + List rawIds = objectMapper.readValue( + page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]", + new TypeReference>() {}); + if (!rawIds.isEmpty()) rawId = rawIds.get(0); + } catch (Exception ignored) {} + + WikiProcessingJobEntity job = jobService.createLocalRepair(kbId, rawId, page.getId()); + eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId())); + return Map.of("jobId", job.getId()); + } + + // ==================== RFC-032: Search preview ==================== + + @PostMapping("/kb/{kbId}/search-preview") + public List searchPreview( + @PathVariable Long kbId, + @RequestBody Map body) { + String query = (String) body.getOrDefault("query", ""); + String mode = (String) body.getOrDefault("mode", "hybrid"); + int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5; + return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java index 79b55ed8..d34b952b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiResearchController.java @@ -8,6 +8,7 @@ import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.common.result.R; import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiResearchService; @@ -98,7 +99,8 @@ public class WikiResearchController { @GetMapping(value = "/stream/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@PathVariable String sessionId) { // 10 分钟超时(research 典型 < 1 分钟,10 分钟给重连留余地) - SseEmitter emitter = new SseEmitter(10 * 60 * 1000L); + // RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码 + SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L); boolean attached = streamTracker.attach(sessionId, emitter); if (!attached) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/ChunkPageRef.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/ChunkPageRef.java new file mode 100644 index 00000000..4247a3a4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/ChunkPageRef.java @@ -0,0 +1,6 @@ +package vip.mate.wiki.dto; + +/** + * RFC-029: Lightweight chunk-to-page reference for shared-chunk signal computation. + */ +public record ChunkPageRef(Long chunkId, Long pageId) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentBatchPlan.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentBatchPlan.java new file mode 100644 index 00000000..cf5c3d51 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentBatchPlan.java @@ -0,0 +1,23 @@ +package vip.mate.wiki.dto; + +import java.util.Map; + +/** + * RFC-051 follow-up: multi-page enrich response. + *

+ * Maps page slug → {@link EnrichmentPlan}. Slugs missing from the map are + * treated as "LLM proposed nothing for that page" and skipped silently. + * Each plan still goes through the full {@code WikiEnrichmentApplier} + * round-trip validation, so a malformed plan for one page can't corrupt the + * others — that page is just rejected and its peers move on. + */ +public record EnrichmentBatchPlan(Map plans) { + + public EnrichmentBatchPlan { + if (plans == null) plans = Map.of(); + } + + public boolean isEmpty() { + return plans.isEmpty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentPlan.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentPlan.java new file mode 100644 index 00000000..1baed3b7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentPlan.java @@ -0,0 +1,22 @@ +package vip.mate.wiki.dto; + +import java.util.List; + +/** + * RFC-051 PR-5b: replacement-plan output of {@code WikiLinkEnrichmentService}. + *

+ * The LLM is asked to return a list of surgical wrap operations rather than + * a full rewritten page body. Each {@link EnrichmentReplacement} describes + * "wrap the Nth occurrence of {@code original} with {@code replacement}". + * Java validates and applies them, guaranteeing no non-link prose changes. + */ +public record EnrichmentPlan(List replacements) { + + public EnrichmentPlan { + if (replacements == null) replacements = List.of(); + } + + public boolean isEmpty() { + return replacements.isEmpty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentReplacement.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentReplacement.java new file mode 100644 index 00000000..e1300cc3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/EnrichmentReplacement.java @@ -0,0 +1,23 @@ +package vip.mate.wiki.dto; + +/** + * RFC-051 PR-5b: one entry of {@link EnrichmentPlan}. + *

+ * Semantics: + *

    + *
  • {@code original} — literal text that must already exist in the page.
  • + *
  • {@code replacement} — must be a wikilink form, either {@code [[slug]]} + * or {@code [[slug|label]]}. The visible text after wrapping has to + * equal {@code original}, otherwise the replacement is rejected.
  • + *
  • {@code occurrence} — 1-based index. {@code 1} means the first + * occurrence in the page; {@code 2} means the second; and so on. Counts + * skip text already inside another wikilink.
  • + *
+ * Default occurrence is 1 when the LLM omits the field. + */ +public record EnrichmentReplacement(String original, String replacement, int occurrence) { + + public EnrichmentReplacement { + if (occurrence <= 0) occurrence = 1; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/GeneratedPage.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/GeneratedPage.java new file mode 100644 index 00000000..87c6693f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/GeneratedPage.java @@ -0,0 +1,28 @@ +package vip.mate.wiki.dto; + +import java.util.List; + +/** + * RFC-051 PR-6 (skeleton): structured create/merge output. + *

+ * Replaces the legacy {@code ---FILE---} block protocol used by + * {@code WikiBatchCreateParser}. {@code evidenceChunkIds} is optional and + * intended for the on-demand compile path so per-page citations bind to + * the chunks the prompt actually consumed. + *

+ * Fields are ordered to mirror the existing prompt template so the + * follow-up Spring-AI {@code BeanOutputConverter} swap is a one-shot + * replace of the parser, not a prompt rewrite. + */ +public record GeneratedPage( + String slug, + String title, + String summary, + String pageType, + String content, + List evidenceChunkIds +) { + public GeneratedPage { + if (evidenceChunkIds == null) evidenceChunkIds = List.of(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageCitationWithRaw.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageCitationWithRaw.java new file mode 100644 index 00000000..261ef752 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageCitationWithRaw.java @@ -0,0 +1,12 @@ +package vip.mate.wiki.dto; + +import java.math.BigDecimal; + +/** + * RFC-029: Citation record joined with the chunk's raw material ID. + */ +public record PageCitationWithRaw(Long id, Long pageId, Long chunkId, Long rawId, + Integer paragraphIdx, String anchorText, BigDecimal confidence, + String rawTitle, Integer chunkOrdinal, + Integer startOffset, Integer endOffset, + String snippet) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java new file mode 100644 index 00000000..d0e458ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java @@ -0,0 +1,16 @@ +package vip.mate.wiki.dto; + +import java.util.List; + +/** + * RFC-032: Enhanced search result with snippet, match metadata, and relevance reason. + */ +public record PageSearchResult( + String slug, + String title, + String summary, + String snippet, + List matchedBy, + String reason, + double score +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/RawTitleRef.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RawTitleRef.java new file mode 100644 index 00000000..85a88b89 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RawTitleRef.java @@ -0,0 +1,6 @@ +package vip.mate.wiki.dto; + +/** + * RFC-032: Lightweight raw material ID-to-title projection for batch lookups. + */ +public record RawTitleRef(Long id, String title) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/RelatedPageResult.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RelatedPageResult.java new file mode 100644 index 00000000..ba1fa593 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RelatedPageResult.java @@ -0,0 +1,9 @@ +package vip.mate.wiki.dto; + +import java.util.List; + +/** + * RFC-029: A related page with aggregated relation score and contributing signals. + */ +public record RelatedPageResult(String slug, String title, String summary, + double score, List signals) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/RelationExplanation.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RelationExplanation.java new file mode 100644 index 00000000..83a0aa50 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RelationExplanation.java @@ -0,0 +1,13 @@ +package vip.mate.wiki.dto; + +import java.util.List; + +/** + * RFC-029: Detailed breakdown of the relation between two pages. + */ +public record RelationExplanation(String slugA, String slugB, double totalScore, + List breakdown) { + public static RelationExplanation notFound() { + return new RelationExplanation(null, null, 0, List.of()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/RouteResult.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RouteResult.java new file mode 100644 index 00000000..f5c0cbfd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RouteResult.java @@ -0,0 +1,30 @@ +package vip.mate.wiki.dto; + +import java.util.List; + +/** + * RFC-051 PR-6 (skeleton): structured route-phase output. + *

+ * Today {@code processChunkTwoPhase} parses the route LLM's free-form JSON + * with a hand-rolled extractor, which fails on weak models that emit + * trailing commentary or mismatched braces. The follow-up wiring will use + * Spring AI {@code BeanOutputConverter} so the prompt and the + * parser share one schema. + *

+ * Defining the type up-front (PR-6 partial) lets the prompt rewrite, parser + * swap, and call-site change land in one cohesive PR without renames. + * + * @param create new pages the router proposes (slug + title + summary) + * @param update slugs of existing pages that should absorb this chunk + */ +public record RouteResult(List create, List update) { + + public RouteResult { + if (create == null) create = List.of(); + if (update == null) update = List.of(); + } + + public boolean isEmpty() { + return create.isEmpty() && update.isEmpty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/RoutedPageMeta.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RoutedPageMeta.java new file mode 100644 index 00000000..09f4c7fc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/RoutedPageMeta.java @@ -0,0 +1,14 @@ +package vip.mate.wiki.dto; + +/** + * RFC-051 PR-6 (skeleton): one entry of {@link RouteResult#create()}. + *

+ * {@code purposeHint} is optional; when populated by the router it feeds + * the create-page prompt's framing. + */ +public record RoutedPageMeta(String slug, String title, String summary, String purposeHint) { + + public RoutedPageMeta(String slug, String title, String summary) { + this(slug, title, summary, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/SignalScore.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/SignalScore.java new file mode 100644 index 00000000..ecd72583 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/SignalScore.java @@ -0,0 +1,6 @@ +package vip.mate.wiki.dto; + +/** + * RFC-029: Individual signal contribution to a relation score. + */ +public record SignalScore(String signal, double weight, double score) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiChunkDraft.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiChunkDraft.java new file mode 100644 index 00000000..1691ee72 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiChunkDraft.java @@ -0,0 +1,26 @@ +package vip.mate.wiki.dto; + +/** + * RFC-051 PR-1a: a chunk-to-be along with the structural metadata produced by + * {@code DocumentPreprocessService} / {@code WikiContentNormalizer}. + *

+ * This carries everything {@link vip.mate.wiki.service.WikiChunkService} needs + * to persist a chunk row, including the new metadata columns added in V39: + * {@code page_number}, {@code token_count}, {@code header_breadcrumb}, + * {@code source_section}. The structural fields are nullable because not every + * source format yields each piece of metadata (e.g. plain text has no page + * number; HTML has no slide id). + *

+ * No production callers in PR-1a — wiring lands in PR-1c. The type is added + * here so the persistence overload in PR-1a can be unit-tested independently. + */ +public record WikiChunkDraft( + String content, + int startOffset, + int endOffset, + Integer pageNumber, + Integer tokenCount, + String headerBreadcrumb, + String sourceSection +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java new file mode 100644 index 00000000..af892600 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiPageLite.java @@ -0,0 +1,22 @@ +package vip.mate.wiki.dto; + +/** + * RFC-029: Lightweight page projection without content. + *

+ * RFC-051 PR-2: {@code pageType} added so callers can default-filter system + * pages from list/search/related results. Backwards-compatible 4-arg + * factory keeps older constructors (e.g. {@code new WikiPageLite(id, slug, + * title, summary)}) compiling — they yield {@code pageType=null}, which the + * filter treats as "not system". + */ +public record WikiPageLite(Long id, String slug, String title, String summary, String pageType) { + + public WikiPageLite(Long id, String slug, String title, String summary) { + this(id, slug, title, summary, null); + } + + /** True when this page should be hidden from default tool/search results. */ + public boolean isSystem() { + return "system".equals(pageType); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java new file mode 100644 index 00000000..a27b7952 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiKbDirtyEvent.java @@ -0,0 +1,26 @@ +package vip.mate.wiki.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * Fired after a knowledge base's content meaningfully changes — i.e. raw + * material ingest commits, or a non-trivial compile lands. Listeners that + * want to refresh derived artefacts (overview narrative, embeddings drift + * checks, search index) subscribe here. + * + *

Carries only {@code kbId}. Listeners debounce or batch on their own. + * The intent is "this KB is dirty, rebuild downstream when convenient", + * not "this exact raw was just processed" — the latter already has + * {@link WikiProcessingEvent}.

+ */ +@Getter +public class WikiKbDirtyEvent extends ApplicationEvent { + + private final Long kbId; + + public WikiKbDirtyEvent(Object source, Long kbId) { + super(source); + this.kbId = kbId; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiChunkTokenBackfillJob.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiChunkTokenBackfillJob.java new file mode 100644 index 00000000..83e4e931 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiChunkTokenBackfillJob.java @@ -0,0 +1,87 @@ +package vip.mate.wiki.job; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.wiki.model.WikiChunkEntity; +import vip.mate.wiki.repository.WikiChunkMapper; + +import java.util.List; + +/** + * RFC-051 PR-1a: low-frequency backfill job that fills the new + * {@code token_count} column on existing chunks. Runs in the background after + * the V39 migration adds the column with all-NULL data. + *

+ * Strategy: + *

    + *
  • Pick at most {@value #BATCH_SIZE} chunks where {@code token_count IS NULL}.
  • + *
  • Estimate tokens as {@code ceil(charCount / 4.0)} — a rough approximation + * that PR-1c will replace with a real tokenizer once Tika and the + * preprocessing pipeline are in place.
  • + *
  • Persist and stop. Subsequent ticks pick up the next batch.
  • + *
  • Any failure is logged at {@code warn} and swallowed so the main + * ingest pipeline is never affected.
  • + *
+ * + * The job is scheduled at a half-hour cron so a fresh upgrade does not + * thrash the database. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WikiChunkTokenBackfillJob { + + private static final int BATCH_SIZE = 500; + + private final WikiChunkMapper chunkMapper; + + /** + * RFC-051 follow-up: count chunks still missing a token estimate. + * Used by the admin endpoint to decide whether a manual rerun is worthwhile. + */ + public long pendingCount() { + return chunkMapper.selectCount( + new LambdaQueryWrapper() + .isNull(WikiChunkEntity::getTokenCount)); + } + + @Async + @Scheduled(cron = "${mate.wiki.chunk-token-backfill-cron:0 */30 * * * ?}") + public void runOnce() { + try { + List batch = chunkMapper.selectList( + new LambdaQueryWrapper() + .isNull(WikiChunkEntity::getTokenCount) + .last("LIMIT " + BATCH_SIZE)); + if (batch.isEmpty()) { + return; + } + + int updated = 0; + for (WikiChunkEntity chunk : batch) { + Integer chars = chunk.getCharCount(); + if (chars == null || chars <= 0) { + chunk.setTokenCount(0); + } else { + chunk.setTokenCount((int) Math.ceil(chars / 4.0)); + } + try { + chunkMapper.updateById(chunk); + updated++; + } catch (Exception inner) { + log.warn("[WikiChunkTokenBackfill] Failed to update chunk={}: {}", + chunk.getId(), inner.getMessage()); + } + } + log.info("[WikiChunkTokenBackfill] Backfilled token_count for {}/{} chunks", + updated, batch.size()); + } catch (Exception e) { + log.warn("[WikiChunkTokenBackfill] Backfill batch failed (will retry next tick): {}", + e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiHardModelException.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiHardModelException.java new file mode 100644 index 00000000..5db916b8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiHardModelException.java @@ -0,0 +1,17 @@ +package vip.mate.wiki.job; + +/** + * RFC-031: Thrown when a wiki processing step encounters a hard/fatal + * model error that warrants fallback to a different model. + */ +public class WikiHardModelException extends RuntimeException { + + private final String errorCode; + + public WikiHardModelException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public String getErrorCode() { return errorCode; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobDispatcher.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobDispatcher.java new file mode 100644 index 00000000..785ee4aa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobDispatcher.java @@ -0,0 +1,72 @@ +package vip.mate.wiki.job; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import vip.mate.wiki.job.event.WikiJobCreatedEvent; +import vip.mate.wiki.repository.WikiProcessingJobMapper; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.job.template.WikiProcessingTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +/** + * RFC-031: Listens for WikiJobCreatedEvent and dispatches jobs + * to the appropriate processing template. + */ +@Slf4j +@Component +public class WikiJobDispatcher { + + private final Map templateMap; + private final WikiProcessingJobMapper jobMapper; + + private static final ExecutorService DISPATCH_EXECUTOR = + Executors.newVirtualThreadPerTaskExecutor(); + + public WikiJobDispatcher(List templates, + WikiProcessingJobMapper jobMapper) { + this.jobMapper = jobMapper; + this.templateMap = templates.stream().collect( + Collectors.toMap(t -> t.getClass().getSimpleName(), t -> t)); + log.info("[WikiDispatcher] Registered templates: {}", templateMap.keySet()); + } + + @EventListener(WikiJobCreatedEvent.class) + public void onJobCreated(WikiJobCreatedEvent event) { + DISPATCH_EXECUTOR.submit(() -> dispatch(event.jobId())); + } + + public void dispatch(Long jobId) { + WikiProcessingJobEntity job = jobMapper.selectById(jobId); + if (job == null) return; + + try { + WikiJobStage stage = WikiJobStage.valueOf(job.getStage().toUpperCase()); + if (stage.isTerminal()) return; + } catch (IllegalArgumentException e) { + log.warn("[WikiDispatch] Unknown stage '{}' for job {}", job.getStage(), jobId); + return; + } + + WikiProcessingTemplate template = resolveTemplate(job.getJobType()); + if (template == null) { + log.error("[WikiDispatch] No template for job type: {}", job.getJobType()); + return; + } + template.execute(job); + } + + private WikiProcessingTemplate resolveTemplate(String jobType) { + return switch (jobType) { + case "heavy_ingest" -> templateMap.get("HeavyIngestTemplate"); + case "light_enrich" -> templateMap.get("LightEnrichTemplate"); + case "local_repair" -> templateMap.get("LocalRepairTemplate"); + default -> null; + }; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStage.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStage.java new file mode 100644 index 00000000..19d57f1b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStage.java @@ -0,0 +1,16 @@ +package vip.mate.wiki.job; + +/** + * RFC-030: Stage state machine for wiki processing jobs. + */ +public enum WikiJobStage { + QUEUED, ROUTING, + PHASE_A_RUNNING, PHASE_A_DONE, + PHASE_B_RUNNING, + ENRICHING, EMBEDDING, + COMPLETED, FAILED, PARTIAL, CANCELLED; + + public boolean isTerminal() { + return this == COMPLETED || this == FAILED || this == PARTIAL || this == CANCELLED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStatus.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStatus.java new file mode 100644 index 00000000..aef9659e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStatus.java @@ -0,0 +1,8 @@ +package vip.mate.wiki.job; + +/** + * RFC-030: Top-level status of a wiki processing job. + */ +public enum WikiJobStatus { + QUEUED, RUNNING, COMPLETED, FAILED, PARTIAL, CANCELLED +} 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 new file mode 100644 index 00000000..707a8a8d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobStep.java @@ -0,0 +1,9 @@ +package vip.mate.wiki.job; + +/** + * RFC-030: Logical steps within a wiki processing job, + * used for per-step model routing. + */ +public enum WikiJobStep { + ROUTE, CREATE_PAGE, MERGE_PAGE, ENRICH, SUMMARY +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobType.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobType.java new file mode 100644 index 00000000..55cbf343 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiJobType.java @@ -0,0 +1,10 @@ +package vip.mate.wiki.job; + +/** + * RFC-030: Types of wiki processing jobs. + */ +public enum WikiJobType { + HEAVY_INGEST, + LIGHT_ENRICH, + LOCAL_REPAIR +} 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 new file mode 100644 index 00000000..29fece9a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java @@ -0,0 +1,50 @@ +package vip.mate.wiki.job; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * RFC-030: Per-KB processing configuration POJO, deserialized from + * {@link vip.mate.wiki.model.WikiKnowledgeBaseEntity#getConfigContent()}. + */ +@Data +public class WikiKbConfig { + + /** + * RFC-051: ingest pipeline mode for this KB. {@code "lazy"} skips page + * generation on upload (chunk + embed only); {@code "eager"} runs the + * legacy heavy ingest pipeline. {@code null} means caller should apply + * its own default. PR-1a only adds the field; the lazy branch lands + * in PR-1b. + */ + private String ingestMode; + + /** + * RFC-051: KB-level default chat model. Used by routing as the + * intermediate fallback between {@link #stepModels} and the system + * default. {@code null} means the caller should fall through to the + * system default. The frontend already writes this field; before + * PR-1a it had no Java field to deserialize into and was silently + * dropped. + */ + private Long wikiDefaultModelId; + + /** Per-step model overrides: "heavy_ingest.create_page" → modelId */ + private Map stepModels; + + /** Global fallback model chain for all steps in this KB */ + private List fallbackModelIds; + + /** + * RFC-051 PR-6b follow-up: per-KB opt-in for structured route output. + *

+ * Different KBs run different chat models — DashScope and Anthropic + * follow the format hint reliably; weaker locally-served Ollama models + * may not. Keeping the flag per-KB lets users flip it where it pays + * off. {@code null} (the common case) falls back to + * {@link vip.mate.wiki.WikiProperties#isUseStructuredRoute()}. + */ + private Boolean useStructuredRoute; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiModelRoutingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiModelRoutingService.java new file mode 100644 index 00000000..c69b18ea --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiModelRoutingService.java @@ -0,0 +1,153 @@ +package vip.mate.wiki.job; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import vip.mate.llm.chatmodel.ProviderChatModelFactory; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.job.fallback.ModelFallbackHandler; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.job.strategy.WikiStepModelStrategy; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * RFC-030: Model routing service — selects the right model for each + * wiki processing step, with fallback chain support. + */ +@Slf4j +@Service +public class WikiModelRoutingService { + + private final List strategies; + private final ModelFallbackHandler fallbackChainHead; + private final ProviderChatModelFactory chatModelFactory; + private final ModelConfigService modelConfigService; + private final WikiKnowledgeBaseService kbService; + private final ObjectMapper objectMapper; + + @Autowired(required = false) + private AvailableProviderPool providerPool; + + private static final RetryTemplate WIKI_NO_RETRY = RetryTemplate.builder() + .maxAttempts(1).build(); + + public WikiModelRoutingService(List strategies, + List fallbackHandlers, + ProviderChatModelFactory chatModelFactory, + ModelConfigService modelConfigService, + WikiKnowledgeBaseService kbService, + ObjectMapper objectMapper) { + this.strategies = strategies; + this.chatModelFactory = chatModelFactory; + this.modelConfigService = modelConfigService; + this.kbService = kbService; + this.objectMapper = objectMapper; + this.fallbackChainHead = buildChain(fallbackHandlers); + } + + /** + * Select the best model for a given job and step, respecting strategy + * priority and provider pool availability. + */ + public Long selectModelId(WikiProcessingJobEntity job, WikiJobStep step) { + WikiKnowledgeBaseEntity kb = (job != null) ? kbService.getById(job.getKbId()) : null; + for (WikiStepModelStrategy strategy : strategies) { + if (strategy.supports(step)) { + Long modelId = strategy.selectModelId(job, kb, step); + if (modelId != null && isAvailable(modelId)) return modelId; + } + } + throw new WikiModelUnavailableException("No available model for step: " + step); + } + + /** + * RFC-051 PR-3: KB-scoped overload. Synthesizes a minimal job context so + * legacy call sites in {@code WikiProcessingService} (which don't always + * have the {@code WikiProcessingJobEntity} on hand) can still benefit + * from per-step model overrides. {@code jobType} matches the keys the UI + * writes into KB config — {@code "heavy_ingest"} for the eager pipeline, + * {@code "light_enrich"} for wikilink enrichment. + */ + public Long selectModelId(Long kbId, String jobType, WikiJobStep step) { + WikiProcessingJobEntity synthetic = new WikiProcessingJobEntity(); + synthetic.setKbId(kbId); + synthetic.setJobType(jobType); + return selectModelId(synthetic, step); + } + + /** + * Select a fallback model after a failure. + */ + public Long selectFallbackModel(WikiProcessingJobEntity job, WikiJobStep step, String errorCode) { + return fallbackChainHead.handle(job, step, errorCode) + .orElseThrow(() -> new WikiModelUnavailableException( + "Exhausted all fallback models for step: " + step)); + } + + /** + * Build a ChatModel instance for a given model ID. + */ + public ChatModel buildChatModel(Long modelId) { + ModelConfigEntity model = modelConfigService.getModel(modelId); + return chatModelFactory.buildFor(model, WIKI_NO_RETRY); + } + + /** + * Build the fallback chain JSON for a job (called once during routing stage). + */ + public String buildFallbackChainJson(Long kbId) { + WikiKbConfig config = kbConfigOf(kbId); + List chain; + if (config != null && config.getFallbackModelIds() != null && !config.getFallbackModelIds().isEmpty()) { + chain = config.getFallbackModelIds(); + } else { + chain = List.of(modelConfigService.getDefaultModel().getId()); + } + if (providerPool != null) { + chain = chain.stream().filter(this::isAvailable).collect(Collectors.toList()); + } + try { + return objectMapper.writeValueAsString(chain); + } catch (Exception e) { + return "[]"; + } + } + + private boolean isAvailable(Long modelId) { + if (providerPool == null) return true; + try { + ModelConfigEntity m = modelConfigService.getModel(modelId); + return providerPool.contains(m.getProvider()); + } catch (Exception e) { + return false; + } + } + + private WikiKbConfig kbConfigOf(Long kbId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null || kb.getConfigContent() == null) return null; + try { + return objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); + } catch (Exception e) { + return null; + } + } + + private static ModelFallbackHandler buildChain(List handlers) { + if (handlers.isEmpty()) throw new IllegalStateException("No ModelFallbackHandler registered"); + for (int i = 0; i < handlers.size() - 1; i++) { + handlers.get(i).setNext(handlers.get(i + 1)); + } + return handlers.get(0); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiModelUnavailableException.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiModelUnavailableException.java new file mode 100644 index 00000000..8a058561 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiModelUnavailableException.java @@ -0,0 +1,10 @@ +package vip.mate.wiki.job; + +/** + * RFC-030: Thrown when no model is available for a wiki processing step. + */ +public class WikiModelUnavailableException extends RuntimeException { + public WikiModelUnavailableException(String message) { + super(message); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java new file mode 100644 index 00000000..05b7a8ea --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiProcessingJobService.java @@ -0,0 +1,194 @@ +package vip.mate.wiki.job; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.repository.WikiProcessingJobMapper; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * RFC-030: Wiki processing job lifecycle service — creates, transitions, + * and records errors for processing jobs. + */ +@Slf4j +@Service +public class WikiProcessingJobService { + + private final WikiProcessingJobMapper jobMapper; + private final WikiModelRoutingService routingService; + private final ObjectMapper objectMapper; + + @Autowired(required = false) + private AvailableProviderPool providerPool; + + @Autowired + private ModelConfigService modelConfigService; + + public WikiProcessingJobService(WikiProcessingJobMapper jobMapper, + WikiModelRoutingService routingService, + ObjectMapper objectMapper) { + this.jobMapper = jobMapper; + this.routingService = routingService; + this.objectMapper = objectMapper; + } + + public WikiProcessingJobEntity createHeavyIngest(Long kbId, Long rawId) { + WikiProcessingJobEntity job = new WikiProcessingJobEntity(); + job.setKbId(kbId); + job.setRawId(rawId); + job.setJobType(WikiJobType.HEAVY_INGEST.name().toLowerCase()); + job.setStage(WikiJobStage.QUEUED.name().toLowerCase()); + job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase()); + job.setMaxRetries(3); + job.setRetryCount(0); + jobMapper.insert(job); + return job; + } + + public WikiProcessingJobEntity createLightEnrich(Long kbId, Long rawId) { + WikiProcessingJobEntity job = new WikiProcessingJobEntity(); + job.setKbId(kbId); + job.setRawId(rawId); + job.setJobType(WikiJobType.LIGHT_ENRICH.name().toLowerCase()); + job.setStage(WikiJobStage.QUEUED.name().toLowerCase()); + job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase()); + job.setMaxRetries(2); + job.setRetryCount(0); + jobMapper.insert(job); + return job; + } + + public WikiProcessingJobEntity createLocalRepair(Long kbId, Long rawId, Long targetPageId) { + WikiProcessingJobEntity job = new WikiProcessingJobEntity(); + job.setKbId(kbId); + job.setRawId(rawId); + job.setJobType(WikiJobType.LOCAL_REPAIR.name().toLowerCase()); + job.setStage(WikiJobStage.QUEUED.name().toLowerCase()); + job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase()); + job.setMaxRetries(2); + job.setRetryCount(0); + try { + job.setMetaJson(objectMapper.writeValueAsString(Map.of("targetPageId", targetPageId))); + } catch (Exception e) { + job.setMetaJson("{}"); + } + jobMapper.insert(job); + return job; + } + + public WikiProcessingJobEntity transition(Long jobId, WikiJobStage newStage) { + WikiProcessingJobEntity job = jobMapper.selectById(jobId); + if (job == null) return null; + + job.setStage(newStage.name().toLowerCase()); + if (newStage == WikiJobStage.ROUTING) { + job.setStartedAt(LocalDateTime.now()); + job.setStatus(WikiJobStatus.RUNNING.name().toLowerCase()); + job.setFallbackChainJson(routingService.buildFallbackChainJson(job.getKbId())); + } else if (newStage == WikiJobStage.COMPLETED) { + job.setFinishedAt(LocalDateTime.now()); + job.setStatus(WikiJobStatus.COMPLETED.name().toLowerCase()); + } else if (newStage == WikiJobStage.FAILED) { + job.setFinishedAt(LocalDateTime.now()); + job.setStatus(WikiJobStatus.FAILED.name().toLowerCase()); + } else if (newStage == WikiJobStage.PARTIAL) { + job.setFinishedAt(LocalDateTime.now()); + job.setStatus(WikiJobStatus.PARTIAL.name().toLowerCase()); + } else if (!newStage.isTerminal()) { + // Non-terminal intermediate stage: mark as running + job.setStatus(WikiJobStatus.RUNNING.name().toLowerCase()); + } + jobMapper.updateById(job); + return job; + } + + public void recordHardError(Long jobId, String errorCode, String errorMessage) { + WikiProcessingJobEntity job = jobMapper.selectById(jobId); + if (job == null) return; + + job.setErrorCode(errorCode); + job.setErrorMessage(truncate(errorMessage, 2000)); + job.setFinishedAt(LocalDateTime.now()); + + if (job.getRetryCount() < job.getMaxRetries()) { + job.setRetryCount(job.getRetryCount() + 1); + job.setResumeFromStage(job.getStage()); + job.setStage(WikiJobStage.QUEUED.name().toLowerCase()); + job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase()); + } else { + job.setStatus(WikiJobStatus.FAILED.name().toLowerCase()); + job.setStage(WikiJobStage.FAILED.name().toLowerCase()); + } + jobMapper.updateById(job); + + if (providerPool != null && isHardError(errorCode) && job.getCurrentModelId() != null) { + notifyPoolHardError(job.getCurrentModelId(), errorCode); + } + } + + public void recordSoftError(Long jobId, String errorCode, String errorMessage) { + WikiProcessingJobEntity job = jobMapper.selectById(jobId); + if (job == null) return; + + job.setErrorCode(errorCode); + job.setErrorMessage(truncate(errorMessage, 2000)); + job.setFinishedAt(LocalDateTime.now()); + + if (job.getRetryCount() < job.getMaxRetries()) { + job.setRetryCount(job.getRetryCount() + 1); + job.setResumeFromStage(job.getStage()); + job.setStage(WikiJobStage.QUEUED.name().toLowerCase()); + job.setStatus(WikiJobStatus.QUEUED.name().toLowerCase()); + } else { + job.setStatus(WikiJobStatus.FAILED.name().toLowerCase()); + job.setStage(WikiJobStage.FAILED.name().toLowerCase()); + } + jobMapper.updateById(job); + } + + /** + * Recover stuck jobs on startup (routing or *_running → queued). + */ + public void recoverOnStartup() { + int recovered = jobMapper.recoverStuckJobs(); + if (recovered > 0) { + log.info("[WikiJob] Recovered {} stuck jobs on startup", recovered); + } + } + + private static boolean isHardError(String errorCode) { + return errorCode != null && ( + errorCode.equals("AUTH_ERROR") || + errorCode.equals("BILLING") || + errorCode.equals("MODEL_NOT_FOUND")); + } + + private void notifyPoolHardError(Long modelId, String errorCode) { + try { + ModelConfigEntity model = modelConfigService.getModel(modelId); + if (model != null) { + AvailableProviderPool.RemovalSource source; + try { + source = AvailableProviderPool.RemovalSource.valueOf(errorCode); + } catch (IllegalArgumentException e) { + source = AvailableProviderPool.RemovalSource.AUTH_ERROR; + } + providerPool.remove(model.getProvider(), source, "Wiki job hard error: " + errorCode); + } + } catch (Exception e) { + log.warn("[WikiJob] Failed to notify provider pool: {}", e.getMessage()); + } + } + + private static String truncate(String s, int maxLen) { + if (s == null) return null; + return s.length() <= maxLen ? s : s.substring(0, maxLen); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiSoftModelException.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiSoftModelException.java new file mode 100644 index 00000000..bf8e391d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiSoftModelException.java @@ -0,0 +1,17 @@ +package vip.mate.wiki.job; + +/** + * RFC-031: Thrown when a wiki processing step encounters a transient + * model error that can be retried. + */ +public class WikiSoftModelException extends RuntimeException { + + private final String errorCode; + + public WikiSoftModelException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public String getErrorCode() { return errorCode; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/event/WikiJobCreatedEvent.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/event/WikiJobCreatedEvent.java new file mode 100644 index 00000000..8a3d4ea5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/event/WikiJobCreatedEvent.java @@ -0,0 +1,7 @@ +package vip.mate.wiki.job.event; + +/** + * RFC-031: Published when a new wiki processing job is created, + * triggering the dispatcher to execute it. + */ +public record WikiJobCreatedEvent(Long jobId) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/GlobalFallbackModelHandler.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/GlobalFallbackModelHandler.java new file mode 100644 index 00000000..b79f3e08 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/GlobalFallbackModelHandler.java @@ -0,0 +1,35 @@ +package vip.mate.wiki.job.fallback; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; + +import java.util.Optional; + +/** + * RFC-030: Final fallback — returns the system default model. + */ +@Component +@Order(2) +@RequiredArgsConstructor +public class GlobalFallbackModelHandler implements ModelFallbackHandler { + + private final ModelConfigService modelConfigService; + + private ModelFallbackHandler next; + + @Override + public Optional handle(WikiProcessingJobEntity job, WikiJobStep step, String errorCode) { + try { + return Optional.of(modelConfigService.getDefaultModel().getId()); + } catch (Exception e) { + return Optional.empty(); + } + } + + @Override + public void setNext(ModelFallbackHandler next) { this.next = next; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/ModelFallbackHandler.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/ModelFallbackHandler.java new file mode 100644 index 00000000..0570c86b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/ModelFallbackHandler.java @@ -0,0 +1,16 @@ +package vip.mate.wiki.job.fallback; + +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; + +import java.util.Optional; + +/** + * RFC-030: Chain of responsibility for model fallback selection. + */ +public interface ModelFallbackHandler { + + Optional handle(WikiProcessingJobEntity job, WikiJobStep step, String errorCode); + + void setNext(ModelFallbackHandler next); +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/PoolAwareModelFallbackHandler.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/PoolAwareModelFallbackHandler.java new file mode 100644 index 00000000..9316f8a5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/fallback/PoolAwareModelFallbackHandler.java @@ -0,0 +1,70 @@ +package vip.mate.wiki.job.fallback; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; + +import java.util.List; +import java.util.Optional; + +/** + * RFC-030: Pool-aware fallback — walks the job's fallback chain, + * skipping models whose provider is no longer in the AvailableProviderPool. + */ +@Slf4j +@Component +@Order(1) +public class PoolAwareModelFallbackHandler implements ModelFallbackHandler { + + @Autowired(required = false) + private AvailableProviderPool providerPool; + + @Autowired + private ModelConfigService modelConfigService; + + @Autowired + private ObjectMapper objectMapper; + + private ModelFallbackHandler next; + + @Override + public Optional handle(WikiProcessingJobEntity job, WikiJobStep step, String errorCode) { + List fallbackChain = parseFallbackChain(job.getFallbackChainJson()); + for (Long modelId : fallbackChain) { + if (!modelId.equals(job.getCurrentModelId()) && isAvailable(modelId)) { + return Optional.of(modelId); + } + } + return next != null ? next.handle(job, step, errorCode) : Optional.empty(); + } + + private boolean isAvailable(Long modelId) { + if (providerPool == null) return true; + try { + ModelConfigEntity model = modelConfigService.getModel(modelId); + return providerPool.contains(model.getProvider()); + } catch (Exception e) { + return false; + } + } + + private List parseFallbackChain(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return List.of(); + } + } + + @Override + public void setNext(ModelFallbackHandler next) { this.next = next; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java new file mode 100644 index 00000000..7be65cca --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/model/WikiProcessingJobEntity.java @@ -0,0 +1,60 @@ +package vip.mate.wiki.job.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * RFC-030: Wiki processing job entity — tracks the lifecycle of a single + * raw material processing run with per-stage state and model routing info. + */ +@Data +@TableName("mate_wiki_processing_job") +public class WikiProcessingJobEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long kbId; + + private Long rawId; + + private String jobType; + + private String stage; + + private String status; + + private Long primaryModelId; + + private Long currentModelId; + + private String fallbackChainJson; + + private Integer retryCount; + + private Integer maxRetries; + + private String errorCode; + + private String errorMessage; + + private String resumeFromStage; + + /** Generic JSON metadata (e.g. targetPageId for LOCAL_REPAIR) */ + private String metaJson; + + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java new file mode 100644 index 00000000..3fd190a9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java @@ -0,0 +1,33 @@ +package vip.mate.wiki.job.strategy; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +/** + * RFC-030: Final fallback strategy — uses the system default model. + * Cheap steps (ROUTE, ENRICH, SUMMARY) prefer a lighter/cheaper model; + * strong steps (CREATE_PAGE, MERGE_PAGE) use the default model. + */ +@Component +@Order(3) +@RequiredArgsConstructor +public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy { + + private final ModelConfigService modelConfigService; + + @Override + public boolean supports(WikiJobStep step) { return true; } + + @Override + public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { + // RFC-030: cheap steps (ROUTE, ENRICH, SUMMARY) should ideally use a lighter model, + // but ModelConfigService has no "cheapest chat model" concept yet. + // When per-step pricing metadata is added, this switch can differentiate. + return modelConfigService.getDefaultModel().getId(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java new file mode 100644 index 00000000..ea44de14 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java @@ -0,0 +1,44 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import java.util.Map; + +/** + * RFC-030: Highest-priority strategy — uses per-KB step model overrides + * from the KB's configContent JSON. + */ +@Slf4j +@Component +@Order(1) +@RequiredArgsConstructor +public class KbConfigStepModelStrategy implements WikiStepModelStrategy { + + private final ObjectMapper objectMapper; + + @Override + public boolean supports(WikiJobStep step) { return true; } + + @Override + public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { + if (kb == null || kb.getConfigContent() == null) return null; + try { + WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); + Map stepModels = config.getStepModels(); + if (stepModels == null) return null; + String key = job.getJobType() + "." + step.name().toLowerCase(); + return stepModels.get(key); + } catch (Exception e) { + log.debug("[KbConfigStrategy] Failed to parse KB config: {}", e.getMessage()); + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java new file mode 100644 index 00000000..24bbebfb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java @@ -0,0 +1,49 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +/** + * RFC-051 PR-1a: middle-priority strategy that resolves the KB-level default + * chat model ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits between the + * per-step override ({@link KbConfigStepModelStrategy}, Order 1) and the + * system-wide default ({@link GlobalDefaultStepModelStrategy}, Order 3), + * yielding the chain prescribed by RFC-051 §10.1: + * + *

+ *   stepModels[step] -> wikiDefaultModelId -> system default
+ * 
+ * + * The frontend has long written {@code wikiDefaultModelId} into the KB config + * JSON, but no Java code consumed it before this strategy existed. + */ +@Slf4j +@Component +@Order(2) +@RequiredArgsConstructor +public class KbDefaultModelStrategy implements WikiStepModelStrategy { + + private final ObjectMapper objectMapper; + + @Override + public boolean supports(WikiJobStep step) { return true; } + + @Override + public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { + if (kb == null || kb.getConfigContent() == null) return null; + try { + WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); + return config.getWikiDefaultModelId(); + } catch (Exception e) { + log.debug("[KbDefaultModelStrategy] Failed to parse KB config: {}", e.getMessage()); + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiStepModelStrategy.java new file mode 100644 index 00000000..df1b98a0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiStepModelStrategy.java @@ -0,0 +1,21 @@ +package vip.mate.wiki.job.strategy; + +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +/** + * RFC-030: Strategy for selecting a model for a specific processing step. + * Implementations are evaluated in @Order priority; first non-null result wins. + */ +public interface WikiStepModelStrategy { + + boolean supports(WikiJobStep step); + + /** + * Select a model ID for the given job and step. + * + * @return model ID, or null to defer to the next strategy + */ + Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step); +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/template/HeavyIngestTemplate.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/HeavyIngestTemplate.java new file mode 100644 index 00000000..f98fd5b2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/HeavyIngestTemplate.java @@ -0,0 +1,53 @@ +package vip.mate.wiki.job.template; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.job.*; +import vip.mate.wiki.job.event.WikiJobCreatedEvent; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.service.WikiProcessingService; + +/** + * RFC-031: Heavy ingest template — delegates to the legacy processing + * service for the two-phase digest pipeline, then optionally dispatches + * a light enrichment job. + */ +@Component +public class HeavyIngestTemplate extends WikiProcessingTemplate { + + private final WikiProcessingService legacyProcessingService; + + public HeavyIngestTemplate(WikiModelRoutingService routingService, + WikiProcessingJobService jobService, + ApplicationEventPublisher eventPublisher, + WikiProperties wikiProperties, + WikiProcessingService legacyProcessingService) { + super(routingService, jobService, eventPublisher, wikiProperties); + this.legacyProcessingService = legacyProcessingService; + } + + @Override + protected WikiJobStep routingStep() { return WikiJobStep.CREATE_PAGE; } + + @Override + protected WikiJobStage mainStage() { return WikiJobStage.PHASE_A_RUNNING; } + + @Override + protected void doProcess(WikiProcessingJobEntity job, Long modelId) { + // Transition period: delegate to existing processing pipeline as a whole. + // TODO: RFC-031 future — split into processInChunksForJob(job, modelId), + // processChunkTwoPhaseForJob(job, modelId), scheduleEmbeddingAsync(rawId) + // for per-stage job transitions and per-step model routing. + legacyProcessingService.processRawMaterial(job.getRawId()); + } + + @Override + protected void onSuccess(WikiProcessingJobEntity job) { + if (wikiProperties.isLightEnrichEnabled()) { + WikiProcessingJobEntity enrichJob = + jobService.createLightEnrich(job.getKbId(), job.getRawId()); + eventPublisher.publishEvent(new WikiJobCreatedEvent(enrichJob.getId())); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/template/LightEnrichTemplate.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/LightEnrichTemplate.java new file mode 100644 index 00000000..155a3796 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/LightEnrichTemplate.java @@ -0,0 +1,59 @@ +package vip.mate.wiki.job.template; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.WikiPageLite; +import vip.mate.wiki.job.*; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.service.WikiCitationService; +import vip.mate.wiki.service.WikiLinkEnrichmentService; +import vip.mate.wiki.service.WikiRelationService; + +import java.util.List; + +/** + * RFC-031: Light enrichment template — adds [[wikilinks]] and rebuilds + * citations without regenerating page content. + */ +@Slf4j +@Component +public class LightEnrichTemplate extends WikiProcessingTemplate { + + private final WikiLinkEnrichmentService enrichmentService; + private final WikiCitationService citationService; + private final WikiRelationService relationService; + + public LightEnrichTemplate(WikiModelRoutingService routingService, + WikiProcessingJobService jobService, + ApplicationEventPublisher eventPublisher, + WikiProperties wikiProperties, + WikiLinkEnrichmentService enrichmentService, + WikiCitationService citationService, + WikiRelationService relationService) { + super(routingService, jobService, eventPublisher, wikiProperties); + this.enrichmentService = enrichmentService; + this.citationService = citationService; + this.relationService = relationService; + } + + @Override + protected WikiJobStep routingStep() { return WikiJobStep.ENRICH; } + + @Override + protected WikiJobStage mainStage() { return WikiJobStage.ENRICHING; } + + @Override + protected void doProcess(WikiProcessingJobEntity job, Long modelId) { + List pages = relationService.pagesByRawId(job.getRawId()); + for (WikiPageLite page : pages) { + try { + enrichmentService.enrichPage(page.id(), modelId); + citationService.buildCitations(page.id(), job.getKbId()); + } catch (Exception e) { + log.warn("[LightEnrich] Failed to enrich page {}: {}", page.slug(), e.getMessage()); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/template/LocalRepairTemplate.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/LocalRepairTemplate.java new file mode 100644 index 00000000..3cf0dca3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/LocalRepairTemplate.java @@ -0,0 +1,69 @@ +package vip.mate.wiki.job.template; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.job.*; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.service.WikiCitationService; +import vip.mate.wiki.service.WikiProcessingService; + +import java.util.Map; + +/** + * RFC-031: Local repair template — regenerates a single target page + * without affecting other pages derived from the same raw material. + */ +@Slf4j +@Component +public class LocalRepairTemplate extends WikiProcessingTemplate { + + private final WikiProcessingService legacyProcessingService; + private final WikiCitationService citationService; + private final ObjectMapper objectMapper; + + public LocalRepairTemplate(WikiModelRoutingService routingService, + WikiProcessingJobService jobService, + ApplicationEventPublisher eventPublisher, + WikiProperties wikiProperties, + WikiProcessingService legacyProcessingService, + WikiCitationService citationService, + ObjectMapper objectMapper) { + super(routingService, jobService, eventPublisher, wikiProperties); + this.legacyProcessingService = legacyProcessingService; + this.citationService = citationService; + this.objectMapper = objectMapper; + } + + @Override + protected WikiJobStep routingStep() { return WikiJobStep.CREATE_PAGE; } + + @Override + protected WikiJobStage mainStage() { return WikiJobStage.PHASE_B_RUNNING; } + + @Override + protected void doProcess(WikiProcessingJobEntity job, Long modelId) { + Map meta = parseMetaJson(job.getMetaJson()); + Object pageIdObj = meta.get("targetPageId"); + if (pageIdObj == null) { + throw new IllegalArgumentException("targetPageId missing from job metaJson, jobId=" + job.getId()); + } + Long targetPageId = Long.valueOf(pageIdObj.toString()); + + legacyProcessingService.repairSinglePage(targetPageId, modelId); + citationService.buildCitations(targetPageId, job.getKbId()); + } + + private Map parseMetaJson(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("[LocalRepair] Failed to parse metaJson: {}", e.getMessage()); + return Map.of(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/template/WikiProcessingTemplate.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/WikiProcessingTemplate.java new file mode 100644 index 00000000..276d0fbb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/template/WikiProcessingTemplate.java @@ -0,0 +1,87 @@ +package vip.mate.wiki.job.template; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.job.*; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; + +/** + * RFC-031: Template method base class for all wiki processing job types. + * The {@link #execute} skeleton handles routing, error classification, + * and fallback; subclasses implement the actual processing in {@link #doProcess}. + */ +@Slf4j +public abstract class WikiProcessingTemplate { + + protected final WikiModelRoutingService routingService; + protected final WikiProcessingJobService jobService; + protected final ApplicationEventPublisher eventPublisher; + protected final WikiProperties wikiProperties; + + protected WikiProcessingTemplate(WikiModelRoutingService routingService, + WikiProcessingJobService jobService, + ApplicationEventPublisher eventPublisher, + WikiProperties wikiProperties) { + this.routingService = routingService; + this.jobService = jobService; + this.eventPublisher = eventPublisher; + this.wikiProperties = wikiProperties; + } + + /** + * Non-overridable skeleton: route → process → handle errors. + */ + public final void execute(WikiProcessingJobEntity job) { + try { + job = jobService.transition(job.getId(), WikiJobStage.ROUTING); + Long modelId = routingService.selectModelId(job, routingStep()); + job.setCurrentModelId(modelId); + + job = jobService.transition(job.getId(), mainStage()); + try { + doProcess(job, modelId); + jobService.transition(job.getId(), WikiJobStage.COMPLETED); + onSuccess(job); + } catch (WikiHardModelException e) { + handleHardError(job, e); + } catch (WikiSoftModelException e) { + handleSoftError(job, e); + } catch (Exception e) { + jobService.recordSoftError(job.getId(), "UNKNOWN", e.getMessage()); + } + } catch (WikiModelUnavailableException e) { + log.error("[WikiTemplate] No model available for job {}: {}", job.getId(), e.getMessage()); + jobService.recordHardError(job.getId(), "MODEL_NOT_FOUND", e.getMessage()); + } + } + + /** The logical step used for model selection during routing. */ + protected abstract WikiJobStep routingStep(); + + /** The primary stage to transition to before processing starts. */ + protected abstract WikiJobStage mainStage(); + + /** Subclass implements the actual processing logic. */ + protected abstract void doProcess(WikiProcessingJobEntity job, Long modelId); + + /** Hook called after successful completion (optional override). */ + protected void onSuccess(WikiProcessingJobEntity job) {} + + private void handleHardError(WikiProcessingJobEntity job, WikiHardModelException e) { + try { + Long fallbackModelId = routingService.selectFallbackModel( + job, routingStep(), e.getErrorCode()); + job.setCurrentModelId(fallbackModelId); + doProcess(job, fallbackModelId); + jobService.transition(job.getId(), WikiJobStage.COMPLETED); + onSuccess(job); + } catch (Exception fallbackEx) { + jobService.recordHardError(job.getId(), e.getErrorCode(), e.getMessage()); + } + } + + private void handleSoftError(WikiProcessingJobEntity job, WikiSoftModelException e) { + jobService.recordSoftError(job.getId(), e.getErrorCode(), e.getMessage()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiChunkEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiChunkEntity.java index 1c0f28c2..aef31ce6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiChunkEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiChunkEntity.java @@ -51,12 +51,23 @@ public class WikiChunkEntity { /** RFC-011:生成该 embedding 的模型名称(切模型时需全量重嵌) */ private String embeddingModel; + /** RFC-051: source page number (PDF/PPTX) when known; null otherwise. */ + private Integer pageNumber; + + /** RFC-051: estimated token count; null until populated by chunker or backfill job. */ + private Integer tokenCount; + + /** RFC-051: header path leading to this chunk, e.g. "Intro / Setup / Linux". */ + private String headerBreadcrumb; + + /** RFC-051: short identifier of the source section (slide id, sheet name, heading). */ + private String sourceSection; + @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 737d1475..b87846b3 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 @@ -59,6 +59,5 @@ public class WikiKnowledgeBaseEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java new file mode 100644 index 00000000..8de827ad --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageCitationEntity.java @@ -0,0 +1,36 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * RFC-029: Wiki page citation entity — links a page to the chunks it was derived from. + */ +@Data +@TableName("mate_wiki_page_citation") +public class WikiPageCitationEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long pageId; + + private Long chunkId; + + private Integer paragraphIdx; + + private String anchorText; + + private BigDecimal confidence; + + private String createdBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableLogic + private Integer deleted; +} 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 4ea02c42..78641cc6 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 @@ -42,18 +42,42 @@ public class WikiPageEntity { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String sourceRawIds; - /** 版本号(每次 AI 更新递增) */ + /** RFC-047 P2: paired source lineage — JSON array of {rawId, rawTitle} objects. Canonical; dual-written with sourceRawIds. */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String sourceEntries; + + /** Page type: entity / concept / source / synthesis */ + private String pageType; + + /** Purpose hint for LLM ingest routing */ + private String purposeHint; + + /** Version number (incremented on each AI update) */ private Integer version; /** 最后更新者:ai / manual */ private String lastUpdatedBy; + /** + * RFC-051 PR-2: protection flag. {@code locked=1} blocks AI/tool/UI deletion + * and batch cleanup; combined with {@code pageType="system"} for the + * built-in {@code overview} / {@code log} pages. + */ + private Integer locked; + + /** + * RFC-051 PR-7: soft-archive flag. {@code archived=1} hides the page from + * default list / search / related results without destroying it. Used to + * tuck away pages that are no longer relevant but whose history (citations, + * source-raw lineage) should stay queryable. + */ + private Integer archived; + @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/WikiRawMaterialEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java index 351d8c14..c875524c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java @@ -73,6 +73,5 @@ public class WikiRawMaterialEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/relation/DirectLinkSignal.java b/mateclaw-server/src/main/java/vip/mate/wiki/relation/DirectLinkSignal.java new file mode 100644 index 00000000..059ee813 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/relation/DirectLinkSignal.java @@ -0,0 +1,70 @@ +package vip.mate.wiki.relation; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.service.WikiPageService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC-029: Direct-link signal — pages connected via [[wikilinks]] + * are related (weight = 2.0). Checks both outgoing and incoming links. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DirectLinkSignal implements RelationSignalStrategy { + + private final WikiPageMapper pageMapper; + private final WikiPageService pageService; + private final ObjectMapper objectMapper; + + @Override + public String signalName() { return "direct_link"; } + + @Override + public double weight() { return 2.0; } + + @Override + public Map score(Long seedPageId, Long kbId) { + WikiPageEntity seed = pageMapper.selectById(seedPageId); + if (seed == null) return Map.of(); + + Map scores = new HashMap<>(); + + // Outgoing links: slugs this page links to + List outgoing = parseStringList(seed.getOutgoingLinks()); + for (String slug : outgoing) { + WikiPageEntity target = pageService.getBySlug(kbId, slug); + if (target != null) { + scores.put(target.getId(), weight()); + } + } + + // Incoming links: pages whose outgoingLinks contain seed's slug + List inbound = pageService.getBacklinks(kbId, seed.getSlug()); + for (WikiPageEntity p : inbound) { + scores.merge(p.getId(), weight(), Double::sum); + } + + scores.remove(seedPageId); + return scores; + } + + private List parseStringList(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("[DirectLinkSignal] Failed to parse outgoingLinks: {}", e.getMessage()); + return List.of(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/relation/RelationSignalStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/relation/RelationSignalStrategy.java new file mode 100644 index 00000000..9b27fc11 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/relation/RelationSignalStrategy.java @@ -0,0 +1,27 @@ +package vip.mate.wiki.relation; + +import java.util.Map; + +/** + * RFC-029: Strategy interface for computing a single relation signal + * between a seed page and other pages in the knowledge base. + *

+ * Each implementation returns a map of pageId → raw score for pages + * that have a positive signal. Pages not in the map score 0. + * The seed page itself is filtered out by {@link vip.mate.wiki.service.WikiRelationService}. + */ +public interface RelationSignalStrategy { + + String signalName(); + + double weight(); + + /** + * Compute scores for pages related to the given seed page. + * + * @param seedPageId the seed page ID + * @param kbId the knowledge base ID + * @return pageId → weighted score (only positive entries) + */ + Map score(Long seedPageId, Long kbId); +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/relation/SemanticNearSignal.java b/mateclaw-server/src/main/java/vip/mate/wiki/relation/SemanticNearSignal.java new file mode 100644 index 00000000..c32b36c1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/relation/SemanticNearSignal.java @@ -0,0 +1,109 @@ +package vip.mate.wiki.relation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.service.WikiChunkService; +import vip.mate.wiki.service.WikiEmbeddingService; +import vip.mate.wiki.service.WikiPageService; + +import java.util.*; + +/** + * RFC-029: Semantic-near signal (optional, weight = 1.0). + * Uses chunk embeddings to find semantically similar pages. + * Silently returns empty when embedding service is unavailable. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SemanticNearSignal implements RelationSignalStrategy { + + private static final double THRESHOLD = 0.85; + + private final WikiEmbeddingService embeddingService; + private final WikiChunkService chunkService; + private final WikiPageCitationMapper citationMapper; + private final WikiPageService pageService; + + @Override + public String signalName() { return "semantic_near"; } + + @Override + public double weight() { return 1.0; } + + @Override + public Map score(Long seedPageId, Long kbId) { + if (!embeddingService.isAvailable()) return Map.of(); + + // Get seed page's chunks and their embeddings + WikiPageEntity seed = pageService.getById(seedPageId); + if (seed == null) return Map.of(); + + // Collect seed chunk embeddings via citation mapper + List seedChunkIds = citationMapper.listWithRawByPageId(seedPageId) + .stream().map(c -> c.chunkId()).distinct().toList(); + if (seedChunkIds.isEmpty()) return Map.of(); + + // Get all chunks in the KB + List allChunks = chunkService.listByKbId(kbId); + Set seedChunkSet = new HashSet<>(seedChunkIds); + + // Get seed chunk vectors + List seedVectors = new ArrayList<>(); + for (WikiChunkEntity chunk : allChunks) { + if (seedChunkSet.contains(chunk.getId()) && chunk.getEmbedding() != null) { + seedVectors.add(WikiEmbeddingService.bytesToFloats(chunk.getEmbedding())); + } + } + if (seedVectors.isEmpty()) return Map.of(); + + // Average seed vectors into a single representative vector + float[] seedVec = averageVectors(seedVectors); + + // Score all non-seed chunks by cosine similarity, aggregate to page level + Map chunkScores = new HashMap<>(); + for (WikiChunkEntity chunk : allChunks) { + if (seedChunkSet.contains(chunk.getId()) || chunk.getEmbedding() == null) continue; + float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding()); + double sim = WikiEmbeddingService.cosine(seedVec, vec); + if (sim >= THRESHOLD) { + chunkScores.merge(chunk.getId(), sim, Math::max); + } + } + + // Map chunk scores to page scores via citation + Map pageScores = new HashMap<>(); + for (var entry : chunkScores.entrySet()) { + List pageIds = citationMapper.listPageIdsByChunkId(entry.getKey()); + for (Long pid : pageIds) { + if (!pid.equals(seedPageId)) { + pageScores.merge(pid, entry.getValue() * weight(), Math::max); + } + } + } + return pageScores; + } + + private float[] averageVectors(List vectors) { + if (vectors.size() == 1) return vectors.get(0); + int dim = vectors.get(0).length; + float[] avg = new float[dim]; + for (float[] v : vectors) { + for (int i = 0; i < dim; i++) avg[i] += v[i]; + } + float norm = 0; + for (int i = 0; i < dim; i++) { + avg[i] /= vectors.size(); + norm += avg[i] * avg[i]; + } + norm = (float) Math.sqrt(norm); + if (norm > 0) { + for (int i = 0; i < dim; i++) avg[i] /= norm; + } + return avg; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/relation/SharedChunkSignal.java b/mateclaw-server/src/main/java/vip/mate/wiki/relation/SharedChunkSignal.java new file mode 100644 index 00000000..50b3ed49 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/relation/SharedChunkSignal.java @@ -0,0 +1,44 @@ +package vip.mate.wiki.relation; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import vip.mate.wiki.dto.PageCitationWithRaw; +import vip.mate.wiki.repository.WikiPageCitationMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * RFC-029: Shared-chunk signal — pages that share the same source chunk + * are strongly related (weight = 5.0). + */ +@Component +@RequiredArgsConstructor +public class SharedChunkSignal implements RelationSignalStrategy { + + private final WikiPageCitationMapper citationMapper; + + @Override + public String signalName() { return "shared_chunk"; } + + @Override + public double weight() { return 5.0; } + + @Override + public Map score(Long seedPageId, Long kbId) { + List seedChunkIds = citationMapper.listWithRawByPageId(seedPageId) + .stream().map(PageCitationWithRaw::chunkId).distinct().toList(); + if (seedChunkIds.isEmpty()) return Map.of(); + + Map pageCount = new HashMap<>(); + citationMapper.listByChunkIds(seedChunkIds).stream() + .filter(ref -> !ref.pageId().equals(seedPageId)) + .forEach(ref -> pageCount.merge(ref.pageId(), 1L, Long::sum)); + + return pageCount.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, + e -> e.getValue() * weight())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/relation/SharedRawSignal.java b/mateclaw-server/src/main/java/vip/mate/wiki/relation/SharedRawSignal.java new file mode 100644 index 00000000..9f50a772 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/relation/SharedRawSignal.java @@ -0,0 +1,61 @@ +package vip.mate.wiki.relation; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.repository.WikiPageMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC-029: Shared-raw signal — pages derived from the same raw material + * are related (weight = 3.0). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SharedRawSignal implements RelationSignalStrategy { + + private final WikiPageCitationMapper citationMapper; + private final WikiPageMapper pageMapper; + private final ObjectMapper objectMapper; + + @Override + public String signalName() { return "shared_raw"; } + + @Override + public double weight() { return 3.0; } + + @Override + public Map score(Long seedPageId, Long kbId) { + WikiPageEntity seed = pageMapper.selectById(seedPageId); + if (seed == null) return Map.of(); + + List rawIds = parseRawIds(seed.getSourceRawIds()); + if (rawIds.isEmpty()) return Map.of(); + + Map scores = new HashMap<>(); + for (Long rawId : rawIds) { + citationMapper.listPageIdsByRawId(rawId).stream() + .filter(pid -> !pid.equals(seedPageId)) + .forEach(pid -> scores.merge(pid, weight(), Double::sum)); + } + return scores; + } + + private List parseRawIds(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("[SharedRawSignal] Failed to parse sourceRawIds: {}", e.getMessage()); + return List.of(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageCitationMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageCitationMapper.java new file mode 100644 index 00000000..686eb6ff --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageCitationMapper.java @@ -0,0 +1,47 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import vip.mate.wiki.dto.ChunkPageRef; +import vip.mate.wiki.dto.PageCitationWithRaw; +import vip.mate.wiki.model.WikiPageCitationEntity; + +import java.util.Collection; +import java.util.List; + +/** + * RFC-029: Wiki page citation mapper — bidirectional page↔chunk queries. + */ +@Mapper +public interface WikiPageCitationMapper extends BaseMapper { + + @Select("SELECT c.id, c.page_id, c.chunk_id, wc.raw_id, " + + "c.paragraph_idx, c.anchor_text, c.confidence, " + + "rm.title AS raw_title, " + + "wc.ordinal AS chunk_ordinal, " + + "wc.start_offset, wc.end_offset, " + + "SUBSTRING(wc.content, 1, 200) AS snippet " + + "FROM mate_wiki_page_citation c " + + "JOIN mate_wiki_chunk wc ON c.chunk_id = wc.id " + + "LEFT JOIN mate_wiki_raw_material rm ON wc.raw_id = rm.id " + + "WHERE c.page_id = #{pageId} AND c.deleted = 0") + List listWithRawByPageId(@Param("pageId") Long pageId); + + @Select("SELECT page_id FROM mate_wiki_page_citation " + + "WHERE chunk_id = #{chunkId} AND deleted = 0") + List listPageIdsByChunkId(@Param("chunkId") Long chunkId); + + @Select("SELECT DISTINCT c.page_id FROM mate_wiki_page_citation c " + + "JOIN mate_wiki_chunk wc ON c.chunk_id = wc.id " + + "WHERE wc.raw_id = #{rawId} AND c.deleted = 0 AND wc.deleted = 0") + List listPageIdsByRawId(@Param("rawId") Long rawId); + + @Update("UPDATE mate_wiki_page_citation SET deleted = 1 WHERE page_id = #{pageId}") + void softDeleteByPageId(@Param("pageId") Long pageId); + + @Select("") + List listByChunkIds(@Param("chunkIds") Collection chunkIds); +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java index 87bae508..333d1a70 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java @@ -4,12 +4,14 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import vip.mate.wiki.dto.WikiPageLite; import vip.mate.wiki.model.WikiPageEntity; +import java.util.Collection; import java.util.List; /** - * Wiki 页面 Mapper + * Wiki page mapper * * @author MateClaw Team */ @@ -17,14 +19,65 @@ import java.util.List; public interface WikiPageMapper extends BaseMapper { /** - * DB 级别关键词搜索(H2 + MySQL 通用 LIKE)。 - * 不 SELECT content CLOB,避免全量加载到 Java 内存。 + * DB keyword search (H2 + MySQL compatible LIKE). + * Does not SELECT content CLOB to avoid loading large blobs into Java heap. */ - @Select("SELECT id, kb_id, slug, title, summary, source_raw_ids, last_updated_by " + + @Select("SELECT id, kb_id, slug, title, summary, source_raw_ids, last_updated_by, page_type " + "FROM mate_wiki_page " + - "WHERE kb_id = #{kbId} AND deleted = 0 " + + "WHERE kb_id = #{kbId} AND deleted = 0 AND archived = 0 " + "AND (LOWER(title) LIKE #{pattern} OR LOWER(summary) LIKE #{pattern} " + " OR LOWER(content) LIKE #{pattern}) " + "ORDER BY title LIMIT 20") List searchByKeyword(@Param("kbId") Long kbId, @Param("pattern") String pattern); + + // ==================== RFC-029: Relation model ==================== + + /** + * Batch-fetch lightweight page projections by IDs (no content). + */ + @Select("") + List selectBatchLite(@Param("ids") Collection ids); + + /** + * List all pages as lightweight projections (no content). + */ + @Select("SELECT id, slug, title, summary, page_type AS pageType FROM mate_wiki_page " + + "WHERE kb_id = #{kbId} AND deleted = 0 AND archived = 0 ORDER BY update_time DESC") + List selectAllLite(@Param("kbId") Long kbId); + + /** + * Fetch only the content column for a single page (lazy-load for snippet extraction). + */ + @Select("SELECT content FROM mate_wiki_page WHERE id = #{id} AND deleted = 0") + String selectContentById(@Param("id") Long id); + + // ==================== RFC-032: Two-phase keyword search ==================== + + /** + * Phase 1 (fast): search only title + summary columns. + */ + @Select("SELECT id FROM mate_wiki_page " + + "WHERE kb_id = #{kbId} AND deleted = 0 AND archived = 0 " + + "AND (LOWER(title) LIKE #{kw} OR LOWER(summary) LIKE #{kw}) " + + "LIMIT #{limit}") + List searchFastIds(@Param("kbId") Long kbId, + @Param("kw") String kw, + @Param("limit") int limit); + + /** + * Phase 2 (slow): search full content, excluding already-found IDs. + */ + @Select("") + List searchContentIds(@Param("kbId") Long kbId, + @Param("kw") String kw, + @Param("excludeIds") List excludeIds, + @Param("limit") int limit); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiProcessingJobMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiProcessingJobMapper.java new file mode 100644 index 00000000..8fbbacae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiProcessingJobMapper.java @@ -0,0 +1,42 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; + +import java.util.List; +import java.util.Optional; + +/** + * RFC-030: Wiki processing job mapper. + */ +@Mapper +public interface WikiProcessingJobMapper extends BaseMapper { + + @Select("SELECT * FROM mate_wiki_processing_job " + + "WHERE kb_id = #{kbId} AND status = 'queued' AND deleted = 0 " + + "ORDER BY create_time ASC LIMIT #{limit}") + List listQueued(@Param("kbId") Long kbId, @Param("limit") int limit); + + @Select("SELECT * FROM mate_wiki_processing_job " + + "WHERE raw_id = #{rawId} AND deleted = 0 ORDER BY create_time DESC LIMIT 1") + Optional findLatestByRawId(@Param("rawId") Long rawId); + + /** + * List all non-deleted jobs for a KB (for stats/dashboard queries). + */ + @Select("SELECT * FROM mate_wiki_processing_job " + + "WHERE kb_id = #{kbId} AND deleted = 0 " + + "ORDER BY create_time DESC LIMIT #{limit}") + List listByKbId(@Param("kbId") Long kbId, @Param("limit") int limit); + + /** + * Recover stuck jobs on startup: reset routing/*_running stages back to queued. + */ + @Update("UPDATE mate_wiki_processing_job " + + "SET status = 'queued', " + + " stage = COALESCE(resume_from_stage, 'queued'), " + + " update_time = CURRENT_TIMESTAMP(3) " + + "WHERE (stage = 'routing' OR stage LIKE '%_running') AND deleted = 0") + int recoverStuckJobs(); +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java index 2f4ae307..777df692 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java @@ -2,13 +2,27 @@ package vip.mate.wiki.repository; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import vip.mate.wiki.dto.RawTitleRef; import vip.mate.wiki.model.WikiRawMaterialEntity; +import java.util.Collection; +import java.util.List; + /** - * Wiki 原始材料 Mapper + * Wiki raw material mapper * * @author MateClaw Team */ @Mapper public interface WikiRawMaterialMapper extends BaseMapper { + + /** + * RFC-032: Batch-fetch raw material titles by IDs (fixes N+1 in wiki_semantic_search). + */ + @Select("") + List selectBatchTitles(@Param("ids") Collection ids); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/retrieval/SnippetExtractor.java b/mateclaw-server/src/main/java/vip/mate/wiki/retrieval/SnippetExtractor.java new file mode 100644 index 00000000..beabedb5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/retrieval/SnippetExtractor.java @@ -0,0 +1,30 @@ +package vip.mate.wiki.retrieval; + +/** + * RFC-032: Extracts a context-aware snippet from page content + * around a query match point. + */ +public class SnippetExtractor { + + private static final int CONTEXT_CHARS = 150; + + /** + * Extract a snippet from content centered on the first occurrence of the query. + * If no exact match is found, returns the first ~300 characters. + */ + public static String extract(String content, String query) { + if (content == null || query == null) return null; + int idx = content.toLowerCase().indexOf(query.toLowerCase()); + if (idx < 0) { + return content.length() <= CONTEXT_CHARS * 2 + ? content + : content.substring(0, CONTEXT_CHARS * 2) + "..."; + } + int start = Math.max(0, idx - CONTEXT_CHARS); + int end = Math.min(content.length(), idx + query.length() + CONTEXT_CHARS); + String snippet = content.substring(start, end); + if (start > 0) snippet = "..." + snippet; + if (end < content.length()) snippet = snippet + "..."; + return snippet; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/DocumentPreprocessService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/DocumentPreprocessService.java new file mode 100644 index 00000000..82e89008 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/DocumentPreprocessService.java @@ -0,0 +1,261 @@ +package vip.mate.wiki.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.WikiChunkDraft; +import vip.mate.wiki.model.WikiRawMaterialEntity; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * RFC-051 PR-1c: produces {@link WikiChunkDraft}s from a raw material's + * extracted text, populating the structural metadata columns added in V39 + * ({@code page_number}, {@code token_count}, {@code header_breadcrumb}, + * {@code source_section}). + *

+ * Strategy is intentionally lightweight: + *

    + *
  • Pre-scan the normalized text once to build sparse maps from char + * offset → header breadcrumb and char offset → PDF page number.
  • + *
  • Reuse the existing sentence-boundary chunker (passed in as an + * interface from {@link WikiProcessingService}).
  • + *
  • For each chunk, look up the breadcrumb and page that were active at + * the chunk's {@code startOffset}.
  • + *
  • Estimate token count as {@code ceil(charCount / 4.0)}, matching the + * backfill heuristic so eager-vs-lazy and old-vs-new chunks share one + * scale until a real tokenizer lands in a follow-up.
  • + *
+ * + * No Tika integration: existing {@code DocumentExtractTool} already covers + * the binary formats and the metadata we need for chunks lives inside the + * extracted text, not in document properties. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DocumentPreprocessService { + + private final WikiContentNormalizer normalizer; + private final WikiProperties properties; + + // Markdown ATX heading: 1-6 leading '#', then a space, then heading text. Setext-style + // (=== / ---) headings are not handled — DocumentExtractTool emits ATX for everything. + private static final Pattern MARKDOWN_HEADING = Pattern.compile("^(#{1,6})\\s+(.+?)\\s*$"); + + // DocumentExtractTool inserts page markers for PDF text extraction. Match a line like + // `--- Page 12 ---` (case-insensitive, tolerant of extra whitespace). PPTX uses + // `--- Slide 4 ---`; treat both as a page-number-like signal. + private static final Pattern PAGE_MARKER = Pattern.compile( + "^\\s*-{2,}\\s*(?:Page|Slide)\\s+(\\d+)\\s*-{2,}\\s*$", + Pattern.CASE_INSENSITIVE); + + /** + * Chunker SPI: pluggable so a unit test (or PR-1c follow-up using a smarter + * splitter) can pass a different boundary algorithm without dragging the + * whole {@link WikiProcessingService} into preprocessing. + */ + public interface Chunker { + List split(String text); + } + + /** + * Build chunk drafts from already-normalized text. Caller is expected to + * have routed the raw text through {@link WikiContentNormalizer} first + * (see {@link #preprocess(WikiRawMaterialEntity, String, Chunker)}). + */ + public List buildDrafts(String text, Chunker chunker) { + if (text == null || text.isBlank()) return List.of(); + + HeaderIndex headerIndex = HeaderIndex.scan(text); + PageIndex pageIndex = PageIndex.scan(text); + + List windows = chunker.split(text); + List drafts = new ArrayList<>(windows.size()); + for (int[] win : windows) { + int start = win[0]; + int end = win[1]; + String content = text.substring(start, end); + int chars = content.length(); + int tokens = (int) Math.ceil(chars / 4.0); + + String breadcrumb = headerIndex.breadcrumbAt(start); + String section = headerIndex.sectionAt(start); // last segment of breadcrumb + Integer page = pageIndex.pageAt(start); + + drafts.add(new WikiChunkDraft(content, start, end, page, tokens, breadcrumb, section)); + } + return drafts; + } + + /** + * Convenience entry: normalize, then chunk, then attach metadata. Intended + * for the lazy ingest branch of {@link WikiProcessingService}. + */ + public List preprocess(WikiRawMaterialEntity raw, String extractedText, Chunker chunker) { + String normalized = normalizer.normalize(raw == null ? null : raw.getSourceType(), extractedText); + return buildDrafts(normalized, chunker); + } + + /** Returns the post-normalization text alone, for callers that need to chunk separately. */ + public String normalize(WikiRawMaterialEntity raw, String extractedText) { + return normalizer.normalize(raw == null ? null : raw.getSourceType(), extractedText); + } + + @SuppressWarnings("unused") // exposed for future callers; keeps the property accessible. + public WikiProperties properties() { + return properties; + } + + // ──────────────────────────────────────────────────────────────────────── + // Header index — precomputed list of (lineStartOffset, breadcrumb, deepestHeading) + // sorted by offset. Lookup uses binary search. + // ──────────────────────────────────────────────────────────────────────── + + private static final class HeaderIndex { + private final int[] starts; + private final String[] breadcrumbs; + private final String[] deepest; + + private HeaderIndex(int[] starts, String[] breadcrumbs, String[] deepest) { + this.starts = starts; + this.breadcrumbs = breadcrumbs; + this.deepest = deepest; + } + + static HeaderIndex scan(String text) { + List offsets = new ArrayList<>(); + List crumbs = new ArrayList<>(); + List last = new ArrayList<>(); + Deque stack = new ArrayDeque<>(); // each entry: [level, title] + + int n = text.length(); + int i = 0; + while (i < n) { + int lineEnd = text.indexOf('\n', i); + if (lineEnd < 0) lineEnd = n; + String line = text.substring(i, lineEnd); + Matcher m = MARKDOWN_HEADING.matcher(line); + if (m.matches()) { + int level = m.group(1).length(); + String title = m.group(2).trim(); + // Pop deeper-or-equal levels. + while (!stack.isEmpty() && Integer.parseInt(stack.peek()[0]) >= level) { + stack.pop(); + } + stack.push(new String[]{Integer.toString(level), title}); + String breadcrumb = buildBreadcrumb(stack); + offsets.add(new int[]{i}); + crumbs.add(breadcrumb); + last.add(title); + } + i = lineEnd + 1; + } + + int[] starts = new int[offsets.size()]; + String[] breadcrumbs = new String[offsets.size()]; + String[] deepest = new String[offsets.size()]; + for (int k = 0; k < offsets.size(); k++) { + starts[k] = offsets.get(k)[0]; + breadcrumbs[k] = crumbs.get(k); + deepest[k] = last.get(k); + } + return new HeaderIndex(starts, breadcrumbs, deepest); + } + + private static String buildBreadcrumb(Deque stack) { + // Stack iterates top-first; build a root-first list for display. + List ordered = new ArrayList<>(stack); + StringBuilder sb = new StringBuilder(); + for (int k = ordered.size() - 1; k >= 0; k--) { + if (sb.length() > 0) sb.append(" / "); + sb.append(ordered.get(k)[1]); + } + // Cap at the column width of the DB column (1024). + return sb.length() > 1000 ? sb.substring(0, 1000) : sb.toString(); + } + + String breadcrumbAt(int offset) { + int idx = floorIndex(offset); + return idx < 0 ? null : breadcrumbs[idx]; + } + + String sectionAt(int offset) { + int idx = floorIndex(offset); + if (idx < 0) return null; + String s = deepest[idx]; + return s != null && s.length() > 500 ? s.substring(0, 500) : s; + } + + private int floorIndex(int offset) { + // Largest index with starts[idx] <= offset. + if (starts.length == 0 || offset < starts[0]) return -1; + int lo = 0, hi = starts.length - 1, ans = -1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + if (starts[mid] <= offset) { ans = mid; lo = mid + 1; } + else hi = mid - 1; + } + return ans; + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Page index — same shape as HeaderIndex but for "--- Page N ---" markers. + // ──────────────────────────────────────────────────────────────────────── + + private static final class PageIndex { + private final int[] starts; + private final int[] pages; + + private PageIndex(int[] starts, int[] pages) { + this.starts = starts; + this.pages = pages; + } + + static PageIndex scan(String text) { + List matches = new ArrayList<>(); + int n = text.length(); + int i = 0; + while (i < n) { + int lineEnd = text.indexOf('\n', i); + if (lineEnd < 0) lineEnd = n; + String line = text.substring(i, lineEnd); + Matcher m = PAGE_MARKER.matcher(line); + if (m.matches()) { + try { + int page = Integer.parseInt(m.group(1)); + matches.add(new int[]{i, page}); + } catch (NumberFormatException ignored) { + // overflow / not a number; ignore — page stays unknown for this region. + } + } + i = lineEnd + 1; + } + int[] starts = new int[matches.size()]; + int[] pages = new int[matches.size()]; + for (int k = 0; k < matches.size(); k++) { + starts[k] = matches.get(k)[0]; + pages[k] = matches.get(k)[1]; + } + return new PageIndex(starts, pages); + } + + Integer pageAt(int offset) { + if (starts.length == 0 || offset < starts[0]) return null; + int lo = 0, hi = starts.length - 1, ans = -1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + if (starts[mid] <= offset) { ans = mid; lo = mid + 1; } + else hi = mid - 1; + } + return ans < 0 ? null : pages[ans]; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java index afc1dc8f..ba9779f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java @@ -2,52 +2,81 @@ package vip.mate.wiki.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.PageSearchResult; +import vip.mate.wiki.dto.RelatedPageResult; +import vip.mate.wiki.dto.WikiPageLite; import vip.mate.wiki.model.WikiChunkEntity; import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.retrieval.SnippetExtractor; import java.util.*; import java.util.stream.Collectors; /** - * RFC-011: 混合检索服务 + * RFC-011 + RFC-032: Hybrid retrieval service. *

- * 支持三种模式: - *

    - *
  • {@code keyword} — DB LIKE 搜索(现有 WikiPageService.searchPages)
  • - *
  • {@code semantic} — chunk 向量 cosine 相似度 → 回溯到 page
  • - *
  • {@code hybrid} — 两者融合,RRF (Reciprocal Rank Fusion) 排名
  • - *
- * - * @author MateClaw Team + * Three search modes: keyword (DB LIKE), semantic (chunk vectors), + * hybrid (RRF fusion). RFC-032 adds: N+1 fix, two-phase keyword search, + * relation boost, snippet extraction, and PageSearchResult DTO. */ @Slf4j @Service -@RequiredArgsConstructor public class HybridRetriever { private final WikiPageService pageService; private final WikiChunkService chunkService; private final WikiEmbeddingService embeddingService; private final WikiProperties properties; + private final WikiPageMapper pageMapper; + + @Autowired(required = false) + private WikiRelationService relationService; + + private static final double RELATION_BOOST = 0.15; + + public HybridRetriever(WikiPageService pageService, + WikiChunkService chunkService, + WikiEmbeddingService embeddingService, + WikiProperties properties, + WikiPageMapper pageMapper) { + this.pageService = pageService; + this.chunkService = chunkService; + this.embeddingService = embeddingService; + this.properties = properties; + this.pageMapper = pageMapper; + } public enum Mode { KEYWORD, SEMANTIC, HYBRID } /** - * 搜索结果(页面级) + * Legacy page hit record (kept for backward compatibility). */ public record PageHit(Long pageId, String slug, String title, String summary, double score) {} /** - * 搜索结果(chunk 级,语义搜索专用) + * Chunk-level search result (semantic search). + *

+ * RFC-051 PR-1c: {@code pageNumber} and {@code headerBreadcrumb} are + * populated when the chunk has those columns set (lazy ingest with + * preprocessor on, or backfilled chunks). Both are nullable. */ - public record ChunkHit(Long chunkId, Long rawId, String snippet, float score) {} + public record ChunkHit(Long chunkId, Long rawId, String snippet, float score, + Integer pageNumber, String headerBreadcrumb) { + + /** Backwards-compatible factory for callers that don't yet pass metadata. */ + public ChunkHit(Long chunkId, Long rawId, String snippet, float score) { + this(chunkId, rawId, snippet, score, null, null); + } + } /** - * 执行混合搜索,返回页面级结果 + * RFC-032: Enhanced search returning PageSearchResult with snippet and matchedBy metadata. */ - public List searchPages(Long kbId, String query, String modeStr, int topK) { + public List search(Long kbId, String query, String modeStr, int topK) { Mode mode = parseMode(modeStr); List semantic = List.of(); @@ -60,9 +89,7 @@ public class HybridRetriever { keyword = keywordSearch(kbId, query, topK * 3); } - // 如果 semantic 不可用(no embedding model),回退到 keyword if (mode == Mode.SEMANTIC && semantic.isEmpty()) { - log.debug("[HybridRetriever] Semantic unavailable, falling back to keyword"); keyword = keywordSearch(kbId, query, topK * 3); } @@ -75,21 +102,60 @@ public class HybridRetriever { fused = rrfFuse(semantic, keyword, 60); } - // 取 topK,装配 PageHit - return fused.stream() - .limit(topK) - .map(ri -> { - WikiPageEntity page = pageService.getById(ri.pageId); - if (page == null) return null; - return new PageHit(ri.pageId, page.getSlug(), page.getTitle(), - page.getSummary(), ri.score); - }) - .filter(Objects::nonNull) - .toList(); + // RFC-032: Relation boost (1-hop expansion on top-3 seeds) + fused = applyRelationBoost(fused, kbId, topK); + + // Batch-fetch page info (N+1 fix) + List topIds = fused.stream().limit(topK).map(ri -> ri.pageId).toList(); + if (topIds.isEmpty()) return List.of(); + + Map liteMap = pageMapper.selectBatchLite(topIds) + .stream().collect(Collectors.toMap(WikiPageLite::id, p -> p)); + + // Build result with snippets + List results = new ArrayList<>(); + for (RankedItem ri : fused.stream().limit(topK).toList()) { + WikiPageLite lite = liteMap.get(ri.pageId); + if (lite == null) continue; + // RFC-051 PR-2: hide system pages (overview / log) from search results. + if (lite.isSystem()) continue; + + String snippet = null; + if (!ri.matchedBy.contains("relation_boost")) { + String content = pageMapper.selectContentById(ri.pageId); + if (content != null) { + snippet = SnippetExtractor.extract(content, query); + } + } + + String reason = buildReason(lite, ri.matchedBy, query); + // RFC-051 §9.4: when the entry came from relation boost, override / append + // the reason with the seed slug + dominant signals so callers can explain + // why an out-of-search-corpus page surfaced. + if (ri.relationReason() != null && !ri.relationReason().isBlank()) { + reason = (reason == null || reason.isBlank()) + ? ri.relationReason() + : reason + " · " + ri.relationReason(); + } + results.add(new PageSearchResult( + lite.slug(), lite.title(), lite.summary(), + snippet != null ? snippet : lite.summary(), + ri.matchedBy, reason, ri.score)); + } + return results; } /** - * chunk 级语义搜索(Agent 直接拿 chunk 片段作为证据) + * Legacy searchPages — returns PageHit for backward compatibility. + */ + public List searchPages(Long kbId, String query, String modeStr, int topK) { + return search(kbId, query, modeStr, topK).stream() + .map(r -> new PageHit(null, r.slug(), r.title(), r.summary(), r.score())) + .toList(); + } + + /** + * Chunk-level semantic search. */ public List searchChunks(Long kbId, String query, int topK) { if (!embeddingService.isAvailable()) return List.of(); @@ -107,16 +173,17 @@ public class HybridRetriever { String snippet = c.getContent().length() > 300 ? c.getContent().substring(0, 300) + "..." : c.getContent(); - return new ChunkHit(c.getId(), c.getRawId(), snippet, score); + return new ChunkHit(c.getId(), c.getRawId(), snippet, score, + c.getPageNumber(), c.getHeaderBreadcrumb()); }) .sorted(Comparator.comparingDouble(ChunkHit::score).reversed()) .limit(topK) .toList(); } - // ==================== 内部方法 ==================== + // ==================== Internal methods ==================== - /** 语义搜索:chunk cosine → 聚合到 page(同页多 chunk 取最高分) */ + /** Semantic search: chunk cosine → aggregate to page level */ private List semanticSearch(Long kbId, String query, int limit) { float[] queryVec = embeddingService.embedQuery(kbId, query); if (queryVec == null) return List.of(); @@ -124,25 +191,19 @@ public class HybridRetriever { List allChunks = chunkService.listByKbId(kbId); if (allChunks.isEmpty()) return List.of(); - // chunk → score, 然后 需要映射到 page。 - // 当前没有 chunk → page 的直接关联(chunk 只有 rawId)。 - // 走 rawId → 找该 rawId 对应的所有 page(source_raw_ids 含该 rawId) - // 这是个近似:一个 rawId 可能产出多个 page,都算命中。 Map chunkScores = new HashMap<>(); for (WikiChunkEntity chunk : allChunks) { - if (chunk.getEmbedding() == null) continue; + if (chunk.getEmbedding() == null || chunk.getRawId() == null) continue; float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding()); float score = WikiEmbeddingService.cosine(queryVec, vec); - chunkScores.merge(chunk.getRawId(), score, Math::max); // rawId 级聚合 + chunkScores.merge(chunk.getRawId(), score, Math::max); } - // rawId → page IDs List allPages = pageService.listByKbId(kbId); Map pageScores = new HashMap<>(); for (WikiPageEntity page : allPages) { String rawIds = page.getSourceRawIds(); if (rawIds == null) continue; - // 解析 "[1,2,3]" 格式 for (String rawIdStr : rawIds.replaceAll("[\\[\\]\\s]", "").split(",")) { try { long rawId = Long.parseLong(rawIdStr.trim()); @@ -157,32 +218,144 @@ public class HybridRetriever { return pageScores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(limit) - .map(e -> new RankedItem(e.getKey(), e.getValue())) + .map(e -> new RankedItem(e.getKey(), e.getValue(), List.of("semantic"))) .toList(); } - /** 关键词搜索:走现有 DB LIKE */ + /** + * RFC-032: Two-phase keyword search — fast path (title+summary) first, + * full content search only if needed to fill topK. + */ private List keywordSearch(Long kbId, String query, int limit) { - List results = pageService.searchPages(kbId, query); + String kw = "%" + query.toLowerCase() + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + "%"; + + // Phase 1: fast path (title + summary only) + List fastIds = pageMapper.searchFastIds(kbId, kw, limit); + List ranked = new ArrayList<>(); - for (int i = 0; i < Math.min(results.size(), limit); i++) { - // LIKE 无分数,用倒序排名作为伪分数 - ranked.add(new RankedItem(results.get(i).getId(), 1.0 / (i + 1))); + for (int i = 0; i < fastIds.size(); i++) { + ranked.add(new RankedItem(fastIds.get(i), 1.0 / (i + 1), List.of("title"))); } + + if (fastIds.size() >= limit) return ranked; + + // Phase 2: full content search (supplement) + List contentIds = pageMapper.searchContentIds(kbId, kw, fastIds, limit - fastIds.size()); + for (int i = 0; i < contentIds.size(); i++) { + ranked.add(new RankedItem(contentIds.get(i), + 1.0 / (fastIds.size() + i + 1), List.of("content"))); + } + return ranked; } - /** RRF 融合:score = Σ 1/(k + rank_i) */ + /** RRF fusion: score = Σ 1/(k + rank_i) */ private List rrfFuse(List a, List b, int k) { Map fused = new HashMap<>(); - for (int i = 0; i < a.size(); i++) fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum); - for (int i = 0; i < b.size(); i++) fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum); + Map> matchedByMap = new HashMap<>(); + + for (int i = 0; i < a.size(); i++) { + fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum); + matchedByMap.computeIfAbsent(a.get(i).pageId, x -> new ArrayList<>()).addAll(a.get(i).matchedBy); + } + for (int i = 0; i < b.size(); i++) { + fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum); + matchedByMap.computeIfAbsent(b.get(i).pageId, x -> new ArrayList<>()).addAll(b.get(i).matchedBy); + } + return fused.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) - .map(e -> new RankedItem(e.getKey(), e.getValue())) + .map(e -> new RankedItem(e.getKey(), e.getValue(), + matchedByMap.getOrDefault(e.getKey(), List.of()).stream().distinct().toList())) .toList(); } + /** + * RFC-032: 1-hop relation boost on top-3 seed pages. + *

+ * RFC-051 §9.4 makes the boost magnitude data-driven instead of a flat + * constant when {@code mate.wiki.use-normalized-relation-boost} is on. + */ + private List applyRelationBoost(List hits, Long kbId, int topK) { + if (relationService == null || hits.isEmpty()) return hits; + + List seedIds = hits.stream().limit(3).map(h -> h.pageId).toList(); + // Per-candidate aggregate raw score (sum of contributions from each seed-relation + // pair) plus a remembered "best" reason — the seed that contributed the highest + // relation score and its dominant signals. Used for the human-readable reason + // surfaced via PageSearchResult.reason. + Map rawScoreMap = new HashMap<>(); + Map reasonMap = new HashMap<>(); + + for (Long seedId : seedIds) { + List seedLites = pageMapper.selectBatchLite(List.of(seedId)); + if (seedLites.isEmpty()) continue; + WikiPageLite seed = seedLites.get(0); + // RFC-051 PR-5: don't expand 1-hop neighborhood from a system page seed. + // Otherwise the overview / log neighborhood — typically every page that + // shares a raw with them — leaks into search results via boost. PR-2's + // result-emit filter drops the system pages themselves; this guard ensures + // we don't even use them as expansion roots. + if (seed.isSystem()) continue; + try { + relationService.relatedPages(kbId, seed.slug(), 3) + .forEach(r -> { + WikiPageEntity relPage = pageService.getBySlug(kbId, r.slug()); + if (relPage == null) return; + rawScoreMap.merge(relPage.getId(), r.score(), Double::sum); + // Keep the strongest single seed→neighbor pair as the reason. + RelationReasonRecord existing = reasonMap.get(relPage.getId()); + if (existing == null || r.score() > existing.contribution) { + reasonMap.put(relPage.getId(), + new RelationReasonRecord(seed.slug(), r.signals(), r.score())); + } + }); + } catch (Exception e) { + log.debug("[HybridRetriever] Relation boost failed for seed {}: {}", seed.slug(), e.getMessage()); + } + } + + Set existingIds = hits.stream().map(h -> h.pageId).collect(Collectors.toSet()); + rawScoreMap.keySet().removeAll(existingIds); + + if (rawScoreMap.isEmpty()) return hits; + + // Choose boost magnitude per candidate: legacy flat constant or normalized × λ. + Map boostMap = new HashMap<>(); + if (properties != null && properties.isUseNormalizedRelationBoost()) { + double maxRaw = rawScoreMap.values().stream().mapToDouble(Double::doubleValue).max().orElse(0.0); + double lambda = Math.max(0, properties.getRelationBoostLambda()); + if (maxRaw <= 0 || lambda <= 0) { + rawScoreMap.forEach((pid, raw) -> boostMap.put(pid, 0.0)); + } else { + final double maxRawF = maxRaw; + rawScoreMap.forEach((pid, raw) -> boostMap.put(pid, (raw / maxRawF) * lambda)); + } + } else { + rawScoreMap.forEach((pid, raw) -> boostMap.put(pid, RELATION_BOOST)); + } + + List expanded = new ArrayList<>(hits); + boostMap.forEach((pid, score) -> { + RelationReasonRecord rr = reasonMap.get(pid); + String reason = rr == null ? null : formatRelationReason(rr); + expanded.add(new RankedItem(pid, score, List.of("relation_boost"), reason)); + }); + return expanded; + } + + private String buildReason(WikiPageLite lite, List matchedBy, String query) { + if (matchedBy.contains("relation_boost")) return "Structurally related to top search results"; + if (matchedBy.contains("title") && matchedBy.contains("semantic")) return "Title and semantic match"; + if (matchedBy.contains("title")) return "Title match"; + if (matchedBy.contains("semantic")) return "Semantic similarity"; + if (matchedBy.contains("content")) return "Content match"; + return "Keyword match"; + } + private Mode parseMode(String mode) { if (mode == null || mode.isBlank()) { String defaultMode = properties.getSearchDefaultMode(); @@ -199,5 +372,27 @@ public class HybridRetriever { }; } - private record RankedItem(Long pageId, double score) {} + /** + * RFC-051 §9.4: optional human-readable explanation for relation boost + * entries. {@code null} when this RankedItem wasn't produced by the + * relation pass. + */ + private record RankedItem(Long pageId, double score, List matchedBy, String relationReason) { + /** Back-compat ctor — keyword/semantic items don't carry a relation reason. */ + RankedItem(Long pageId, double score, List matchedBy) { + this(pageId, score, matchedBy, null); + } + } + + /** Internal: which seed/signals contributed the strongest relation pull to a candidate. */ + private record RelationReasonRecord(String seedSlug, List signals, double contribution) {} + + private static String formatRelationReason(RelationReasonRecord r) { + if (r == null) return null; + StringBuilder sb = new StringBuilder("related to '").append(r.seedSlug()).append("'"); + if (r.signals() != null && !r.signals().isEmpty()) { + sb.append(" via ").append(String.join("+", r.signals())); + } + return sb.toString(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java new file mode 100644 index 00000000..a18ebd17 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java @@ -0,0 +1,109 @@ +package vip.mate.wiki.service; + +import java.util.ArrayList; +import java.util.List; + +/** + * RFC-047 P1: Stateful parser for the BatchCreate LLM response format. + *

+ * Expected format: + *

+ * ---FILE: slug-one---
+ * {"slug":"slug-one","title":"Title One","summary":"...","content":"..."}
+ * ---END FILE---
+ *
+ * ---FILE: slug-two---
+ * {"slug":"slug-two","title":"Title Two","summary":"...","content":"..."}
+ * ---END FILE---
+ * 
+ *

+ * Design notes: + * - One FILE block per page; JSON payload is a single object (not NDJSON). + * - Malformed blocks (missing END FILE, blank JSON body) are skipped with a warning. + * - Trailing / leading whitespace within a block is trimmed before JSON parse. + * - Extra text outside FILE blocks (preamble, commentary) is silently ignored. + */ +public class WikiBatchCreateParser { + + /** Parsed page from a single FILE block. */ + public record ParsedPage(String slug, String rawJson) {} + + private static final String FILE_START_PREFIX = "---FILE:"; + private static final String FILE_END_MARKER = "---END FILE---"; + + /** + * Parse the full BatchCreate LLM response into a list of parsed pages. + * Never throws — malformed blocks produce warnings but parsing continues. + * + * @param response raw LLM text output + * @return ordered list of successfully parsed pages (may be empty) + */ + public List parse(String response) { + List result = new ArrayList<>(); + if (response == null || response.isBlank()) { + return result; + } + + String[] lines = response.split("\n", -1); + State state = State.OUTSIDE; + String currentSlug = null; + StringBuilder bodyBuffer = null; + + for (String rawLine : lines) { + String line = rawLine.stripTrailing(); + + switch (state) { + case OUTSIDE -> { + if (isFileStart(line)) { + currentSlug = extractSlug(line); + bodyBuffer = new StringBuilder(); + state = State.INSIDE; + } + // Everything else outside blocks is silently ignored + } + case INSIDE -> { + if (line.trim().equals(FILE_END_MARKER)) { + String json = bodyBuffer.toString().strip(); + if (!json.isBlank() && currentSlug != null) { + result.add(new ParsedPage(currentSlug, json)); + } + currentSlug = null; + bodyBuffer = null; + state = State.OUTSIDE; + } else if (isFileStart(line)) { + // New FILE block without END FILE — previous block is malformed; start fresh + currentSlug = extractSlug(line); + bodyBuffer = new StringBuilder(); + // Stay in INSIDE state + } else { + bodyBuffer.append(line).append('\n'); + } + } + } + } + + // Unclosed block at EOF — discard + return result; + } + + private boolean isFileStart(String line) { + return line.startsWith(FILE_START_PREFIX); + } + + /** + * Extract slug from a line like {@code ---FILE: slug-name---}. + * Returns the text between the first colon+space and the trailing "---" (or end of line). + */ + private String extractSlug(String line) { + // line starts with "---FILE:" + int colonIdx = line.indexOf(':'); + if (colonIdx < 0) return ""; + String after = line.substring(colonIdx + 1).strip(); + if (after.endsWith("---")) { + after = after.substring(0, after.length() - 3).strip(); + } + return after; + } + + private enum State { OUTSIDE, INSIDE } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiChunkService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiChunkService.java index a29af317..c1286145 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiChunkService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiChunkService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.wiki.dto.WikiChunkDraft; import vip.mate.wiki.model.WikiChunkEntity; import vip.mate.wiki.repository.WikiChunkMapper; @@ -55,6 +56,125 @@ public class WikiChunkService { return reconcile(kbId, rawId, chunks, offsets, existing); } + /** + * RFC-051 PR-1a: persist chunks along with their structural metadata + * (page number, token count, header breadcrumb, source section). + *

+ * Behaves like {@link #persistChunks(Long, Long, List, List)} — full insert + * when no existing chunks for the raw, otherwise hash-based reconcile so + * unchanged chunks keep their embeddings. Reconcile updates the metadata + * columns on retained rows so re-running ingest with a richer normalizer + * can fill in fields that were null on the previous pass. + * + * @param drafts chunks-to-persist with metadata (ordered, ordinal == index) + * @return persisted chunk IDs (same order as {@code drafts}) + */ + @Transactional + public List persistChunks(Long kbId, Long rawId, List drafts) { + List existing = listByRawId(rawId); + + if (existing.isEmpty()) { + return insertAllDrafts(kbId, rawId, drafts); + } + return reconcileDrafts(kbId, rawId, drafts, existing); + } + + private List insertAllDrafts(Long kbId, Long rawId, List drafts) { + List ids = new ArrayList<>(drafts.size()); + for (int i = 0; i < drafts.size(); i++) { + WikiChunkDraft draft = drafts.get(i); + String hash = computeHash(draft.content()); + WikiChunkEntity entity = buildEntityFromDraft(kbId, rawId, i, draft, hash); + chunkMapper.insert(entity); + ids.add(entity.getId()); + } + log.info("[WikiChunk] Inserted {} drafts for raw={}", drafts.size(), rawId); + return ids; + } + + private List reconcileDrafts(Long kbId, Long rawId, List drafts, + List existing) { + Map oldByOrdinal = new HashMap<>(); + for (WikiChunkEntity e : existing) { + oldByOrdinal.put(e.getOrdinal(), e); + } + + List resultIds = new ArrayList<>(drafts.size()); + Set retainedIds = new HashSet<>(); + int retained = 0, rebuilt = 0; + + for (int i = 0; i < drafts.size(); i++) { + WikiChunkDraft draft = drafts.get(i); + String hash = computeHash(draft.content()); + WikiChunkEntity old = oldByOrdinal.get(i); + + if (old != null && hash.equals(old.getContentHash())) { + // Same content: keep existing row (and its embedding). Refresh offsets and + // metadata so a re-run with a smarter normalizer can fill gaps. + boolean changed = false; + if (!Objects.equals(old.getStartOffset(), draft.startOffset())) { + old.setStartOffset(draft.startOffset()); changed = true; + } + if (!Objects.equals(old.getEndOffset(), draft.endOffset())) { + old.setEndOffset(draft.endOffset()); changed = true; + } + if (!Objects.equals(old.getPageNumber(), draft.pageNumber())) { + old.setPageNumber(draft.pageNumber()); changed = true; + } + if (!Objects.equals(old.getTokenCount(), draft.tokenCount())) { + old.setTokenCount(draft.tokenCount()); changed = true; + } + if (!Objects.equals(old.getHeaderBreadcrumb(), draft.headerBreadcrumb())) { + old.setHeaderBreadcrumb(draft.headerBreadcrumb()); changed = true; + } + if (!Objects.equals(old.getSourceSection(), draft.sourceSection())) { + old.setSourceSection(draft.sourceSection()); changed = true; + } + if (changed) { + chunkMapper.updateById(old); + } + resultIds.add(old.getId()); + retainedIds.add(old.getId()); + retained++; + } else { + WikiChunkEntity entity = buildEntityFromDraft(kbId, rawId, i, draft, hash); + chunkMapper.insert(entity); + resultIds.add(entity.getId()); + rebuilt++; + } + } + + int deleted = 0; + for (WikiChunkEntity old : existing) { + if (!retainedIds.contains(old.getId()) && !resultIds.contains(old.getId())) { + chunkMapper.deleteById(old.getId()); + deleted++; + } + } + + log.info("[WikiChunk] Reconciled drafts raw={}: retained={}, rebuilt={}, deleted={}", + rawId, retained, rebuilt, deleted); + return resultIds; + } + + private WikiChunkEntity buildEntityFromDraft(Long kbId, Long rawId, int ordinal, + WikiChunkDraft draft, String hash) { + WikiChunkEntity entity = new WikiChunkEntity(); + entity.setKbId(kbId); + entity.setRawId(rawId); + entity.setOrdinal(ordinal); + entity.setContent(draft.content()); + entity.setCharCount(draft.content().length()); + entity.setStartOffset(draft.startOffset()); + entity.setEndOffset(draft.endOffset()); + entity.setContentHash(hash); + entity.setPageNumber(draft.pageNumber()); + entity.setTokenCount(draft.tokenCount()); + entity.setHeaderBreadcrumb(draft.headerBreadcrumb()); + entity.setSourceSection(draft.sourceSection()); + return entity; + } + /** * 增量对账:比对 hash,保留不变的 chunk(保护未来的 embedding),重建变化的。 * diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCitationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCitationService.java new file mode 100644 index 00000000..49d1426f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCitationService.java @@ -0,0 +1,104 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiPageCitationEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.repository.WikiPageMapper; + +import java.math.BigDecimal; +import java.util.List; + +/** + * RFC-029: Builds citation records linking pages to their source chunks. + * Called asynchronously after page creation or update to avoid blocking + * the main processing pipeline. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiCitationService { + + private final WikiPageMapper pageMapper; + private final WikiChunkService chunkService; + private final WikiPageCitationMapper citationMapper; + private final ObjectMapper objectMapper; + + @Async + public void buildCitationsAsync(Long pageId, Long kbId) { + buildCitations(pageId, kbId); + } + + /** + * Rebuild all citation records for a page based on its sourceRawIds. + * Soft-deletes existing citations first, then creates new ones for + * all chunks belonging to the page's source raw materials. + */ + public void buildCitations(Long pageId, Long kbId) { + WikiPageEntity page = pageMapper.selectById(pageId); + if (page == null) return; + + List rawIds = parseRawIds(page.getSourceRawIds()); + citationMapper.softDeleteByPageId(pageId); + + for (Long rawId : rawIds) { + List chunks = chunkService.listByRawId(rawId); + for (WikiChunkEntity chunk : chunks) { + WikiPageCitationEntity citation = new WikiPageCitationEntity(); + citation.setPageId(pageId); + citation.setChunkId(chunk.getId()); + citation.setConfidence(BigDecimal.ONE); + citation.setCreatedBy("system"); + citationMapper.insert(citation); + } + } + + log.debug("[WikiCitation] Built citations for pageId={}, kbId={}", pageId, kbId); + } + + /** + * RFC-051 PR-4: rebuild citations from a specific list of evidence chunks + * rather than the union of all chunks for the page's source raws. Used by + * {@code WikiCompileService} so on-demand pages cite only the chunks the + * compile prompt actually saw — keeping relation/citation signals clean. + *

+ * Falls back to the raw-level rebuild if the evidence list is null/empty, + * so callers don't have to special-case "no evidence found". + */ + public void buildCitations(Long pageId, Long kbId, List evidenceChunkIds) { + if (evidenceChunkIds == null || evidenceChunkIds.isEmpty()) { + buildCitations(pageId, kbId); + return; + } + WikiPageEntity page = pageMapper.selectById(pageId); + if (page == null) return; + + citationMapper.softDeleteByPageId(pageId); + for (Long chunkId : evidenceChunkIds) { + WikiPageCitationEntity citation = new WikiPageCitationEntity(); + citation.setPageId(pageId); + citation.setChunkId(chunkId); + citation.setConfidence(BigDecimal.ONE); + citation.setCreatedBy("compile"); + citationMapper.insert(citation); + } + log.info("[WikiCitation] Built {} evidence citations for pageId={}, kbId={}", + evidenceChunkIds.size(), pageId, kbId); + } + + private List parseRawIds(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("[WikiCitation] Failed to parse sourceRawIds: {}", e.getMessage()); + return List.of(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCompileService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCompileService.java new file mode 100644 index 00000000..f0541bea --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCompileService.java @@ -0,0 +1,217 @@ +package vip.mate.wiki.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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiChunkMapper; + +import java.util.ArrayList; +import java.util.List; + +/** + * RFC-051 PR-4: on-demand page compilation. + *

+ * Given a topic (or an explicit {@code slug}), search for the most relevant + * chunks via {@link HybridRetriever}, build a short evidence pack from the + * top hits, and ask the LLM for a single Markdown page. The resulting page + * is persisted and its citations are bound to the evidence chunk IDs only — + * not to every chunk of the source raw — so relation signals stay clean. + *

+ * This is the bridge between lazy ingest (where 0 pages is the steady state) + * and the user/agent saying "now produce a page about X". + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiCompileService { + + private final HybridRetriever hybridRetriever; + private final WikiChunkMapper chunkMapper; + private final WikiPageService pageService; + private final WikiCitationService citationService; + private final ObjectMapper objectMapper; + + /** + * Optional. When wired we use the routing chain (stepModels[CREATE_PAGE] + * -> wikiDefaultModelId -> system default); otherwise the caller's + * pre-built {@link ChatModel} factory wins. + */ + @Autowired(required = false) + private WikiModelRoutingService modelRoutingService; + + /** RFC-051 PR-2b/2c: optional overview rebuilder + log appender. */ + @Autowired(required = false) + private WikiOverviewService overviewService; + + @Autowired(required = false) + private WikiLogService logService; + + /** + * Compile outcome. + *

    + *
  • {@code pageId}, {@code slug}, {@code title} non-null → page produced.
  • + *
  • {@code evidenceChunkCount == 0} → no chunks matched the topic; + * {@code pageId/slug/title} all null. Caller (agent) should fall + * back to {@code wiki_search_pages} or report no source material.
  • + *
+ */ + public record CompileResult(Long pageId, String slug, String title, int evidenceChunkCount, + boolean created) { + + public static CompileResult noEvidence() { + return new CompileResult(null, null, null, 0, false); + } + } + + /** + * Compile or update a single page on the topic. + * + * @param kbId knowledge base id + * @param topic natural-language topic; required + * @param slug optional explicit slug; auto-derived from topic when null/blank + * @param maxEvidenceChunks evidence pack cap (defaults to 8 when null) + * @return result with the persisted page id and how many chunks were cited + */ + public CompileResult compilePage(Long kbId, String topic, String slug, Integer maxEvidenceChunks) { + if (kbId == null) throw new IllegalArgumentException("kbId is required"); + if (topic == null || topic.isBlank()) throw new IllegalArgumentException("topic is required"); + int cap = (maxEvidenceChunks == null || maxEvidenceChunks <= 0) + ? 8 : Math.min(20, maxEvidenceChunks); + + // 1. Retrieve evidence chunks via semantic search (hybrid retriever). + List hits = hybridRetriever.searchChunks(kbId, topic, cap); + if (hits.isEmpty()) { + // Structured "nothing matched" result rather than throw — lets the + // tool surface respond with a clean message instead of a stack trace. + log.info("[WikiCompile] No evidence chunks for topic='{}' kbId={}", topic, kbId); + return CompileResult.noEvidence(); + } + + List evidenceChunkIds = new ArrayList<>(hits.size()); + StringBuilder evidenceBlock = new StringBuilder(); + for (int i = 0; i < hits.size(); i++) { + HybridRetriever.ChunkHit hit = hits.get(i); + evidenceChunkIds.add(hit.chunkId()); + WikiChunkEntity chunk = chunkMapper.selectById(hit.chunkId()); + if (chunk == null || chunk.getContent() == null) continue; + evidenceBlock.append("### Evidence ").append(i + 1) + .append(" (chunk=").append(hit.chunkId()).append(")"); + if (hit.headerBreadcrumb() != null && !hit.headerBreadcrumb().isBlank()) { + evidenceBlock.append(" — ").append(hit.headerBreadcrumb()); + } + if (hit.pageNumber() != null) { + evidenceBlock.append(" (page ").append(hit.pageNumber()).append(")"); + } + evidenceBlock.append("\n\n").append(chunk.getContent()).append("\n\n"); + } + + // 2. Resolve slug + title. + String resolvedSlug = (slug == null || slug.isBlank()) ? WikiPageService.toSlug(topic) : slug; + if (resolvedSlug == null || resolvedSlug.isBlank()) { + resolvedSlug = "page-" + System.currentTimeMillis(); + } + WikiPageEntity existing = pageService.getBySlug(kbId, resolvedSlug); + if (existing != null && WikiPageService.isProtected(existing)) { + throw new IllegalStateException("Refusing to compile over protected page: " + resolvedSlug); + } + + // 3. Build LLM prompt — prompt body lives in resources/prompts/wiki/compile-{system,user}.txt + // so we can iterate on phrasing without recompiling Java. + String system = PromptLoader.loadPrompt("wiki/compile-system"); + String user = PromptLoader.loadPrompt("wiki/compile-user") + .replace("{topic}", topic) + .replace("{evidence}", evidenceBlock.toString()); + + ChatModel chatModel = resolveChatModel(kbId); + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(system), new UserMessage(user)))); + if (resp == null || resp.getResult() == null + || resp.getResult().getOutput() == null + || resp.getResult().getOutput().getText() == null) { + throw new IllegalStateException("LLM returned no compile output"); + } + String body = resp.getResult().getOutput().getText(); + JsonNode parsed = parseJson(body); + if (parsed == null) { + throw new IllegalStateException("LLM compile output was not valid JSON"); + } + String title = parsed.path("title").asText(topic); + String summary = parsed.path("summary").asText(""); + String content = parsed.path("content").asText(""); + if (content.isBlank()) { + throw new IllegalStateException("LLM compile output missing content"); + } + + // 4. Persist (create or update via AI path). + WikiPageEntity persisted; + boolean created; + if (existing == null) { + persisted = pageService.createPage(kbId, resolvedSlug, title, content, summary, null); + created = true; + } else { + persisted = pageService.updatePageByAi(kbId, resolvedSlug, content, summary, null); + created = false; + if (persisted == null) persisted = existing; + } + + // 5. Bind evidence citations only. + try { + citationService.buildCitations(persisted.getId(), kbId, evidenceChunkIds); + } catch (Exception e) { + log.warn("[WikiCompile] Failed to attach evidence citations for pageId={}: {}", + persisted.getId(), e.getMessage()); + } + + log.info("[WikiCompile] {} page slug={} title='{}' from {} evidence chunks (kbId={})", + created ? "Created" : "Updated", resolvedSlug, title, evidenceChunkIds.size(), kbId); + + // RFC-051 PR-2c: log every compile attempt; PR-2b: refresh overview. + if (logService != null) { + logService.append(kbId, WikiLogService.EventType.COMPILE, + (created ? "compiled new page " : "recompiled page ") + resolvedSlug + + " · topic='" + topic + "' · " + evidenceChunkIds.size() + " evidence chunks"); + } + if (overviewService != null) overviewService.rebuild(kbId); + + return new CompileResult(persisted.getId(), resolvedSlug, title, evidenceChunkIds.size(), created); + } + + private ChatModel resolveChatModel(Long kbId) { + if (modelRoutingService == null) { + throw new IllegalStateException("ModelRoutingService unavailable; cannot compile"); + } + Long modelId = modelRoutingService.selectModelId(kbId, "compile_page", WikiJobStep.CREATE_PAGE); + return modelRoutingService.buildChatModel(modelId); + } + + private JsonNode parseJson(String text) { + if (text == null) return null; + try { + return objectMapper.readTree(text); + } catch (Exception ignored) { + int s = text.indexOf('{'); + int e = text.lastIndexOf('}'); + if (s >= 0 && e > s) { + try { + return objectMapper.readTree(text.substring(s, e + 1)); + } catch (Exception ignored2) { + return null; + } + } + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java new file mode 100644 index 00000000..7b9275bd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java @@ -0,0 +1,104 @@ +package vip.mate.wiki.service; + +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Component; + +/** + * RFC-051 PR-1c: source-type-specific text normalization. + *

+ * Goal is structural cleanup before chunking — strip noise from HTML, + * normalize line endings, trim duplicate whitespace — without touching the + * actual semantic content. Heading and page-marker extraction lives in + * {@link DocumentPreprocessService} because it needs the offsets of the + * surviving text, not just the text itself. + * + *

Tika is intentionally not pulled in: existing {@code DocumentExtractTool} + * already covers PDF/Office via pdftotext / pdfplumber / Java fallbacks. + */ +@Slf4j +@Component +public class WikiContentNormalizer { + + private static final int MAX_HTML_LEN = 8 * 1024 * 1024; // 8 MB safety cap + + /** + * Normalize raw text by source type. Returns the input unchanged when the + * source type is unknown so we never lose content silently. + * + * @param sourceType {@code WikiRawMaterialEntity.sourceType} value + * (text / pdf / docx / xlsx / pptx / url / paste / markdown) + * @param rawText extracted text content + * @return cleaned text, never {@code null} + */ + public String normalize(String sourceType, String rawText) { + if (rawText == null) return ""; + String type = sourceType == null ? "" : sourceType.toLowerCase(); + return switch (type) { + case "url", "html" -> normalizeHtml(rawText); + // PDF text from DocumentExtractTool may already contain "--- Page N ---" + // markers; we keep them so the preprocessor can map char offsets to pages. + case "pdf" -> collapseBlankLines(rawText); + case "docx", "pptx", "xlsx" -> collapseBlankLines(rawText); + case "markdown", "md", "text", "paste" -> collapseBlankLines(rawText); + default -> collapseBlankLines(rawText); + }; + } + + /** + * Strip nav/footer/script/style/aside and ad-like classes from HTML, then + * return readable text. Falls through to the raw input when the document + * is too large to parse safely or jsoup throws. + */ + private String normalizeHtml(String rawHtml) { + if (rawHtml.length() > MAX_HTML_LEN) { + log.warn("[WikiContentNormalizer] HTML payload exceeds {} bytes, skipping cleanup", MAX_HTML_LEN); + return collapseBlankLines(rawHtml); + } + try { + Document doc = Jsoup.parse(rawHtml); + // Drop structural noise. + doc.select("script, style, noscript, nav, header, footer, aside, form, iframe").remove(); + // Drop common ad / share / cookie banners by class hint. + Elements adNodes = doc.select( + "[class*=ad-], [class*=ads], [class^=ad_], [id*=ads], " + + "[class*=cookie-banner], [class*=share-], [class*=related-posts]"); + adNodes.remove(); + // Strip aria-hidden / display:none nodes — these are usually skip links / overlays. + for (Element hidden : doc.select("[aria-hidden=true], [hidden]")) hidden.remove(); + + // Convert to text. Jsoup .text() collapses whitespace; we want headings on + // their own lines so the preprocessor can detect them. Walk children manually + // to preserve heading boundaries. + StringBuilder sb = new StringBuilder(Math.min(rawHtml.length(), 256 * 1024)); + for (Element el : doc.body() != null ? doc.body().getAllElements() : doc.getAllElements()) { + String tag = el.tagName(); + String text = el.ownText(); + if (text.isBlank()) continue; + if (tag.matches("h[1-6]")) { + int level = Integer.parseInt(tag.substring(1)); + sb.append('\n').append("#".repeat(level)).append(' ').append(text.trim()).append('\n'); + } else { + sb.append(text.trim()).append('\n'); + } + } + String out = sb.toString(); + return out.isBlank() ? collapseBlankLines(rawHtml) : collapseBlankLines(out); + } catch (Exception e) { + log.warn("[WikiContentNormalizer] HTML parse failed, falling back to raw text: {}", e.getMessage()); + return collapseBlankLines(rawHtml); + } + } + + /** + * Collapse 3+ consecutive blank lines down to 2, normalize CRLF to LF. + * Cheap, lossless cleanup that helps chunk boundary detection. + */ + private String collapseBlankLines(String text) { + String unified = text.replace("\r\n", "\n").replace('\r', '\n'); + return unified.replaceAll("\n{3,}", "\n\n"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java index 819b5825..e366276c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java @@ -4,18 +4,17 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.PageSearchResult; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; -import java.util.ArrayList; import java.util.List; /** - * Wiki 上下文服务 + * Wiki context service — builds context for agent conversation injection. *

- * 为 Agent 对话构建 Wiki 知识库上下文,注入到系统提示词中。 - * - * @author MateClaw Team + * RFC-032: buildRelevantContext now delegates to HybridRetriever instead + * of using a custom keyword matching algorithm. */ @Slf4j @Service @@ -24,17 +23,14 @@ public class WikiContextService { private final WikiKnowledgeBaseService kbService; private final WikiPageService pageService; + private final HybridRetriever hybridRetriever; private final WikiProperties properties; /** - * 构建与用户消息相关的 Wiki 上下文(任务前知识注入) + * Build relevant wiki context for the current user message. *

- * 从用户消息中提取关键词,匹配 Wiki 页面的标题和摘要, - * 注入 top-3 相关页面的完整内容到 system prompt 中。 - * - * @param agentId Agent ID - * @param userMessage 用户当前消息 - * @return 相关 Wiki 页面内容,如果没有匹配则返回空字符串 + * RFC-032: Uses HybridRetriever for consistent search quality, + * returns snippet + reason instead of just summary. */ public String buildRelevantContext(Long agentId, String userMessage) { if (!properties.isEnabled() || userMessage == null || userMessage.isBlank()) { @@ -46,72 +42,48 @@ public class WikiContextService { return ""; } - // 从用户消息中提取关键词(简单分词:按非字母数字中文分割,过滤短词) - String[] keywords = userMessage.toLowerCase() - .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", " ") - .trim() - .split("\\s+"); - if (keywords.length == 0 || (keywords.length == 1 && keywords[0].isBlank())) { + Long kbId = kbs.get(0).getId(); + List hits = hybridRetriever.search(kbId, userMessage, "hybrid", 5); + if (hits.isEmpty()) { return ""; } - // 使用缓存的 listSummaries(不加载 content),按关键词评分 - record ScoredPage(WikiPageEntity page, int score) {} - List scored = new ArrayList<>(); - - for (WikiKnowledgeBaseEntity kb : kbs) { - List pages = pageService.listSummaries(kb.getId()); // 走缓存 - for (WikiPageEntity page : pages) { - String titleLower = page.getTitle() != null ? page.getTitle().toLowerCase() : ""; - String summaryLower = page.getSummary() != null ? page.getSummary().toLowerCase() : ""; - int score = 0; - for (String kw : keywords) { - if (kw.length() < 2) continue; - if (titleLower.contains(kw)) score += 3; - if (summaryLower.contains(kw)) score += 1; - } - if (score > 0) { - scored.add(new ScoredPage(page, score)); - } - } - } - - if (scored.isEmpty()) { - return ""; - } - - // 取 top-5 最相关页面,只注入摘要(不注入全文),受 token 预算限制 - scored.sort((a, b) -> Integer.compare(b.score, a.score)); - int topN = Math.min(5, scored.size()); - int maxChars = properties.getMaxContextChars(); + StringBuilder sb = new StringBuilder("\n"); + sb.append("[Relevant wiki pages for this query. Use wiki_read_page(slug) for full content. " + + "When using information from these pages in your answer, always cite the source page title, " + + "e.g. 「来源:[[页面标题]]」or「(来源:页面标题)」.]\n\n"); int totalChars = 0; + int maxChars = properties.getMaxContextChars(); - StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append("[Relevant wiki pages for this query. Use wiki_read_page(slug) for full content.]\n\n"); - for (int i = 0; i < topN; i++) { - WikiPageEntity page = scored.get(i).page; - String line = "- **" + page.getTitle() + "** (`" + page.getSlug() + "`)"; - if (page.getSummary() != null && !page.getSummary().isBlank()) { - line += " — " + page.getSummary(); - } - line += "\n"; - if (totalChars + line.length() > maxChars) { + for (PageSearchResult hit : hits) { + String entry = buildContextEntry(hit); + if (totalChars + entry.length() > maxChars) { sb.append("- ... (use wiki_search_pages for more)\n"); break; } - sb.append(line); - totalChars += line.length(); + sb.append(entry); + totalChars += entry.length(); } sb.append(""); return sb.toString(); } + private String buildContextEntry(PageSearchResult hit) { + StringBuilder entry = new StringBuilder(); + entry.append("- **[[").append(hit.slug()).append("]]** ").append(hit.title()).append("\n"); + String excerpt = hit.snippet() != null ? hit.snippet() : hit.summary(); + if (excerpt != null) { + entry.append(" ").append(excerpt).append("\n"); + } + if (hit.reason() != null && !hit.reason().isBlank()) { + entry.append(" Relevance: ").append(hit.reason()).append("\n"); + } + entry.append("\n"); + return entry.toString(); + } + /** - * 构建指定 Agent 关联的 Wiki 上下文 - * - * @param agentId Agent ID - * @return Wiki 上下文字符串,如果没有关联知识库或页面则返回空字符串 + * Build full wiki context for agent system prompt. */ public String buildWikiContext(Long agentId) { if (!properties.isEnabled()) { @@ -140,8 +112,6 @@ public class WikiContextService { } sb.append(" (").append(pages.size()).append(" pages)\n\n"); - // 小 KB(≤20 页)保留 summary(成本低且是唯一的语义线索) - // 大 KB(>20 页)紧凑模式(slug + title) boolean compact = pages.size() > 20; for (WikiPageEntity page : pages) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java index 48f2aefd..74aec2c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java @@ -47,6 +47,7 @@ public class WikiEmbeddingService { private final ModelConfigService modelConfigService; private final WikiKnowledgeBaseService kbService; private final SystemSettingMapper systemSettingMapper; + private final vip.mate.llm.service.ModelProviderService modelProviderService; /** 系统默认 embedding 模型的 mate_system_setting key */ public static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id"; @@ -87,15 +88,24 @@ public class WikiEmbeddingService { if (defaultId != null) { ModelConfigEntity model = safeGetModel(defaultId); if (isUsable(model)) { - return new Resolved(factory.build(model), model.getModelName()); + try { + return new Resolved(factory.build(model), model.getModelName()); + } catch (Exception e) { + log.warn("[WikiEmbedding] System default embedding model {} build failed: {}", defaultId, e.getMessage()); + } + } else { + log.warn("[WikiEmbedding] System default embedding model {} is unusable, falling back", defaultId); } - log.warn("[WikiEmbedding] System default embedding model {} is unusable, falling back", defaultId); } // 优先级 3:任意 enabled ModelConfigEntity anyEnabled = modelConfigService.findFirstEnabledEmbedding(); if (isUsable(anyEnabled)) { - return new Resolved(factory.build(anyEnabled), anyEnabled.getModelName()); + try { + return new Resolved(factory.build(anyEnabled), anyEnabled.getModelName()); + } catch (Exception e) { + log.warn("[WikiEmbedding] Fallback embedding model {} build failed: {}", anyEnabled.getId(), e.getMessage()); + } } log.warn("[WikiEmbedding] No usable embedding model configured. " @@ -355,9 +365,17 @@ public class WikiEmbeddingService { } private boolean isUsable(ModelConfigEntity model) { - return model != null - && Boolean.TRUE.equals(model.getEnabled()) - && "embedding".equals(model.getModelType()); + if (model == null || !Boolean.TRUE.equals(model.getEnabled()) + || !"embedding".equals(model.getModelType())) { + return false; + } + // Skip models whose provider lacks a valid API key (mirrors chat model path fix 341ad1f) + try { + return modelProviderService.isProviderConfigured(model.getProvider()); + } catch (Exception e) { + log.debug("[WikiEmbedding] Provider check failed for model {}: {}", model.getId(), e.getMessage()); + return false; + } } private Long readSystemDefaultEmbeddingId() { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java new file mode 100644 index 00000000..c0060166 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEnrichmentApplier.java @@ -0,0 +1,198 @@ +package vip.mate.wiki.service; + +import vip.mate.wiki.dto.EnrichmentPlan; +import vip.mate.wiki.dto.EnrichmentReplacement; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * RFC-051 PR-5b: validate and apply a replacement-plan enrichment without + * letting the LLM touch non-link prose. + *

+ * The applier is intentionally pure: no Spring beans, no I/O. That keeps the + * critical path testable as a plain JUnit class and lets the round-trip + * invariant ("stripped text equals original") get pinned down in one place. + * + *

Invariants

+ *
    + *
  1. Each {@link EnrichmentReplacement#replacement()} must be a wikilink + * in {@code [[slug]]} or {@code [[slug|label]]} form.
  2. + *
  3. The visible text of the replacement (slug for the bare form, label + * for the alias form) must equal {@link EnrichmentReplacement#original()} + * byte-for-byte.
  4. + *
  5. After applying every replacement, stripping all wikilinks back to + * their visible text must yield exactly the input page content.
  6. + *
+ * Failures abort the apply and return {@link Result#rejected(String)}; the + * caller is expected to leave the page untouched on rejection. + */ +public final class WikiEnrichmentApplier { + + /** Default cap so a runaway LLM can't propose 1000 wraps per page. */ + public static final int DEFAULT_MAX_REPLACEMENTS = 50; + + /** Matches {@code [[anything]]} (greedy through to the next ]] but not nested). */ + private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\[\\]]+)]]"); + + /** Matches {@code [[slug]]} or {@code [[slug|label]]} on the replacement string. */ + private static final Pattern REPLACEMENT_SHAPE = + Pattern.compile("^\\[\\[([^\\[\\]|]+)(?:\\|([^\\[\\]]+))?]]$"); + + private WikiEnrichmentApplier() {} + + public static Result apply(String originalContent, EnrichmentPlan plan) { + return apply(originalContent, plan, DEFAULT_MAX_REPLACEMENTS); + } + + public static Result apply(String originalContent, EnrichmentPlan plan, int maxReplacements) { + if (originalContent == null) return Result.rejected("content is null"); + if (plan == null || plan.isEmpty()) { + return Result.unchanged(originalContent); + } + if (plan.replacements().size() > maxReplacements) { + return Result.rejected("too many replacements: " + + plan.replacements().size() + " > " + maxReplacements); + } + + // 1) Per-original index of all candidate positions in the original text, + // skipping positions that fall inside an existing wikilink. + java.util.Map> positionsByOriginal = new java.util.HashMap<>(); + boolean[] insideWikilink = computeWikilinkMask(originalContent); + + // 2) Plan splices: for each replacement pick positions[occurrence-1]. + List splices = new ArrayList<>(); // [start, end, replacementIndex] + List replacementTexts = new ArrayList<>(); + for (EnrichmentReplacement r : plan.replacements()) { + String original = r.original(); + String replacement = r.replacement(); + if (original == null || original.isEmpty()) { + return Result.rejected("empty original in replacement"); + } + Matcher shape = REPLACEMENT_SHAPE.matcher(replacement == null ? "" : replacement); + if (!shape.matches()) { + return Result.rejected("replacement is not a wikilink form: " + replacement); + } + String slug = shape.group(1).trim(); + String label = shape.group(2); + String visible = (label == null) ? slug : label; + if (!visible.equals(original)) { + return Result.rejected("visible text mismatch: replacement='" + + replacement + "' must render '" + original + "'"); + } + + List positions = positionsByOriginal.computeIfAbsent(original, + o -> findPositions(originalContent, o, insideWikilink)); + int idx = r.occurrence() - 1; + if (idx < 0 || idx >= positions.size()) { + // Skip silently — the page may have been re-edited since the LLM saw it. + continue; + } + int start = positions.get(idx); + splices.add(new int[]{start, start + original.length(), replacementTexts.size()}); + replacementTexts.add(replacement); + } + + if (splices.isEmpty()) { + return Result.unchanged(originalContent); + } + + // 3) Apply in reverse offset order so earlier indices don't shift. + splices.sort((a, b) -> Integer.compare(b[0], a[0])); + StringBuilder sb = new StringBuilder(originalContent); + java.util.Set claimed = new java.util.HashSet<>(); + int applied = 0; + for (int[] sp : splices) { + int start = sp[0]; + int end = sp[1]; + // Reject overlapping splices defensively. + for (int i = start; i < end; i++) { + if (claimed.contains(i)) { + return Result.rejected("overlapping splice at " + start); + } + } + sb.replace(start, end, replacementTexts.get(sp[2])); + for (int i = start; i < end; i++) claimed.add(i); + applied++; + } + + // 4) Round-trip: stripping wikilinks from the result must reproduce input. + String enriched = sb.toString(); + if (!stripWikilinks(enriched).equals(stripWikilinks(originalContent))) { + return Result.rejected("round-trip invariant violated"); + } + return Result.applied(enriched, applied); + } + + /** + * Strip every {@code [[...]]} down to its visible text: + *
    + *
  • {@code [[slug]]} → {@code slug}
  • + *
  • {@code [[slug|label]]} → {@code label}
  • + *
+ * Used both for round-trip validation and for diff testing. + */ + public static String stripWikilinks(String content) { + if (content == null) return ""; + Matcher m = WIKILINK.matcher(content); + StringBuilder out = new StringBuilder(content.length()); + int last = 0; + while (m.find()) { + out.append(content, last, m.start()); + String inner = m.group(1); + int pipe = inner.indexOf('|'); + String visible = pipe >= 0 ? inner.substring(pipe + 1) : inner; + out.append(visible); + last = m.end(); + } + out.append(content, last, content.length()); + return out.toString(); + } + + private static boolean[] computeWikilinkMask(String content) { + boolean[] mask = new boolean[content.length()]; + Matcher m = WIKILINK.matcher(content); + while (m.find()) { + for (int i = m.start(); i < m.end(); i++) mask[i] = true; + } + return mask; + } + + private static List findPositions(String content, String needle, boolean[] insideWikilink) { + List out = new ArrayList<>(); + if (needle.isEmpty()) return out; + int from = 0; + while (from <= content.length() - needle.length()) { + int p = content.indexOf(needle, from); + if (p < 0) break; + // Skip if any byte of the match falls inside an existing wikilink. + boolean overlap = false; + for (int i = p; i < p + needle.length(); i++) { + if (insideWikilink[i]) { overlap = true; break; } + } + if (!overlap) out.add(p); + from = p + 1; + } + return out; + } + + /** + * Outcome of {@link #apply(String, EnrichmentPlan)}. + */ + public sealed interface Result permits Result.Applied, Result.Unchanged, Result.Rejected { + + String content(); + + static Result applied(String content, int count) { return new Applied(content, count); } + static Result unchanged(String content) { return new Unchanged(content); } + static Result rejected(String reason) { return new Rejected(reason); } + + record Applied(String content, int replacementCount) implements Result {} + record Unchanged(String content) implements Result {} + record Rejected(String reason) implements Result { + @Override public String content() { return null; } + } + } +} 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 41fcf5b9..6930004d 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 @@ -22,6 +22,16 @@ public class WikiKnowledgeBaseService { private final WikiKnowledgeBaseMapper kbMapper; + /** + * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked + * required=false + Lazy so the KB service has no construction dependency + * on a service that needs WikiPageService — handy for the older tests that + * still wire this class manually. + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + @org.springframework.context.annotation.Lazy + private WikiScaffoldService scaffoldService; + private static final String DEFAULT_CONFIG = """ # Wiki Processing Rules @@ -96,6 +106,10 @@ public class WikiKnowledgeBaseService { entity.setRawCount(0); kbMapper.insert(entity); log.info("[Wiki] Knowledge base created: id={}, name={}, workspaceId={}", entity.getId(), name, workspaceId); + // RFC-051 PR-2: ensure overview / log system pages exist for every new KB. + if (scaffoldService != null) { + scaffoldService.ensureScaffold(entity.getId()); + } return entity; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java new file mode 100644 index 00000000..1b7daf56 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java @@ -0,0 +1,439 @@ +package vip.mate.wiki.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.wiki.WikiProperties; +import vip.mate.wiki.dto.EnrichmentBatchPlan; +import vip.mate.wiki.dto.EnrichmentPlan; +import vip.mate.wiki.dto.EnrichmentReplacement; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.model.WikiPageEntity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * RFC-031 + RFC-051 PR-5b: lightweight wikilink enrichment. + *

+ * Earlier versions asked the LLM for a fully rewritten page and trusted the + * response if it was "long enough" — which let weak models silently drop + * paragraphs, translate prose, or rephrase claims while pretending to only + * add brackets. This rewrite switches to a replacement plan: the LLM + * proposes wraps, Java applies them surgically, and a round-trip check + * guarantees the non-link prose is unchanged byte-for-byte. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiLinkEnrichmentService { + + private final WikiPageService pageService; + private final WikiModelRoutingService routingService; + private final WikiProperties wikiProperties; + private final ObjectMapper objectMapper; + + private static final ExecutorService WIKI_EXECUTOR = + Executors.newVirtualThreadPerTaskExecutor(); + + /** Matches existing wikilinks. Group 1 is the inner text (slug or slug|label). */ + private static final Pattern WIKILINK_USAGE = Pattern.compile("\\[\\[([^\\[\\]]+)]]"); + + /** + * Enrich a single page with [[wikilinks]] using a replacement plan. + */ + public void enrichPage(Long pageId, Long modelId) { + WikiPageEntity page = pageService.getById(pageId); + if (page == null || page.getContent() == null) return; + + String index = buildIndexPrompt(page.getKbId()); + ChatModel chatModel = routingService.buildChatModel(modelId); + applyEnrichment(page, chatModel, index); + } + + /** + * Batch-enrich all pages in a KB. + *

+ * Internally honors {@code mate.wiki.enrich-batch-size}: when {@code 1} + * (the default), pages are enriched one-per-LLM-call as before. When + * larger, pages are grouped into batches and the LLM is asked for a + * multi-page replacement plan. Pages whose body exceeds + * {@code enrich-batch-per-page-max-chars} fall through to single-page + * mode so the batch prompt stays bounded. + */ + public void enrichAllPages(Long kbId, Long modelId) { + List pages = pageService.listByKbIdWithContent(kbId); + if (pages.isEmpty()) return; + String index = buildIndexPrompt(kbId); + + int batchSize = Math.max(1, wikiProperties.getEnrichBatchSize()); + int perPageCap = Math.max(500, wikiProperties.getEnrichBatchPerPageMaxChars()); + + if (batchSize <= 1) { + // Legacy per-page parallel path. + Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages()); + for (WikiPageEntity page : pages) { + sem.acquireUninterruptibly(); + WIKI_EXECUTOR.submit(() -> { + try { + enrichPageWithIndex(page, modelId, index); + } finally { + sem.release(); + } + }); + } + return; + } + + // Split: oversized pages go solo so the batch prompt stays bounded. + List batchable = new ArrayList<>(pages.size()); + List oversized = new ArrayList<>(); + for (WikiPageEntity p : pages) { + if (p.getContent() != null && p.getContent().length() > perPageCap) { + oversized.add(p); + } else if (p.getContent() != null) { + batchable.add(p); + } + } + + ChatModel chatModel = routingService.buildChatModel(modelId); + + // Process batches sequentially — typical batch is ~5 pages, single LLM call, + // already a fraction of the cost of the previous one-per-page parallelism. + for (int i = 0; i < batchable.size(); i += batchSize) { + List batch = batchable.subList(i, Math.min(i + batchSize, batchable.size())); + try { + applyBatchEnrichment(batch, chatModel, index, perPageCap); + } catch (Exception e) { + log.warn("[WikiEnrich] Batch enrichment failed (size={}): {}", batch.size(), e.getMessage()); + } + } + + // Oversized pages fall back to single-page enrich, in parallel. + if (!oversized.isEmpty()) { + Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages()); + for (WikiPageEntity page : oversized) { + sem.acquireUninterruptibly(); + WIKI_EXECUTOR.submit(() -> { + try { + enrichPageWithIndex(page, modelId, index); + } finally { + sem.release(); + } + }); + } + } + } + + private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) { + if (page == null || page.getContent() == null) return; + ChatModel chatModel = routingService.buildChatModel(modelId); + applyEnrichment(page, chatModel, index); + } + + /** + * Run one batch LLM call covering N pages and apply each per-slug plan + * independently. A malformed plan for one slug doesn't affect peers. + */ + private void applyBatchEnrichment(List batch, ChatModel chatModel, + String index, int perPageCap) { + if (batch.isEmpty()) return; + EnrichmentBatchPlan batchPlan = requestBatchPlan(chatModel, batch, index, perPageCap); + if (batchPlan == null || batchPlan.isEmpty()) return; + + for (WikiPageEntity page : batch) { + EnrichmentPlan plan = batchPlan.plans().get(page.getSlug()); + if (plan == null || plan.isEmpty()) continue; + WikiEnrichmentApplier.Result result = WikiEnrichmentApplier.apply(page.getContent(), plan); + if (result instanceof WikiEnrichmentApplier.Result.Rejected rejected) { + log.warn("[WikiEnrich] Batch plan rejected for slug={}: {}", page.getSlug(), rejected.reason()); + continue; + } + if (result instanceof WikiEnrichmentApplier.Result.Applied applied) { + page.setContent(applied.content()); + page.setOutgoingLinks(pageService.extractLinksAsJson(applied.content())); + pageService.updateById(page); + log.info("[WikiEnrich] Batch applied {} replacements on slug={}", + applied.replacementCount(), page.getSlug()); + } + } + } + + private void applyEnrichment(WikiPageEntity page, ChatModel chatModel, String index) { + // RFC-051 follow-up: tell the LLM how many times each slug is already linked + // in this page so it doesn't waste effort re-proposing positions that are + // already wrapped (which the applier would skip anyway, just more cheaply). + String indexWithUsage = decorateIndexWithUsage(index, page.getContent()); + EnrichmentPlan plan = requestPlan(chatModel, page.getContent(), indexWithUsage, page.getSlug()); + if (plan == null || plan.isEmpty()) { + return; // LLM had nothing to add or call failed; leave page alone. + } + WikiEnrichmentApplier.Result result = WikiEnrichmentApplier.apply(page.getContent(), plan); + if (result instanceof WikiEnrichmentApplier.Result.Rejected rejected) { + log.warn("[WikiEnrich] Plan rejected for slug={}: {}", page.getSlug(), rejected.reason()); + return; + } + if (result instanceof WikiEnrichmentApplier.Result.Unchanged) { + return; + } + if (result instanceof WikiEnrichmentApplier.Result.Applied applied) { + page.setContent(applied.content()); + page.setOutgoingLinks(pageService.extractLinksAsJson(applied.content())); + pageService.updateById(page); + log.info("[WikiEnrich] Applied {} replacements on slug={}", applied.replacementCount(), page.getSlug()); + } + } + + private EnrichmentPlan requestPlan(ChatModel chatModel, String content, String index, String slug) { + String systemPrompt = """ + You are a wiki cross-referencing assistant. + Your ONLY job: emit a JSON replacement plan that wraps existing words/phrases + with [[wikilinks]] from the supplied wiki index. + + Strict output contract — return ONLY this JSON object, nothing else: + { + "replacements": [ + {"original": "", + "replacement": "[[]]" or "[[|

+ * The log page is intentionally just a Markdown document the system writes + * into. Each entry is a single bullet under a {@code ## YYYY-MM-DD } + * section so the page stays human-readable. We don't ship a separate + * activity table yet — when the volume justifies it (RFC §7.3 calls it + * {@code mate_wiki_activity_log}), this service is the only place that + * needs to swap storage backends. + * + *

Trim policy

+ * Because the log page is a single Markdown blob, we cap it at 10 000 + * characters. Once exceeded, the oldest sections are dropped from the top + * (right after the {@code # Log} heading) until the page is back under the + * cap. This keeps the page readable and bounded. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiLogService { + + private static final int MAX_LOG_CHARS = 10_000; + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm"); + + private final WikiPageService pageService; + private final WikiPageMapper pageMapper; + private final WikiScaffoldService scaffoldService; + + /** Common entry types so callers don't have to invent strings. */ + public enum EventType { + INGEST, COMPILE, EDIT, ARCHIVE, REINDEX + } + + /** + * Append a single bullet under today's section header. Idempotent in the + * sense that two simultaneous calls produce two bullets — never crashes, + * never overwrites prior content. Auto-heals when the log page is missing + * by triggering scaffold once and retrying — covers KBs created before the + * scaffold migration shipped. + * + * @param kbId knowledge base id + * @param event high-level category + * @param body human-readable detail; rendered as Markdown after a hyphen + */ + public void append(Long kbId, EventType event, String body) { + if (kbId == null || event == null || body == null || body.isBlank()) return; + WikiPageEntity log = pageService.getBySlug(kbId, WikiScaffoldService.LOG_SLUG); + if (log == null) { + scaffoldService.ensureScaffold(kbId); + log = pageService.getBySlug(kbId, WikiScaffoldService.LOG_SLUG); + if (log == null) { + WikiLogService.log.debug("[WikiLog] No log page for kbId={} after scaffold, skipping append", kbId); + return; + } + } + try { + String existing = log.getContent() == null ? "# Log\n" : log.getContent(); + String today = LocalDate.now().format(DAY); + String time = LocalDateTime.now().format(TIME); + String bullet = "- " + time + " — " + body.replace("\n", " ").trim(); + String updated = appendBullet(existing, today, event.name().toLowerCase(), bullet); + if (updated.length() > MAX_LOG_CHARS) { + updated = trimOldest(updated, MAX_LOG_CHARS); + } + if (updated.equals(existing)) return; + log.setContent(updated); + pageMapper.updateById(log); + } catch (Exception e) { + WikiLogService.log.warn("[WikiLog] Append failed for kbId={}: {}", kbId, e.getMessage()); + } + } + + String appendBullet(String content, String today, String eventTag, String bullet) { + String header = "## " + today + " " + eventTag; + int idx = content.indexOf("\n" + header + "\n"); + if (idx >= 0) { + // Section exists — insert bullet at end of that section (right before the next "## "). + int sectionStart = idx + 1; + int sectionContentStart = content.indexOf('\n', sectionStart); + int nextSection = content.indexOf("\n## ", sectionContentStart); + int insertAt = nextSection < 0 ? content.length() : nextSection; + String before = content.substring(0, insertAt); + String after = content.substring(insertAt); + String prefix = before.endsWith("\n") ? before : before + "\n"; + return prefix + bullet + "\n" + (after.startsWith("\n") ? after : "\n" + after); + } + // New section. Insert at the top, right after the "# Log" heading. + int firstSection = content.indexOf("\n## "); + String section = "\n" + header + "\n\n" + bullet + "\n"; + if (firstSection < 0) { + String tail = content.endsWith("\n") ? content : content + "\n"; + return tail + section; + } + return content.substring(0, firstSection) + section + content.substring(firstSection); + } + + String trimOldest(String content, int cap) { + // Drop the oldest "## ..." section (which is at the bottom under our prepend + // strategy) until we're under the cap. + while (content.length() > cap) { + int lastHeading = content.lastIndexOf("\n## "); + if (lastHeading < 0) break; + content = content.substring(0, lastHeading) + "\n"; + } + return content; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java new file mode 100644 index 00000000..a6c9934e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiNarrativeService.java @@ -0,0 +1,319 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.PreDestroy; +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.prompt.Prompt; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +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.wiki.event.WikiKbDirtyEvent; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.repository.WikiRawMaterialMapper; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Generates and maintains the LLM-narrated section of a knowledge base's + * {@code overview} system page. Listens for {@link WikiKbDirtyEvent}s after + * commit, debounces per-KB so a burst of ingests collapses into a single LLM + * call, then rewrites only the narrative marker block — leaving the + * deterministic stats block (managed by {@link WikiOverviewService}) and any + * user-authored prose untouched. + * + *

Marker contract

+ * The narrative lives between: + *
+ * <!-- mate:overview:narrative:v1:start -->
+ *   ... LLM-rewritten 2-3 sentence summary ...
+ * <!-- mate:overview:narrative:v1:end -->
+ * 
+ * If the markers are missing (legacy overview pages), the narrative is + * inserted right after the existing stats block — so visual order on the + * rendered page is: stats → narrative → user prose. + * + *

Failure modes

+ * Every failure mode short-circuits gracefully — narrative regen is best- + * effort decoration, never a blocker: + *
    + *
  • Empty KB (no processed raws) → skip silently
  • + *
  • No LLM model resolvable → skip silently, log debug
  • + *
  • LLM call throws / times out → keep existing narrative, log warn
  • + *
  • LLM returns empty / overlong garbage → keep existing narrative
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiNarrativeService { + + public static final String NARRATIVE_START = ""; + public static final String NARRATIVE_END = ""; + + /** Debounce window: a burst of ingests within this period collapses into a single LLM call. */ + private static final long DEBOUNCE_MS = 10_000L; + /** Hard cap on the LLM-generated narrative length (chars). */ + private static final int MAX_NARRATIVE_CHARS = 600; + /** How many recent raw titles to feed the LLM as context. */ + private static final int RECENT_SOURCES_LIMIT = 10; + /** How many existing page titles to feed the LLM as context. */ + private static final int TOP_PAGES_LIMIT = 15; + /** Spring AI's internal retries are off — wiki has its own. */ + private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build(); + + private final WikiPageService pageService; + private final WikiPageMapper pageMapper; + private final WikiRawMaterialMapper rawMapper; + private final WikiKnowledgeBaseService kbService; + private final WikiScaffoldService scaffoldService; + private final WikiModelRoutingService modelRoutingService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + + /** kbId → pending regen task. Coalesces bursts. */ + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "wiki-narrative"); + t.setDaemon(true); + return t; + }); + + /** + * Spring fires this AFTER_COMMIT so we never run on a transaction that + * subsequently rolled back. {@code fallbackExecution=true} keeps it + * working when the publisher isn't inside a transaction (test paths, + * imperative ingest from the UI thread). + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onKbDirty(WikiKbDirtyEvent event) { + scheduleRegen(event.getKbId()); + } + + /** + * Schedule (or re-schedule) a narrative regen. Cancels any in-flight + * scheduled task for the same KB so a burst of N ingests produces one + * LLM call, not N. Public so admin endpoints / manual rebuild can call + * directly. + */ + public void scheduleRegen(Long kbId) { + if (kbId == null) return; + pending.compute(kbId, (k, existing) -> { + if (existing != null) existing.cancel(false); + return scheduler.schedule(() -> runRegen(k), DEBOUNCE_MS, TimeUnit.MILLISECONDS); + }); + } + + private void runRegen(Long kbId) { + try { + regenerateNow(kbId); + } catch (Exception e) { + log.warn("[WikiNarrative] Regen failed for kbId={}: {}", kbId, e.getMessage()); + } finally { + pending.remove(kbId); + } + } + + /** + * Synchronous regeneration — exposed so an admin endpoint or test can + * call without going through the debouncer. + */ + public void regenerateNow(Long kbId) { + if (kbId == null) return; + + WikiPageEntity overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + scaffoldService.ensureScaffold(kbId); + overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + log.debug("[WikiNarrative] No overview page for kbId={} after scaffold, skipping", kbId); + return; + } + } + + List recentRaws = rawMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .isNotNull(WikiRawMaterialEntity::getLastProcessedAt) + .orderByDesc(WikiRawMaterialEntity::getLastProcessedAt) + .last("LIMIT " + RECENT_SOURCES_LIMIT)); + if (recentRaws == null || recentRaws.isEmpty()) { + // Empty KB — nothing to summarise. Leave whatever is in the markers. + log.debug("[WikiNarrative] No processed sources for kbId={}, skipping", kbId); + return; + } + + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + String kbTitle = (kb != null && kb.getName() != null) ? kb.getName() : ("KB #" + kbId); + + // Top pages = most recent non-system pages by update time. listByKbId + // already excludes archived; we slice to TOP_PAGES_LIMIT after a + // fresh sort because the underlying default order isn't guaranteed. + List kbPages = pageMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE) + .orderByDesc(WikiPageEntity::getUpdateTime) + .last("LIMIT " + TOP_PAGES_LIMIT)); + + String currentNarrative = extractNarrative(overview.getContent()); + + ChatModel chatModel = resolveChatModel(kbId); + if (chatModel == null) { + log.debug("[WikiNarrative] No chat model resolvable for kbId={}, skipping", kbId); + return; + } + + String narrative; + try { + Prompt prompt = buildPrompt(kbTitle, recentRaws, kbPages, currentNarrative); + String raw = chatModel.call(prompt).getResult().getOutput().getText(); + narrative = sanitize(raw); + } catch (Exception e) { + log.warn("[WikiNarrative] LLM call failed for kbId={}: {}", kbId, e.getMessage()); + return; + } + if (narrative == null || narrative.isBlank()) { + log.debug("[WikiNarrative] LLM returned blank narrative for kbId={}, keeping existing", kbId); + return; + } + + // Re-read the page right before write so we don't clobber a concurrent + // stats refresh from WikiOverviewService. + WikiPageEntity fresh = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (fresh == null) return; + String spliced = spliceNarrative(fresh.getContent(), narrative); + if (spliced.equals(fresh.getContent())) return; + fresh.setContent(spliced); + pageMapper.updateById(fresh); + log.info("[WikiNarrative] Refreshed narrative for kbId={} ({} chars)", kbId, narrative.length()); + } + + // ------------------------------------------------------------------ + // Helpers — visible for testing + // ------------------------------------------------------------------ + + String extractNarrative(String content) { + if (content == null) return ""; + int s = content.indexOf(NARRATIVE_START); + int e = content.indexOf(NARRATIVE_END); + if (s < 0 || e < 0 || e < s) return ""; + String inner = content.substring(s + NARRATIVE_START.length(), e).trim(); + return inner; + } + + String spliceNarrative(String content, String narrative) { + String generated = NARRATIVE_START + "\n" + narrative.trim() + "\n" + NARRATIVE_END; + if (content == null || content.isEmpty()) { + return "# Overview\n\n" + generated + "\n"; + } + int start = content.indexOf(NARRATIVE_START); + int end = content.indexOf(NARRATIVE_END); + if (start >= 0 && end > start) { + return content.substring(0, start) + + generated + + content.substring(end + NARRATIVE_END.length()); + } + // Markers missing — drop the narrative right after the stats block (if + // present) so visual order is stats → narrative; else append at end. + int statsEnd = content.indexOf(WikiOverviewService.MARKER_END); + if (statsEnd >= 0) { + int splitAt = statsEnd + WikiOverviewService.MARKER_END.length(); + String before = content.substring(0, splitAt); + String after = content.substring(splitAt); + String prefix = before.endsWith("\n") ? before : before + "\n"; + String suffix = after.startsWith("\n") ? after : "\n" + after; + return prefix + "\n" + generated + suffix; + } + String trimmed = content.endsWith("\n") ? content : content + "\n"; + return trimmed + "\n" + generated + "\n"; + } + + String sanitize(String raw) { + if (raw == null) return null; + String s = raw.trim(); + // Strip stray markdown code fences the model sometimes wraps even though + // the system prompt forbids them. + if (s.startsWith("```")) { + int firstNl = s.indexOf('\n'); + if (firstNl > 0) s = s.substring(firstNl + 1); + if (s.endsWith("```")) s = s.substring(0, s.length() - 3); + s = s.trim(); + } + // Collapse internal newlines — narrative is supposed to be a single paragraph. + s = s.replaceAll("\\s*\\n+\\s*", " ").trim(); + if (s.length() > MAX_NARRATIVE_CHARS) { + s = s.substring(0, MAX_NARRATIVE_CHARS).trim() + "…"; + } + return s; + } + + private Prompt buildPrompt(String kbTitle, + List recentRaws, + List topPages, + String currentNarrative) { + StringBuilder sources = new StringBuilder(); + for (WikiRawMaterialEntity r : recentRaws) { + String t = r.getTitle() == null || r.getTitle().isBlank() + ? ("source #" + r.getId()) : r.getTitle(); + sources.append("- ").append(t) + .append(" (").append(r.getSourceType() == null ? "?" : r.getSourceType()).append(")\n"); + } + StringBuilder pages = new StringBuilder(); + if (topPages != null) { + for (WikiPageEntity p : topPages) { + if (p.getTitle() == null || p.getTitle().isBlank()) continue; + pages.append("- ").append(p.getTitle()).append('\n'); + } + } + if (pages.length() == 0) pages.append("_(无)_\n"); + String narrative = (currentNarrative == null || currentNarrative.isBlank()) + ? "_(尚无)_" : currentNarrative; + + String system = PromptLoader.loadPrompt("wiki/narrative-system"); + String userTemplate = PromptLoader.loadPrompt("wiki/narrative-user"); + String user = userTemplate + .replace("{kb_title}", kbTitle) + .replace("{recent_sources}", sources.toString().trim()) + .replace("{top_pages}", pages.toString().trim()) + .replace("{current_narrative}", narrative); + + return new Prompt(List.of(new SystemMessage(system), new UserMessage(user))); + } + + private ChatModel resolveChatModel(Long kbId) { + try { + Long modelId = modelRoutingService.selectModelId(kbId, "heavy_ingest", WikiJobStep.SUMMARY); + ModelConfigEntity model = modelConfigService.getModel(modelId); + if (model != null) { + return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY); + } + } catch (Exception e) { + log.debug("[WikiNarrative] Model routing failed for kbId={}: {}", kbId, e.getMessage()); + } + return null; + } + + @PreDestroy + void shutdown() { + scheduler.shutdownNow(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java new file mode 100644 index 00000000..f3ae7f3a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiOverviewService.java @@ -0,0 +1,202 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiChunkMapper; +import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.repository.WikiRawMaterialMapper; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * RFC-051 PR-2b: deterministic overview rebuilder. + *

+ * The {@code overview} system page wraps an auto-generated stats block + * inside marker comments: + * + *

+ * <!-- mate:overview:v1:start -->
+ *   ... rebuilt block ...
+ * <!-- mate:overview:v1:end -->
+ * 
+ * + * Anything outside the markers is user-authored prose and is preserved + * verbatim. Inside the markers, this service rewrites a small set of stats + * derived directly from the database — no LLM, no judgement calls. + *

+ * Hook points: {@code WikiProcessingService.processRawMaterial} on success + * and {@code WikiCompileService.compilePage} after a compile result. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiOverviewService { + + public static final String MARKER_START = ""; + public static final String MARKER_END = ""; + + private final WikiPageService pageService; + private final WikiPageMapper pageMapper; + private final WikiRawMaterialMapper rawMapper; + private final WikiChunkMapper chunkMapper; + private final WikiScaffoldService scaffoldService; + + private static final DateTimeFormatter ISO = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + /** Number of raw materials surfaced in the "Recent Updates" section. */ + private static final int RECENT_UPDATES_LIMIT = 5; + + /** + * Rebuild the marker region of the overview page for {@code kbId}. + * Auto-heals when the overview page is missing by triggering + * {@link WikiScaffoldService#ensureScaffold(Long)} once and retrying — this + * covers KBs created before the scaffold migration shipped. + */ + public void rebuild(Long kbId) { + if (kbId == null) return; + WikiPageEntity overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + // Self-heal for legacy KBs: scaffold then retry once. + scaffoldService.ensureScaffold(kbId); + overview = pageService.getBySlug(kbId, WikiScaffoldService.OVERVIEW_SLUG); + if (overview == null) { + log.debug("[WikiOverview] No overview page for kbId={} after scaffold, skipping rebuild", kbId); + return; + } + } + try { + String stats = computeStatsBlock(kbId); + String rewritten = spliceMarkerRegion(overview.getContent(), stats); + if (rewritten.equals(overview.getContent())) return; + overview.setContent(rewritten); + pageMapper.updateById(overview); + log.debug("[WikiOverview] Refreshed overview for kbId={}", kbId); + } catch (Exception e) { + log.warn("[WikiOverview] Rebuild failed for kbId={}: {}", kbId, e.getMessage()); + } + } + + private String computeStatsBlock(Long kbId) { + long rawCount = rawMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId)); + // Page count excludes system pages (overview / log themselves). + long pageCount = pageMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE)); + long chunkCount = chunkMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiChunkEntity::getKbId, kbId)); + long embeddedChunks = chunkMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiChunkEntity::getKbId, kbId) + .isNotNull(WikiChunkEntity::getEmbedding)); + long pagesWithLinks = pageMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getPageType, WikiScaffoldService.SYSTEM_PAGE_TYPE) + .isNotNull(WikiPageEntity::getOutgoingLinks) + .ne(WikiPageEntity::getOutgoingLinks, "[]") + .ne(WikiPageEntity::getOutgoingLinks, "")); + WikiRawMaterialEntity latest = rawMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .isNotNull(WikiRawMaterialEntity::getLastProcessedAt) + .orderByDesc(WikiRawMaterialEntity::getLastProcessedAt) + .last("LIMIT 1")); + String lastIngest = (latest != null && latest.getLastProcessedAt() != null) + ? latest.getLastProcessedAt().format(ISO) : "—"; + + long embedPct = chunkCount == 0 ? 0 : Math.round(100.0 * embeddedChunks / chunkCount); + long linkPct = pageCount == 0 ? 0 : Math.round(100.0 * pagesWithLinks / pageCount); + + return """ + ## Scope + + - Sources: %d + - Wiki pages: %d + - Chunks: %d + - Last ingest: %s + + ## Recent Updates + + %s + + ## Coverage + + - Embedding coverage: %d / %d (%d%%) + - Pages with wikilinks: %d / %d (%d%%) + """.formatted( + rawCount, pageCount, chunkCount, lastIngest, + renderRecentUpdates(kbId), + embeddedChunks, chunkCount, embedPct, + pagesWithLinks, pageCount, linkPct); + } + + /** + * Render the most recently processed sources as a Markdown bullet list, + * one bullet per raw material — title, source type, ingest time, and + * chunk count. Sources still {@code pending} or never processed are + * skipped (they'd dilute the "what just changed" signal). Empty wikis + * surface a single "No sources ingested yet." line. + */ + private String renderRecentUpdates(Long kbId) { + List recent = rawMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .isNotNull(WikiRawMaterialEntity::getLastProcessedAt) + .orderByDesc(WikiRawMaterialEntity::getLastProcessedAt) + .last("LIMIT " + RECENT_UPDATES_LIMIT)); + if (recent == null || recent.isEmpty()) { + return "_No sources ingested yet._"; + } + StringBuilder sb = new StringBuilder(); + for (WikiRawMaterialEntity raw : recent) { + long chunks = chunkMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiChunkEntity::getRawId, raw.getId())); + String when = raw.getLastProcessedAt().format(ISO); + String title = raw.getTitle() == null || raw.getTitle().isBlank() + ? ("source #" + raw.getId()) : raw.getTitle(); + String type = raw.getSourceType() == null ? "?" : raw.getSourceType(); + String status = raw.getProcessingStatus() == null ? "" : raw.getProcessingStatus(); + String statusBadge = "partial".equals(status) ? " ⚠ partial" : ""; + sb.append("- ").append(when).append(" — ").append(title) + .append(" (").append(type).append(", ").append(chunks).append(" chunks)") + .append(statusBadge).append('\n'); + } + // Trim the trailing newline so the text-block formatting below is clean. + if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '\n') { + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + String spliceMarkerRegion(String content, String newBlock) { + if (content == null || content.isEmpty()) { + // No prior overview — synthesize one with both markers. + return "# Overview\n\n" + MARKER_START + "\n" + newBlock.trim() + "\n" + MARKER_END + "\n"; + } + int start = content.indexOf(MARKER_START); + int end = content.indexOf(MARKER_END); + String generated = MARKER_START + "\n" + newBlock.trim() + "\n" + MARKER_END; + if (start < 0 || end < 0 || end < start) { + // Markers missing or scrambled — append the block at the end of the page. + String trimmed = content.endsWith("\n") ? content : content + "\n"; + return trimmed + "\n" + generated + "\n"; + } + // Replace the block (markers included) with the freshly generated one. + return content.substring(0, start) + + generated + + content.substring(end + MARKER_END.length()); + } + + LocalDateTime now() { return LocalDateTime.now(); } +} 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 30f68252..e4fd9e15 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 @@ -68,24 +68,43 @@ public class WikiPageService { } /** - * 列出知识库的所有页面(不含 content) + * RFC-051 PR-7 follow-up: list ONLY archived pages — the inverse of the + * default {@link #listByKbId} filter. Used by the admin UI's "show archived" + * panel so users can see what they archived and recover it. + */ + public List listArchivedByKbId(Long kbId) { + List pages = pageMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .eq(WikiPageEntity::getArchived, 1) + .orderByDesc(WikiPageEntity::getUpdateTime)); + pages.forEach(p -> p.setContent(null)); + return pages; + } + + /** + * 列出知识库的所有页面(不含 content)。 + * RFC-051 PR-7: archived 页面默认不返回。 */ public List listByKbId(Long kbId) { List pages = pageMapper.selectList( new LambdaQueryWrapper() .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getArchived, 1) .orderByAsc(WikiPageEntity::getTitle)); pages.forEach(p -> p.setContent(null)); return pages; } /** - * 列出知识库所有页面(含 content,用于全文搜索) + * 列出知识库所有页面(含 content,用于全文搜索)。 + * RFC-051 PR-7: archived 页面不参与 enrich / 全文搜索遍历。 */ public List listByKbIdWithContent(Long kbId) { return pageMapper.selectList( new LambdaQueryWrapper() .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getArchived, 1) .orderByAsc(WikiPageEntity::getTitle)); } @@ -98,11 +117,15 @@ public class WikiPageService { if (cached != null && !cached.isExpired()) { return cached.data; } + // RFC-051 PR-7: archived pages are hidden from default summary listings; + // PR-2 added page_type so callers can filter system pages too. List pages = pageMapper.selectList( new LambdaQueryWrapper() .select(WikiPageEntity::getSlug, WikiPageEntity::getTitle, - WikiPageEntity::getSummary, WikiPageEntity::getLastUpdatedBy) + WikiPageEntity::getSummary, WikiPageEntity::getLastUpdatedBy, + WikiPageEntity::getPageType) .eq(WikiPageEntity::getKbId, kbId) + .ne(WikiPageEntity::getArchived, 1) .orderByAsc(WikiPageEntity::getTitle)); summaryCache.put(kbId, new CachedSummaries(pages, System.currentTimeMillis() + SUMMARY_CACHE_TTL_MS)); return pages; @@ -169,11 +192,33 @@ public class WikiPageService { } /** - * 创建新 Wiki 页面 + * Direct update by entity (used by enrichment service). + */ + @Transactional + public void updateById(WikiPageEntity entity) { + pageMapper.updateById(entity); + if (entity.getKbId() != null) { + evictSummaryCache(entity.getKbId()); + } + } + + /** + * Create a new wiki page (without explicit pageType) */ @Transactional public WikiPageEntity createPage(Long kbId, String slug, String title, String content, String summary, String sourceRawIds) { + return createPage(kbId, slug, title, content, summary, sourceRawIds, null); + } + + /** + * Create a new wiki page with explicit pageType classification. + * pageType is stored lowercase (concept / person / place / event / technology / + * organization / product / term / process / other). + */ + @Transactional + public WikiPageEntity createPage(Long kbId, String slug, String title, String content, + String summary, String sourceRawIds, String pageType) { WikiPageEntity entity = new WikiPageEntity(); entity.setKbId(kbId); entity.setSlug(slug); @@ -184,11 +229,32 @@ public class WikiPageService { entity.setSourceRawIds(sourceRawIds); entity.setVersion(1); entity.setLastUpdatedBy("ai"); + if (pageType != null && !pageType.isBlank()) { + entity.setPageType(pageType.toLowerCase()); + } pageMapper.insert(entity); evictSummaryCache(kbId); return entity; } + /** + * List pages derived from a specific raw material (for UI sidebar filtering). + * Uses a LIKE search on sourceRawIds JSON field — cheap and dialect-agnostic. + */ + public List listBySourceRawId(Long kbId, Long rawId) { + List pages = pageMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + // RFC-051 PR-7: a raw's archived pages stop showing up in the + // sidebar's "filter by raw" listing. Lineage is still queryable + // by hitting the page directly via slug. + .ne(WikiPageEntity::getArchived, 1) + .like(WikiPageEntity::getSourceRawIds, rawId.toString()) + .orderByAsc(WikiPageEntity::getTitle)); + pages.forEach(p -> p.setContent(null)); + return pages; + } + /** * AI 更新页面内容(手动编辑的页面不覆盖内容,仅追加来源) */ @@ -237,6 +303,43 @@ public class WikiPageService { return existing; } + /** + * RFC-047 P2: Paired source lineage entry (rawId + rawTitle snapshot at ingest time). + * Keyed by rawId; rawTitle is a snapshot — the raw may be renamed later but lineage stays accurate. + */ + public record SourceEntry(long rawId, String rawTitle) {} + + /** + * RFC-047 P2: Merge a (rawId, rawTitle) pair into a page's source lineage. + * Dual-writes to both sourceEntries (canonical) and sourceRawIds (legacy compat). + * Idempotent: no-ops if rawId already present. + */ + @Transactional + public void mergeSourceLineage(Long pageId, Long rawId, String rawTitle) { + WikiPageEntity page = pageMapper.selectById(pageId); + if (page == null) return; + + List entries = parseSourceEntries(page.getSourceEntries()); + boolean entryExists = entries.stream().anyMatch(e -> e.rawId() == rawId); + + List rawIds = parseSourceRawIds(page.getSourceRawIds()); + boolean idExists = rawIds.contains(rawId); + + if (!entryExists) { + entries.add(new SourceEntry(rawId, rawTitle != null ? rawTitle : "")); + page.setSourceEntries(toJson(entries)); + } + if (!idExists) { + rawIds.add(rawId); + page.setSourceRawIds(toJson(rawIds)); + } + + if (!entryExists || !idExists) { + pageMapper.updateById(page); + evictSummaryCache(page.getKbId()); + } + } + /** * 手动更新页面内容 */ @@ -298,8 +401,30 @@ public class WikiPageService { .collect(Collectors.toList()); } + /** + * RFC-051 PR-2: a page is protected from AI / tool / batch deletion when + * either {@code locked == 1} or {@code pageType == "system"}. The system + * pages ({@code overview} / {@code log}) carry both flags; users may set + * {@code locked} on individual curated pages without making them system. + */ + public static boolean isProtected(WikiPageEntity page) { + if (page == null) return false; + if (page.getLocked() != null && page.getLocked() == 1) return true; + return "system".equals(page.getPageType()); + } + @Transactional public void delete(Long kbId, String slug) { + WikiPageEntity existing = getBySlug(kbId, slug); + if (existing == null) { + // Nothing to delete; preserve idempotent behavior. + return; + } + if (isProtected(existing)) { + log.warn("[Wiki] Refusing to delete protected page kbId={}, slug={}, type={}, locked={}", + kbId, slug, existing.getPageType(), existing.getLocked()); + return; + } pageMapper.delete( new LambdaQueryWrapper() .eq(WikiPageEntity::getKbId, kbId) @@ -307,6 +432,33 @@ public class WikiPageService { evictSummaryCache(kbId); } + /** + * RFC-051 PR-7: flip the {@code archived} flag. + *

+ * Archive hides the page from default list/search/related results without + * destroying it. Citation lineage and source-raw links survive, so an + * archived page can still be unarchived later or audited from raw history. + * Refuses to archive a system page since those are part of the KB's spine. + * + * @param archive true to archive, false to unarchive + * @return true on a state change, false if no-op (page missing or already in target state) + */ + @Transactional + public boolean setArchived(Long kbId, String slug, boolean archive) { + WikiPageEntity existing = getBySlug(kbId, slug); + if (existing == null) return false; + if ("system".equals(existing.getPageType())) { + log.warn("[Wiki] Refusing to archive system page kbId={}, slug={}", kbId, slug); + return false; + } + int target = archive ? 1 : 0; + if (existing.getArchived() != null && existing.getArchived() == target) return false; + existing.setArchived(target); + pageMapper.updateById(existing); + evictSummaryCache(kbId); + return true; + } + /** * 批量删除页面(按 slug 列表) */ @@ -333,16 +485,21 @@ public class WikiPageService { int deleted = 0; for (WikiPageEntity page : allPages) { if ("manual".equals(page.getLastUpdatedBy())) continue; + // RFC-051 PR-2: never sweep system / locked pages, even when their + // source raw is being reprocessed. + if (isProtected(page)) continue; List sourceIds = parseSourceRawIds(page.getSourceRawIds()); if (sourceIds.contains(rawId)) { if (sourceIds.size() == 1) { - // 独占页面:直接删除 delete(kbId, page.getSlug()); deleted++; } else { - // 多来源页面:仅移除该 rawId 引用 + // Multi-source page: remove this rawId from both sourceRawIds and sourceEntries sourceIds.remove(rawId); page.setSourceRawIds(toJson(sourceIds)); + List entries = parseSourceEntries(page.getSourceEntries()); + entries.removeIf(e -> e.rawId() == rawId); + page.setSourceEntries(toJson(entries)); pageMapper.updateById(page); } } @@ -357,15 +514,38 @@ public class WikiPageService { } /** - * 从 Markdown 内容中提取 [[links]] 并返回 JSON 数组 + * Count wiki pages derived from a specific raw material. + * Uses sourceRawIds JSON array field (e.g. "[123]" or "[123,456]"). + */ + public int countBySourceRawId(Long kbId, Long rawId) { + // Use LIKE search on sourceRawIds JSON — works for both single and multi-source pages + return Math.toIntExact(pageMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .like(WikiPageEntity::getSourceRawIds, rawId.toString()))); + } + + /** + * Extract {@code [[links]]} (and {@code [[target|label]]} alias form, + * RFC-051 PR-5) from Markdown content and return them as a JSON array of + * canonical slugs. + *

+ * For aliased links the {@code label} part is purely display — only + * {@code target} feeds slug resolution. Without this split we'd canonicalize + * "Spring AI|Spring AI Alibaba" as a single slug, polluting outgoingLinks + * and breaking graph view / backlinks. */ String extractLinksAsJson(String content) { if (content == null) return "[]"; List links = new ArrayList<>(); Matcher matcher = WIKI_LINK_PATTERN.matcher(content); while (matcher.find()) { - String link = matcher.group(1).trim(); - String slug = toSlug(link); + String raw = matcher.group(1).trim(); + int pipe = raw.indexOf('|'); + String target = pipe >= 0 ? raw.substring(0, pipe).trim() : raw; + if (target.isEmpty()) continue; + String slug = toSlug(target); + if (slug.isEmpty()) continue; if (!links.contains(slug)) { links.add(slug); } @@ -395,6 +575,15 @@ public class WikiPageService { } } + private List parseSourceEntries(String json) { + if (json == null || json.isBlank()) return new ArrayList<>(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + private String toJson(Object obj) { try { return objectMapper.writeValueAsString(obj); 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 a5c5af2e..4abf56cc 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 @@ -16,6 +16,8 @@ import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.WikiChunkDraft; +import vip.mate.wiki.job.WikiKbConfig; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; @@ -53,8 +55,47 @@ public class WikiProcessingService { private final AgentGraphBuilder agentGraphBuilder; private final ObjectMapper objectMapper; private final WikiProgressBus progressBus; + private final WikiCitationService citationService; + private final org.springframework.context.ApplicationEventPublisher eventPublisher; - /** 并行 chunk / 材料处理执行器(JDK 21 虚拟线程);Listener 跨包需要引用,故 public */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + @org.springframework.context.annotation.Lazy + private vip.mate.wiki.job.WikiProcessingJobService wikiJobService; + + /** + * RFC-051 PR-1c: optional preprocessor that fills chunk metadata + * (page_number / token_count / header_breadcrumb / source_section). + * 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) + private DocumentPreprocessService preprocessService; + + /** + * RFC-051 PR-2: ensures system-page scaffold (overview / log) exists for + * 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) + private WikiScaffoldService scaffoldService; + + /** + * 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; + + /** RFC-051 PR-2b/2c: optional overview rebuilder + log appender. */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + private WikiOverviewService overviewService; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + private WikiLogService logService; + + /** Parallel chunk / material processing executor (JDK 21 virtual threads) */ public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); /** @@ -79,6 +120,15 @@ public class WikiProcessingService { * slug 注册为 winner,后到的 chunk 看到 winner 后会把内容写入 winner 对应的 page。 */ final ConcurrentHashMap slugClaims = 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 + * (once per chunk) when the document repeatedly references the same concept. + *

+ * Merge is skipped (not just decremented from count) when a slug is already present. + * Uses ConcurrentHashMap as a concurrent set via putIfAbsent. + */ + final ConcurrentHashMap mergedSlugs = new ConcurrentHashMap<>(); } private final ConcurrentHashMap progressCounters = new ConcurrentHashMap<>(); @@ -132,6 +182,32 @@ public class WikiProcessingService { kbService.updateStatus(kb.getId(), "processing"); + // RFC-051 PR-2: every ingest path opens with a scaffold check so older + // KBs get their overview / log pages on first use without a manual step. + if (scaffoldService != null) { + scaffoldService.ensureScaffold(kb.getId()); + } + + // RFC-051 PR-1b: lazy ingest short-circuit. Per KB config, skip the heavy + // pipeline entirely: extract → chunk → embed → completed. 0 pages is the + // expected outcome, not a failure. ingestMode==null keeps existing behavior. + if ("lazy".equals(resolveIngestMode(kb))) { + processLazyIngest(kb, raw); + return; + } + + // RFC-030 §9.1: create a processing job record and track its ID for stage transitions + Long jobId = null; + if (wikiJobService != null) { + try { + var job = wikiJobService.createHeavyIngest(kb.getId(), rawId); + jobId = job.getId(); + wikiJobService.transition(jobId, vip.mate.wiki.job.WikiJobStage.ROUTING); + } catch (Exception e) { + log.warn("[Wiki] Failed to create heavy ingest job record for raw={}: {}", rawId, e.getMessage()); + } + } + // RFC-012 M2 v2 UI v2:为本次 raw 处理创建共享进度计数器(多 chunk 共享,避免 race) progressCounters.put(rawId, new ProgressCounter()); rawService.updateProgress(rawId, "route", 0, 0); // UI 立即看到 indeterminate 滑条 @@ -168,11 +244,23 @@ public class WikiProcessingService { // Phase 3: 构建已有页面索引(一次构建,所有 chunk 共用) String existingPagesIndex = buildExistingPagesIndex(kb.getId()); + // Phase 3b: Document-level analysis (optional, RFC-047 follow-up) + // Single LLM call producing a concept map injected into every chunk's route prompt. + String documentMap = ""; + if (properties.isUseDocumentAnalysis()) { + documentMap = analyzeDocument(kb, raw, textContent); + } + + // 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) {} + } + // Phase 3: LLM 消化 // result[0] = totalPages, result[1] = failedChunks, result[2] = totalChunks int[] result; if (textContent.length() > properties.getMaxChunkSize()) { - result = processInChunks(kb, raw, textContent, existingPagesIndex); + result = processInChunks(kb, raw, textContent, existingPagesIndex, documentMap); } else { // 单 chunk 也持久化(RFC-013:保证所有 chunk 都入库) try { @@ -181,7 +269,7 @@ public class WikiProcessingService { } catch (Exception e) { log.warn("[Wiki] Single chunk persistence failed for raw={}: {}", rawId, e.getMessage()); } - int pages = processChunk(kb, raw, textContent, existingPagesIndex); + int pages = processChunk(kb, raw, textContent, existingPagesIndex, documentMap); result = new int[]{pages, pages == 0 ? 1 : 0, 1}; } @@ -197,9 +285,25 @@ public class WikiProcessingService { String finalStatus; String finalDetail = null; if (totalPages == 0) { - rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response"); - finalStatus = "failed"; - finalDetail = "No pages generated from LLM response"; + // RFC-051 follow-up: previously this was an unconditional "failed". + // But chunks were already persisted (and the materials are searchable + // via wiki_semantic_search) — the only thing that actually went wrong + // was the LLM not synthesizing pages. Treat that as partial when chunks + // landed: search works, the agent can still wiki_compile_page on demand, + // and the row is rerun-able. Reserve "failed" for the case where nothing + // got indexed at all. + if (totalChunks > 0) { + finalDetail = "Indexed " + totalChunks + + " chunk(s) but no pages were generated. Search and wiki_compile_page still work; reprocess to retry page generation."; + rawService.updateProcessingStatus(rawId, "partial", finalDetail); + finalStatus = "partial"; + log.info("[Wiki] Eager produced 0 pages but {} chunks indexed; marking partial for raw={}", + totalChunks, rawId); + } else { + rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response"); + finalStatus = "failed"; + finalDetail = "No pages generated from LLM response"; + } } else if (failedChunks > 0 || failedPages > 0) { // 部分成功:chunk 整体失败 或 chunk 内有 page 失败 // (M2 v2 follow-up:page 级失败原本被计入 completed,现在正确归 partial) @@ -242,6 +346,40 @@ public class WikiProcessingService { "kbPageCount", pageCount)); } + // Transition job to terminal stage + 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; + default -> vip.mate.wiki.job.WikiJobStage.COMPLETED; + }; + wikiJobService.transition(jobId, terminalStage); + } catch (Exception ignored) {} + } + + // RFC-051 PR-2c: log every non-failed eager ingest. Failures already get a + // RAW_FAILED broadcast and an error message in the raw row. Title goes first + // so the log reads as "what just landed" instead of an opaque raw id. + if (logService != null && !"failed".equals(finalStatus)) { + String title = (raw.getTitle() == null || raw.getTitle().isBlank()) + ? ("raw#" + rawId) : raw.getTitle(); + logService.append(kb.getId(), WikiLogService.EventType.INGEST, + "eager " + finalStatus + " · " + title + + " · " + totalPages + " pages · " + totalChunks + " chunks"); + } + // RFC-051 PR-2b: refresh overview stats whenever a raw lands in a terminal state + // (completed or partial). Failures don't shift the stats meaningfully. + if (overviewService != null && !"failed".equals(finalStatus)) { + overviewService.rebuild(kb.getId()); + } + // Tier 2: signal "KB content is dirty" so WikiNarrativeService can + // schedule (debounced) an LLM-generated overview narrative refresh. + // Stats rebuild above is sync; narrative regen runs after-commit. + if (!"failed".equals(finalStatus)) { + eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId())); + } + log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}", rawId, kb.getId(), totalPages, pageCount); @@ -250,7 +388,10 @@ public class WikiProcessingService { // 如果未来加了事务包裹 processRawMaterial,这里的异步任务需要改用 // TransactionSynchronizationManager.registerSynchronization(afterCommit) // 否则新线程会查不到 chunk(事务未提交)导致 embedding 静默跳过。 - if (totalPages > 0) { + // RFC-051 follow-up: trigger embedding whenever chunks landed, not only when + // pages were produced. Otherwise the partial-with-no-pages case above ends up + // with chunks in DB but never embedded, so semantic search silently misses them. + if (totalChunks > 0) { final Long fKbId = kb.getId(); WIKI_EXECUTOR.submit(() -> { try { @@ -268,6 +409,10 @@ public class WikiProcessingService { log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e); rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); kbService.updateStatus(kb.getId(), "active"); + // Transition job to failed + if (wikiJobService != null && jobId != null) { + try { wikiJobService.transition(jobId, vip.mate.wiki.job.WikiJobStage.FAILED); } catch (Exception ignored) {} + } // RFC-012 M3:广播异常终态 progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); @@ -322,7 +467,7 @@ public class WikiProcessingService { * @return int[3]: [totalPages, failedChunks, totalChunks] */ private int[] processInChunks(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String text, - String existingPagesIndex) { + String existingPagesIndex, String documentMap) { // Phase 1: 切分文本为 chunks(带偏移,供持久化) List chunksWithOffset = splitIntoChunksWithOffsets(text); List chunks = chunksWithOffset.stream().map(ChunkWithOffset::text).toList(); @@ -341,7 +486,7 @@ public class WikiProcessingService { if (totalChunks == 1) { // 单 chunk 不走并行 try { - int pages = processChunk(kb, raw, chunks.get(0), existingPagesIndex); + int pages = processChunk(kb, raw, chunks.get(0), existingPagesIndex, documentMap); return new int[]{pages, pages == 0 ? 1 : 0, 1}; } catch (Exception e) { log.warn("[Wiki] Single chunk failed: {}", e.getMessage()); @@ -368,8 +513,15 @@ public class WikiProcessingService { return; } try { + // RFC-051 PR-9: skip remaining chunks if the user deleted the raw + // while earlier chunks were still in flight. Counts as a "failed chunk" + // for terminal-status accounting (not actually failed, just abandoned). + if (isAborted(raw.getId(), "chunk " + (chunkIndex + 1) + "/" + totalChunks)) { + failedChunks.incrementAndGet(); + return; + } log.info("[Wiki] Processing chunk {}/{}: {} chars", chunkIndex + 1, totalChunks, chunk.length()); - int pages = processChunk(kb, raw, chunk, existingPagesIndex); + int pages = processChunk(kb, raw, chunk, existingPagesIndex, documentMap); totalPages.addAndGet(pages); } catch (Exception e) { failedChunks.incrementAndGet(); @@ -470,10 +622,10 @@ public class WikiProcessingService { * @return 创建+更新的页面数 */ private int processChunk(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String textContent, - String existingPagesIndex) { + String existingPagesIndex, String documentMap) { // RFC-012 M2:两阶段消化(路由 → 逐页 merge),单次 LLM 调用输出量大幅缩减,避免 nginx 60s 网关超时 if (properties.isUseTwoPhaseDigest()) { - return processChunkTwoPhase(kb, raw, textContent, existingPagesIndex); + return processChunkTwoPhase(kb, raw, textContent, existingPagesIndex, documentMap); } // 旧路径:单次调用让 LLM 同时处理新建 + 全量 merge(输出爆炸,易触发 504) @@ -490,7 +642,9 @@ public class WikiProcessingService { new SystemMessage(systemPrompt), new UserMessage(userPrompt) )); - String llmResponse = callLlmWithResilientRetry(prompt, "chunk of raw=" + raw.getId()); + 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); return applyLlmResponse(kb.getId(), raw.getId(), llmResponse); } @@ -510,7 +664,7 @@ public class WikiProcessingService { * @return 创建+更新的页面数 */ private int processChunkTwoPhase(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, - String textContent, String existingPagesIndex) { + String textContent, String existingPagesIndex, String documentMap) { Long kbId = kb.getId(); Long rawId = raw.getId(); String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : ""; @@ -521,25 +675,45 @@ public class WikiProcessingService { ProgressCounter pc = progressCounters.get(rawId); // ─── 阶段 A:路由 ─── + // Rebuild existingPagesIndex fresh at route time so pages created by earlier chunks + // in this run are visible. This prevents the route from scheduling "create" for a + // concept that was already created by a previous chunk (which would be caught by + // savePageContent and converted to update, but wastes a merge LLM call). + // listSummaries uses a 5-min TTL cache that is evicted on every create/update, so + // this picks up changes from sequential chunks without an extra DB hit when nothing changed. + String freshIndex = buildExistingPagesIndex(kbId); + String routeSystem = PromptLoader.loadPrompt("wiki/route-system"); String routeUserTemplate = PromptLoader.loadPrompt("wiki/route-user"); + String documentMapSection = (documentMap != null && !documentMap.isBlank()) + ? "## 文档全局概念地图(预分析结果,供路由参考)\n\n```json\n" + documentMap + "\n```\n" + : ""; String routeUser = routeUserTemplate .replace("{config}", configContent) - .replace("{existing_pages}", existingPagesIndex) + .replace("{document_map_section}", documentMapSection) + .replace("{existing_pages}", freshIndex) .replace("{raw_title}", rawTitle) .replace("{raw_content}", textContent); + + // RFC-051 PR-6b: optionally inject Spring AI's structured-output hint so the + // 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 = + useStructured + ? new org.springframework.ai.converter.BeanOutputConverter<>(vip.mate.wiki.dto.RouteResult.class) + : null; + if (routeConverter != null) { + routeUser = routeUser + "\n\n" + routeConverter.getFormat(); + } + Prompt routePrompt = new Prompt(List.of( new SystemMessage(routeSystem), new UserMessage(routeUser) )); - String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId); - JsonNode routeJson = parseJsonResponse(routeResponse); - if (routeJson == null) { - log.warn("[Wiki] Route phase: failed to parse JSON for kbId={}, rawId={}, responseLen={}, first200={}", - kbId, rawId, routeResponse != null ? routeResponse.length() : 0, - routeResponse != null ? routeResponse.substring(0, Math.min(200, routeResponse.length())) : "null"); - return 0; - } + if (isAborted(rawId, "route phase")) return 0; + String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId, + kbId, vip.mate.wiki.job.WikiJobStep.ROUTE); // RFC-012 follow-up #3:phase B 现在并行执行,计数必须是 atomic AtomicInteger created = new AtomicInteger(0); @@ -547,24 +721,78 @@ public class WikiProcessingService { // ─── 收集 route 输出(仅 metadata,无 content) ─── List createMetas = new ArrayList<>(); - JsonNode createNode = routeJson.path("create"); - if (createNode.isArray()) { - for (JsonNode metaNode : createNode) { - String slug = metaNode.path("slug").asText(""); - String title = metaNode.path("title").asText(""); - if (slug.isBlank() || title.isBlank()) continue; - createMetas.add(metaNode); + List updateSlugs = new ArrayList<>(); + + boolean structuredOk = false; + if (routeConverter != null) { + try { + vip.mate.wiki.dto.RouteResult bound = routeConverter.convert(routeResponse); + if (bound != null) { + for (vip.mate.wiki.dto.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(); + node.put("slug", meta.slug()); + node.put("title", meta.title()); + if (meta.summary() != null) node.put("summary", meta.summary()); + if (meta.purposeHint() != null) node.put("purposeHint", meta.purposeHint()); + createMetas.add(node); + } + for (String slug : bound.update()) { + if (slug != null && !slug.isBlank()) updateSlugs.add(slug); + } + structuredOk = true; + log.debug("[Wiki] Route phase: structured parse ok kbId={} rawId={} create={} update={}", + kbId, rawId, createMetas.size(), updateSlugs.size()); + } + } catch (Exception e) { + log.warn("[Wiki] Route phase: structured parse failed for rawId={}, falling back to lenient JSON: {}", + rawId, e.getMessage()); } } - List updateSlugs = new ArrayList<>(); - JsonNode updateNode = routeJson.path("update"); - if (updateNode.isArray()) { - for (JsonNode slugNode : updateNode) { - String slug = slugNode.asText(""); - if (!slug.isBlank()) updateSlugs.add(slug); + + if (!structuredOk) { + JsonNode routeJson = parseJsonResponse(routeResponse); + if (routeJson == null) { + log.warn("[Wiki] Route phase: failed to parse JSON for kbId={}, rawId={}, responseLen={}, first200={}", + kbId, rawId, routeResponse != null ? routeResponse.length() : 0, + routeResponse != null ? routeResponse.substring(0, Math.min(200, routeResponse.length())) : "null"); + return 0; + } + JsonNode createNode = routeJson.path("create"); + if (createNode.isArray()) { + for (JsonNode metaNode : createNode) { + String slug = metaNode.path("slug").asText(""); + String title = metaNode.path("title").asText(""); + if (slug.isBlank() || title.isBlank()) continue; + createMetas.add(metaNode); + } + } + JsonNode updateNode = routeJson.path("update"); + if (updateNode.isArray()) { + for (JsonNode slugNode : updateNode) { + String slug = slugNode.asText(""); + if (!slug.isBlank()) updateSlugs.add(slug); + } } } int totalPlanned = createMetas.size() + updateSlugs.size(); + + // Chunk fallback: if route returned nothing for a non-trivial chunk, inject an overview page + // 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 = + objectMapper.createObjectNode(); + fallbackMeta.put("slug", overviewSlug); + fallbackMeta.put("title", rawTitle + " 概述"); + fallbackMeta.put("summary", "来自「" + rawTitle + "」的综合概述,涵盖本章节的核心内容。"); + createMetas.add(fallbackMeta); + totalPlanned = 1; + log.info("[Wiki] Chunk fallback: route returned empty for rawId={} chunkLen={}, injecting overview page '{}'", + rawId, textContent.length(), overviewSlug); + } + log.info("[Wiki] Route phase: kbId={}, rawId={}, planned create={}, planned update={}", kbId, rawId, createMetas.size(), updateSlugs.size()); @@ -590,50 +818,30 @@ public class WikiProcessingService { int parallelPages = Math.max(1, properties.getMaxParallelPhaseBPages()); Semaphore pageSem = new Semaphore(parallelPages); - // ─── 阶段 B-1:并行 create ─── - List> createFutures = new ArrayList<>(createMetas.size()); - for (JsonNode meta : createMetas) { - final JsonNode metaRef = meta; - createFutures.add(CompletableFuture.runAsync(() -> { - try { - pageSem.acquire(); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - return; - } - boolean ok = false; - try { - try { - if (createOnePage(kb, raw, textContent, existingPagesIndex, metaRef)) { - created.incrementAndGet(); - } - // createOnePage 内部的 DuplicateKey / canonical / claim fallback 不抛异常 → ok=true。 - ok = true; - } catch (RuntimeException e) { - log.warn("[Wiki] Phase B create page slug='{}' failed: {}", - metaRef.path("slug").asText(""), e.getMessage()); - } - if (pc != null) { - int d = pc.done.incrementAndGet(); - if (!ok) pc.failed.incrementAndGet(); - rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); - progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, - java.util.Map.of( - "rawId", rawId, - "kind", "create", - "ok", ok, - "done", d, - "total", pc.total.get())); - } - } finally { - pageSem.release(); - } - }, WIKI_EXECUTOR)); - } + // ─── Phase B-1: BatchCreate (RFC-047 P1) ─── + // One LLM call for all N creates instead of N individual calls. + // batchCreatePages handles sub-batching, liveIndex updates, and progress counting. + batchCreatePages(kb, raw, textContent, existingPagesIndex, createMetas, created, pc, documentMap); + List> createFutures = new ArrayList<>(0); // kept for allOf join below // ─── 阶段 B-2:并行 merge ─── - List> mergeFutures = new ArrayList<>(updateSlugs.size()); + // Dedup: skip slugs already merged in this run to prevent N-version churn on + // high-frequency reference pages (e.g. a herb mentioned in every chapter gets v1, + // not v19). The first chunk that merges a slug wins; later chunks skip it and + // adjust the shared total counter so progress stays consistent. + List effectiveUpdateSlugs = new ArrayList<>(); for (String slug : updateSlugs) { + if (pc != null && pc.mergedSlugs.putIfAbsent(slug, Boolean.TRUE) != null) { + // Already merged in a previous chunk — remove from total so progress bar stays accurate + pc.total.decrementAndGet(); + log.debug("[Wiki] Phase B merge slug='{}' deduped (already merged this run), skipping", slug); + } else { + effectiveUpdateSlugs.add(slug); + } + } + + List> mergeFutures = new ArrayList<>(effectiveUpdateSlugs.size()); + for (String slug : effectiveUpdateSlugs) { final String mergeSlug = slug; mergeFutures.add(CompletableFuture.runAsync(() -> { try { @@ -677,30 +885,207 @@ public class WikiProcessingService { CompletableFuture.allOf(allFutures.toArray(new CompletableFuture[0])).join(); // 单 chunk 完成时不写"done"——多 chunk 还在跑;最终"done"由 processRawMaterial 的 finally 写入 - log.info("[Wiki] Two-phase digest applied: kbId={}, rawId={}, created={}, updated={}", - kbId, rawId, created.get(), updated.get()); + log.info("[wiki-telemetry-chunk] kbId={} rawId={} chunkLen={} indexLen={} creates={} updates={}", + kbId, rawId, textContent.length(), existingPagesIndex.length(), created.get(), updated.get()); return created.get() + updated.get(); } /** - * RFC-012 M2 v2 — 阶段 B 单页生成:用 chunk 文本 + 该页 metadata 让 LLM 写出完整页面。 + * RFC-047 P1: BatchCreate — single LLM call generating all new pages for a chunk. *

- * 输入仅几 KB(chunk 主题片段 + metadata + 已有页索引),输出仅一页 markdown, - * 单次调用稳稳 ≤ 60 秒,避免 nginx 60s 网关。 - *

- * 兜底:如果 slug 已存在(route 误判),改走 update 路径。 - * - * @return true 表示成功 create 一页(或兜底 update 一页时返回 false 以让上层归到 update 计数) + * Splits {@code createMetas} into sub-batches of {@code batchCreatePageSize}; after each + * sub-batch the saved pages are appended to {@code liveIndex} so subsequent sub-batches + * can link to them. Returns the count of actually-created (not updated) pages. */ - private boolean createOnePage(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, - String chunkText, String existingPagesIndex, JsonNode meta) { + private int batchCreatePages(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, + String chunkText, String existingPagesIndex, + List createMetas, AtomicInteger created, + ProgressCounter pc, String documentMap) { + if (createMetas.isEmpty()) return 0; Long kbId = kb.getId(); Long rawId = raw.getId(); - String slug = meta.path("slug").asText(""); - String title = meta.path("title").asText(""); - String summary = meta.path("summary").asText(""); - if (slug.isBlank() || title.isBlank()) return false; + String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : ""; + int batchSize = Math.max(1, properties.getBatchCreatePageSize()); + WikiBatchCreateParser batchParser = new WikiBatchCreateParser(); + StringBuilder liveIndex = new StringBuilder(existingPagesIndex); + int totalCreated = 0; + + for (int bStart = 0; bStart < createMetas.size(); bStart += batchSize) { + int bEnd = Math.min(bStart + batchSize, createMetas.size()); + List subBatch = createMetas.subList(bStart, bEnd); + + // Build pages_to_create JSON array for this sub-batch + StringBuilder metasJson = new StringBuilder("["); + for (int i = 0; i < subBatch.size(); i++) { + if (i > 0) metasJson.append(","); + metasJson.append(subBatch.get(i).toString()); + } + metasJson.append("]"); + + String batchSystem = PromptLoader.loadPrompt("wiki/batch-create-system"); + String batchUserTemplate = PromptLoader.loadPrompt("wiki/batch-create-user"); + String docMapSection = (documentMap != null && !documentMap.isBlank()) + ? "## 文档全局概念地图(预分析结果,供页面内容生成参考)\n\n```json\n" + documentMap + "\n```\n" + : ""; + String batchUser = batchUserTemplate + .replace("{config}", configContent) + .replace("{document_map_section}", docMapSection) + .replace("{existing_pages}", liveIndex.toString()) + .replace("{pages_to_create}", metasJson.toString()) + .replace("{raw_title}", raw.getTitle()) + .replace("{raw_content}", chunkText); + Prompt batchPrompt = new Prompt(List.of( + new SystemMessage(batchSystem), + new UserMessage(batchUser) + )); + + if (isAborted(rawId, "batch-create sub-batch " + (bStart / batchSize + 1))) { + // Abandon remaining sub-batches; return however many pages we already created. + return totalCreated; + } + String batchResponse = callLlmWithResilientRetry(batchPrompt, + "batch-create " + subBatch.size() + " pages of raw=" + rawId + + " subBatch=" + (bStart / batchSize + 1), + kbId, vip.mate.wiki.job.WikiJobStep.CREATE_PAGE); + + List parsedPages = batchParser.parse(batchResponse); + + // Fallback: if LLM ignored FILE block format (common for single-page sub-batches), + // try treating the entire response as a bare JSON page. + if (parsedPages.isEmpty() && subBatch.size() == 1 && batchResponse != null && !batchResponse.isBlank()) { + String fallbackSlug = subBatch.get(0).path("slug").asText(""); + parsedPages = List.of(new WikiBatchCreateParser.ParsedPage(fallbackSlug, batchResponse.strip())); + log.info("[Wiki] BatchCreate sub-batch {}: no FILE blocks found, trying bare-JSON fallback for slug='{}'", + bStart / batchSize + 1, fallbackSlug); + } + + log.info("[Wiki] BatchCreate sub-batch {}: planned={} parsed={}", + bStart / batchSize + 1, subBatch.size(), parsedPages.size()); + + for (WikiBatchCreateParser.ParsedPage pp : parsedPages) { + JsonNode pageJson = parseJsonResponse(pp.rawJson()); + if (pageJson == null) { + // Truncated or malformed JSON inside the FILE block — by far the most + // common batch-create failure mode (provider hits max-tokens mid-object). + // Recover by re-issuing this slug as a single-page LLM call: tiny payload, + // tiny truncation risk. Up to 2 attempts. + log.info("[Wiki] BatchCreate: unparseable JSON for slug='{}', retrying individually", + pp.slug()); + final String headerSlug = pp.slug(); + JsonNode retryMeta = subBatch.stream() + .filter(m -> headerSlug.equals(m.path("slug").asText(""))) + .findFirst().orElse(null); + if (retryMeta != null) { + int delta = tryCreateOneWithRetry(kb, raw, chunkText, liveIndex, retryMeta, 2, + created, pc, rawId, kbId, headerSlug); + if (delta > 0) totalCreated++; + } else if (pc != null) { + // Sub-batch's parsed FILE header references a slug that wasn't in the + // planned metas — nothing useful to retry. Tick progress and move on. + int d = pc.done.incrementAndGet(); + pc.failed.incrementAndGet(); + rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, + java.util.Map.of("rawId", rawId, "kind", "create", + "ok", false, "done", d, "total", pc.total.get())); + } + continue; + } + // Use slug from JSON body; fall back to FILE header slug + String slug = pageJson.path("slug").asText(pp.slug()); + if (slug.isBlank()) slug = pp.slug(); + String title = pageJson.path("title").asText(""); + String content = pageJson.path("content").asText(""); + String pageSummary = pageJson.path("summary").asText(""); + String pageType = pageJson.path("page_type").asText(""); + if (content.isBlank()) { + log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug); + final String blankSlug = slug; + final String headerSlug = pp.slug(); + // The LLM occasionally renames a slug between the FILE header and the + // JSON body, so match either when finding the meta to retry against. + JsonNode retryMeta = subBatch.stream() + .filter(m -> blankSlug.equals(m.path("slug").asText("")) + || headerSlug.equals(m.path("slug").asText(""))) + .findFirst().orElse(null); + if (retryMeta != null) { + int delta = tryCreateOneWithRetry(kb, raw, chunkText, liveIndex, retryMeta, 2, + created, pc, rawId, kbId, slug); + if (delta > 0) totalCreated++; + } else if (pc != null) { + int d = pc.done.incrementAndGet(); + 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", + "ok", false, "done", d, "total", pc.total.get())); + } + continue; + } + + boolean wasCreated = false; + boolean ok = false; + try { + wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType); + if (wasCreated) { + created.incrementAndGet(); + totalCreated++; + // Append to liveIndex so next sub-batch can link to this page + String briefSummary = pageSummary.length() > 100 + ? pageSummary.substring(0, 100) : pageSummary; + liveIndex.append("\n- ").append(slug).append(": ").append(briefSummary); + } + ok = true; + } catch (RuntimeException e) { + log.warn("[Wiki] BatchCreate: savePageContent failed for slug='{}': {}", slug, e.getMessage()); + } + + if (pc != null) { + int d = pc.done.incrementAndGet(); + if (!ok) pc.failed.incrementAndGet(); + rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, + java.util.Map.of( + "rawId", rawId, + "kind", "create", + "ok", ok, + "done", d, + "total", pc.total.get())); + } + } + + // Retry any pages that LLM omitted from the batch response + int subBatchNum = bStart / batchSize + 1; + java.util.Set returnedSlugs = new java.util.HashSet<>(); + for (WikiBatchCreateParser.ParsedPage pp : parsedPages) { + returnedSlugs.add(pp.slug()); + } + for (JsonNode missingMeta : subBatch) { + String missingSlug = missingMeta.path("slug").asText(""); + if (missingSlug.isBlank() || returnedSlugs.contains(missingSlug)) continue; + log.info("[Wiki] BatchCreate sub-batch {}: slug='{}' missing, retrying individually", + subBatchNum, missingSlug); + int delta = tryCreateOneWithRetry(kb, raw, chunkText, liveIndex, missingMeta, 2, + created, pc, rawId, kbId, missingSlug); + if (delta > 0) totalCreated++; + } + } + return totalCreated; + } + + /** + * RFC-047 P1 retry: call the single-page create prompt for one missing slug. + * Used when a BatchCreate sub-batch omits a planned page. + * + * @return raw LLM response string, or null on failure + */ + private String retrySingleCreate(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, + String chunkText, String existingPagesIndex, + JsonNode pageMeta) { + String slug = pageMeta.path("slug").asText(""); + String title = pageMeta.path("title").asText(""); + String summary = pageMeta.path("summary").asText(""); String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : ""; String createSystem = PromptLoader.loadPrompt("wiki/create-page-system"); String createUserTemplate = PromptLoader.loadPrompt("wiki/create-page-user"); @@ -716,78 +1101,181 @@ public class WikiProcessingService { new SystemMessage(createSystem), new UserMessage(createUser) )); - String response = callLlmWithResilientRetry(prompt, - "create page slug=" + slug + " of raw=" + rawId); - JsonNode pageJson = parseJsonResponse(response); - if (pageJson == null) { - log.warn("[Wiki] Phase B create page slug='{}' returned unparseable JSON, skipping", slug); - return false; - } - String content = pageJson.path("content").asText(""); - String pageSummary = pageJson.path("summary").asText(""); - if (pageSummary.isBlank()) pageSummary = summary; - if (content.isBlank()) { - log.warn("[Wiki] Phase B create page slug='{}' returned blank content, skipping", slug); - return false; - } + 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); + } - // 兜底 0:跨拼写 canonical 匹配(DB 已有 page,但 slug 拼写不同) - // 覆盖场景:之前上传的 raw 已经创建了同概念 page,本次 LLM 给了不同拼写 + /** + * Recover one slug that BatchCreate failed to deliver — calls + * {@link #retrySingleCreate} up to {@code maxAttempts} times, retrying on + * any of: null response, unparseable JSON, blank content, thrown exception. + * Each attempt is an independent single-page LLM call: small payload, low + * truncation risk — two attempts is enough to recover from transient + * provider hiccups without ballooning latency. + * + *

This consolidates what used to be three near-identical inline retry + * blocks (omitted slug / blank content / unparseable JSON) into one path + * with consistent attempt count, log lines, and progress accounting. + * + *

Side effects on success: {@code created} is incremented (only when a + * brand-new page is persisted, not on dedupe), {@code liveIndex} grows so + * subsequent batched pages can wikilink to this one, and {@code pc} ticks + * one {@code done} regardless of outcome with {@code failed} on exhaustion. + * + * @param slugForLog logical slug used in log messages — the actual write + * slug comes from the LLM response or retryMeta.slug + * @return 1 if a new page was persisted, 0 if a parseable response landed + * but the slug already existed (dedup), -1 if all attempts failed + */ + private int tryCreateOneWithRetry(WikiKnowledgeBaseEntity kb, + WikiRawMaterialEntity raw, + String chunkText, + StringBuilder liveIndex, + JsonNode retryMeta, + int maxAttempts, + AtomicInteger created, + ProgressCounter pc, + Long rawId, + Long kbId, + String slugForLog) { + String metaSlug = retryMeta.path("slug").asText(""); + String fallbackTitle = retryMeta.path("title").asText(""); + String fallbackSummary = retryMeta.path("summary").asText(""); + int delta = -1; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + String retryResult = retrySingleCreate(kb, raw, chunkText, liveIndex.toString(), retryMeta); + if (retryResult == null) { + log.warn("[Wiki] retry-create slug='{}' attempt {}/{}: null response", + slugForLog, attempt, maxAttempts); + continue; + } + JsonNode retryJson = parseJsonResponse(retryResult); + if (retryJson == null) { + log.warn("[Wiki] retry-create slug='{}' attempt {}/{}: unparseable JSON", + slugForLog, attempt, maxAttempts); + continue; + } + String resolvedSlug = retryJson.path("slug").asText(metaSlug); + if (resolvedSlug.isBlank()) resolvedSlug = metaSlug; + String content = retryJson.path("content").asText(""); + if (content.isBlank()) { + log.warn("[Wiki] retry-create slug='{}' attempt {}/{}: blank content", + slugForLog, attempt, maxAttempts); + continue; + } + String title = retryJson.path("title").asText(fallbackTitle); + String summary = retryJson.path("summary").asText(fallbackSummary); + boolean wasCreated = savePageContent(kb, raw, resolvedSlug, title, content, summary); + if (wasCreated) { + created.incrementAndGet(); + String brief = summary.length() > 100 ? summary.substring(0, 100) : summary; + liveIndex.append("\n- ").append(resolvedSlug).append(": ").append(brief); + delta = 1; + } else { + delta = 0; + } + log.info("[Wiki] retry-create slug='{}' succeeded on attempt {}/{} (newPage={})", + slugForLog, attempt, maxAttempts, wasCreated); + break; + } catch (Exception e) { + log.warn("[Wiki] retry-create slug='{}' attempt {}/{} threw: {}", + slugForLog, attempt, maxAttempts, e.getMessage()); + } + } + if (delta < 0) { + log.warn("[Wiki] retry-create slug='{}' exhausted {} attempts — giving up", + slugForLog, maxAttempts); + } + if (pc != null) { + int d = pc.done.incrementAndGet(); + 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", + "ok", delta >= 0, "done", d, "total", pc.total.get())); + } + return delta; + } + + /** + * RFC-047 P1: Shared DB save logic for a new page, extracted for use by both + * {@link #createOnePage} and {@link #batchCreatePages}. + * Handles canonical-slug matching, in-flight slug-claim arbitration, and DuplicateKey fallback. + * + * @return true if a new row was inserted; false if an existing page was updated instead + */ + private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, + String slug, String title, String content, String pageSummary) { + return savePageContent(kb, raw, slug, title, content, pageSummary, null); + } + + private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, + String slug, String title, String content, String pageSummary, + String pageType) { + Long kbId = kb.getId(); + Long rawId = raw.getId(); + + // RFC-051 PR-9: refuse to materialize a page (or merge into an existing one) tied + // to a raw the user just deleted. Prevents zombie pages whose source_raw_ids point + // at a tombstoned row. + if (isAborted(rawId, "savePageContent slug=" + slug)) 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)) { String actualSlug = existingByCanonical.getSlug(); pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId); + pageService.mergeSourceLineage(existingByCanonical.getId(), rawId, raw.getTitle()); log.info("[Wiki] Phase B create slug='{}' canonical-matches existing '{}', updated", slug, actualSlug); return false; } - // 兜底 0.5:跨 chunk in-flight slug 抢占(同一 raw 的另一并发 chunk 已声明同概念) - // computeIfAbsent 是原子操作,先到的 chunk 把自己 slug 注册为 winner + // Fallback 0.5: in-flight slug-claim arbitration across parallel chunks ProgressCounter pcLocal = progressCounters.get(rawId); String canonical = WikiPageService.canonicalSlug(slug); if (pcLocal != null && !canonical.isEmpty()) { - // lambda 要求 effectively final,用 finalSlug 副本 final String routedSlug = slug; String winnerSlug = pcLocal.slugClaims.computeIfAbsent(canonical, k -> routedSlug); if (!winnerSlug.equals(slug)) { - // 另一 chunk 先 claim 了同 canonical,但用了不同 slug 拼写 WikiPageEntity winner = pageService.getBySlug(kbId, winnerSlug); if (winner != null) { - // winner 已 INSERT 进 DB → 直接 update pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId); + pageService.mergeSourceLineage(winner.getId(), rawId, raw.getTitle()); log.info("[Wiki] Phase B create slug='{}' lost slug-claim race to '{}', updated", slug, winnerSlug); return false; } - // winner claim 早于 INSERT(claim 是 in-memory,INSERT 是 DB IO) - // → 用 winnerSlug 继续走下面的 INSERT 路径,DuplicateKey fallback 会兜住实际 race log.info("[Wiki] Phase B create slug='{}' redirects to in-flight winner '{}'", slug, winnerSlug); slug = winnerSlug; } } - // 兜底 1:如果 slug 已存在(route 误判 / 上一次成功 INSERT),走 update 而不是 create + // Fallback 1: slug already in DB (route misclassified or prior run) WikiPageEntity existing = pageService.getBySlug(kbId, slug); if (existing != null) { pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); + pageService.mergeSourceLineage(existing.getId(), rawId, raw.getTitle()); log.info("[Wiki] Phase B create page slug='{}' done (updated existing)", slug); - return false; // 不计入 created + return false; } + String sourceRawIds = "[" + rawId + "]"; try { - pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds); + WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType); + pageService.mergeSourceLineage(created.getId(), rawId, raw.getTitle()); log.info("[Wiki] Phase B create page slug='{}' done (created)", slug); + citationService.buildCitationsAsync(created.getId(), kbId); return true; } catch (org.springframework.dao.DuplicateKeyException e) { - // 兜底 2:select-then-create 在并发下不是原子操作。当 N 个 chunk 同时 - // route 出相同 slug,只有第一个 INSERT 能成功,其余都会触发 H2/MySQL - // unique key violation。本次 chunk 的 LLM 输出仍有价值——降级为 update, - // 把内容合并进已存在的 page,而不是丢弃。 + // Fallback 2: concurrent INSERT race — degrade to update pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); log.info("[Wiki] Phase B create page slug='{}' lost INSERT race -> updated existing", slug); - return false; // 不计入 created + return false; } } @@ -820,21 +1308,32 @@ public class WikiProcessingService { String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : ""; String mergeSystem = PromptLoader.loadPrompt("wiki/merge-page-system"); + // Trim existing content to prevent context overflow on small models (qwen-turbo: 4096 tokens). + // Merging a 3000-char page + 30K chunk blows past the limit → truncated JSON → parse failure. + // 1800 chars ≈ ~600 tokens, leaving ample room for the chunk and response. + final int MAX_EXISTING_CHARS = 1800; + String rawExisting = existing.getContent() != null ? existing.getContent() : ""; + String trimmedExisting = rawExisting.length() > MAX_EXISTING_CHARS + ? rawExisting.substring(0, MAX_EXISTING_CHARS) + "\n...(内容已截断,请基于以上内容合并新信息)" + : rawExisting; + String mergeUserTemplate = PromptLoader.loadPrompt("wiki/merge-page-user"); String mergeUser = mergeUserTemplate .replace("{config}", configContent) .replace("{page_slug}", existing.getSlug() != null ? existing.getSlug() : slug) .replace("{page_title}", existing.getTitle() != null ? existing.getTitle() : "") .replace("{page_last_updated_by}", existing.getLastUpdatedBy() != null ? existing.getLastUpdatedBy() : "ai") - .replace("{page_content}", existing.getContent() != null ? existing.getContent() : "") + .replace("{page_content}", trimmedExisting) .replace("{raw_title}", raw.getTitle()) .replace("{raw_content}", chunkText); Prompt prompt = new Prompt(List.of( new SystemMessage(mergeSystem), new UserMessage(mergeUser) )); + if (isAborted(rawId, "merge slug=" + slug)) return false; String response = callLlmWithResilientRetry(prompt, - "merge page slug=" + slug + " of raw=" + rawId); + "merge page slug=" + slug + " of raw=" + rawId, + kbId, vip.mate.wiki.job.WikiJobStep.MERGE_PAGE); JsonNode mergeJson = parseJsonResponse(response); if (mergeJson == null) { log.warn("[Wiki] Phase B merge page slug='{}' returned unparseable JSON, skipping", slug); @@ -846,8 +1345,16 @@ public class WikiProcessingService { log.warn("[Wiki] Phase B merge page slug='{}' returned blank content, skipping", slug); return false; } - pageService.updatePageByAi(kbId, slug, content, summary, rawId); + WikiPageEntity updated = pageService.updatePageByAi(kbId, slug, content, summary, rawId); log.info("[Wiki] Phase B merge page slug='{}' done", slug); + // RFC-047 P2: merge paired source lineage on update + if (updated != null) { + pageService.mergeSourceLineage(updated.getId(), rawId, raw.getTitle()); + } + // RFC-029: async citation rebuild + if (updated != null) { + citationService.buildCitationsAsync(updated.getId(), kbId); + } return true; } @@ -922,6 +1429,47 @@ public class WikiProcessingService { return created + updated; } + /** + * RFC-047 follow-up: Document-level analysis pass. + * Single LLM call on a sample of the full document to produce a concept map + * (topics + key_concepts + structure_notes). The result is injected into + * every chunk's route prompt so the router has global document awareness, + * reducing concept omissions caused by chunk-local context blindness. + * + * @return pretty-printed JSON string of the concept map, or "" on failure + */ + private String analyzeDocument(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String textContent) { + int sampleChars = Math.max(1000, properties.getDocumentAnalysisSampleChars()); + String sample = textContent.length() > sampleChars + ? textContent.substring(0, sampleChars) + "\n...[文档较长,以上为节选]" + : textContent; + String system = PromptLoader.loadPrompt("wiki/analyze-system"); + String userTemplate = PromptLoader.loadPrompt("wiki/analyze-user"); + String user = userTemplate + .replace("{raw_title}", raw.getTitle()) + .replace("{text_sample}", sample); + Prompt prompt = new Prompt(List.of( + new SystemMessage(system), + new UserMessage(user) + )); + 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); + JsonNode json = parseJsonResponse(response); + if (json != null) { + log.info("[Wiki] Document analysis done for raw={}: topics={}, concepts={}", + raw.getId(), + json.path("topics").size(), + json.path("key_concepts").size()); + return json.toPrettyString(); + } + } catch (Exception e) { + log.warn("[Wiki] Document analysis failed for raw={}, continuing without: {}", raw.getId(), e.getMessage()); + } + return ""; + } + /** * 构建已有 Wiki 页面索引(供 LLM 参考) */ @@ -959,6 +1507,28 @@ public class WikiProcessingService { return agentGraphBuilder.buildRuntimeChatModel(defaultModel, WIKI_NO_RETRY); } + /** + * RFC-051 PR-3: build a {@link ChatModel} for an eager-pipeline step, + * honoring the KB-level routing chain when {@link #modelRoutingService} + * 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) { + if (modelRoutingService != null && kbId != null && step != null) { + try { + Long modelId = modelRoutingService.selectModelId(kbId, "heavy_ingest", step); + ModelConfigEntity model = modelConfigService.getModel(modelId); + if (model != null) { + return agentGraphBuilder.buildRuntimeChatModel(model, WIKI_NO_RETRY); + } + } catch (Exception e) { + log.warn("[Wiki] Model routing failed for kbId={} step={}, falling back to default: {}", + kbId, step, e.getMessage()); + } + } + return buildChatModel(); + } + /** * 调用 LLM,带"任务完成或模型不可用才终止"的重试策略。 *

@@ -973,12 +1543,21 @@ public class WikiProcessingService { * 反复瞬时错误把单 chunk 卡到永远;buildChatModel 提到循环外,所有重试复用同一实例。 */ private String callLlmWithResilientRetry(Prompt prompt, String ctx) { + return callLlmWithResilientRetry(prompt, ctx, null, null); + } + + /** + * RFC-051 PR-3: step-aware LLM retry helper. {@code kbId} and {@code step} + * 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) { long backoffMs = 1000; final long maxBackoffMs = 60_000; final int maxAttempts = Math.max(1, properties.getLlmMaxAttempts()); final long maxTotalDurationMs = Math.max(1_000L, properties.getLlmMaxTotalDurationMs()); final long startNanos = System.nanoTime(); - final ChatModel chatModel = buildChatModel(); + final ChatModel chatModel = buildChatModelFor(kbId, step); int attempt = 0; while (true) { attempt++; @@ -993,6 +1572,15 @@ public class WikiProcessingService { if (attempt > 1) { log.info("[Wiki] LLM call for {} succeeded on attempt {}", ctx, attempt); } + // P0 telemetry: log token usage per LLM call so we can baseline costs before optimizing + long durationMs = (System.nanoTime() - startNanos) / 1_000_000L; + try { + var usage = response.getMetadata() != null ? response.getMetadata().getUsage() : null; + long pt = (usage != null && usage.getPromptTokens() != null) ? usage.getPromptTokens() : -1L; + long ct = (usage != null && usage.getCompletionTokens() != null) ? usage.getCompletionTokens() : -1L; + log.info("[wiki-telemetry] ctx={} promptTokens={} completionTokens={} durationMs={}", + ctx, pt, ct, durationMs); + } catch (Exception ignored) {} return response.getResult().getOutput().getText(); } catch (Throwable t) { if (Thread.currentThread().isInterrupted()) { @@ -1129,11 +1717,131 @@ public class WikiProcessingService { return cls + ": " + trimmed; } - /** 瞬时错误的内部标记异常,确保空响应也能走重试路径 */ + /** Transient error marker to route empty responses through the retry path */ private static class TransientLlmException extends RuntimeException { TransientLlmException(String msg) { super(msg); } } + // ==================== RFC-030: Error classification ==================== + + /** + * Classify an exception into an error code aligned with RFC-009 ErrorType. + * + * @return error code string: AUTH_ERROR, BILLING, MODEL_NOT_FOUND, + * RATE_LIMIT, SERVER_ERROR, TIMEOUT, CONTENT_FILTER, UNKNOWN + */ + public String classifyErrorCode(Throwable t) { + Throwable cur = t; + int depth = 0; + while (cur != null && depth < 8) { + String className = cur.getClass().getSimpleName(); + if ("UnknownHostException".equals(className) + || "SSLHandshakeException".equals(className)) { + return "AUTH_ERROR"; + } + String msg = cur.getMessage(); + if (msg != null) { + String m = msg.toLowerCase(); + if (m.contains("401") || m.contains("unauthorized") || m.contains("403") + || m.contains("forbidden") || m.contains("invalid api key") + || m.contains("invalid_api_key") || m.contains("authentication")) { + return "AUTH_ERROR"; + } + if (m.contains("quota") || m.contains("insufficient_quota") || m.contains("billing")) { + return "BILLING"; + } + if (m.contains("model not found") || m.contains("model_not_found")) { + return "MODEL_NOT_FOUND"; + } + if (m.contains("429") || m.contains("rate_limit") || m.contains("too many requests")) { + return "RATE_LIMIT"; + } + if (m.contains("content_filter") || m.contains("content filter") + || m.contains("data_inspection_failed")) { + return "CONTENT_FILTER"; + } + if (m.contains("timeout") || m.contains("timed out")) { + return "TIMEOUT"; + } + if (m.contains("500") || m.contains("502") || m.contains("503") || m.contains("504")) { + return "SERVER_ERROR"; + } + } + cur = cur.getCause(); + depth++; + } + return "UNKNOWN"; + } + + // ==================== RFC-031: Methods for template delegation ==================== + + /** + * Repair a single page by regenerating its content. + * Used by LocalRepairTemplate. + */ + public void repairSinglePage(Long targetPageId, Long modelId) { + WikiPageEntity page = pageService.getById(targetPageId); + if (page == null) { + log.warn("[Wiki] repairSinglePage: page not found: {}", targetPageId); + return; + } + + WikiKnowledgeBaseEntity kb = kbService.getById(page.getKbId()); + if (kb == null) return; + + // Find source raw material + List rawIds = parseSourceRawIds(page.getSourceRawIds()); + if (rawIds.isEmpty()) { + log.warn("[Wiki] repairSinglePage: no source raw IDs for page {}", targetPageId); + return; + } + + WikiRawMaterialEntity raw = rawService.getById(rawIds.get(0)); + if (raw == null) return; + + String textContent = rawService.getTextContent(raw); + if (textContent == null || textContent.isBlank()) return; + + // Use existing two-phase single-page create logic + String existingPagesIndex = buildExistingPagesIndex(kb.getId()); + String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : ""; + String createSystem = PromptLoader.loadPrompt("wiki/create-page-system"); + String createUserTemplate = PromptLoader.loadPrompt("wiki/create-page-user"); + String createUser = createUserTemplate + .replace("{config}", configContent) + .replace("{existing_pages}", existingPagesIndex) + .replace("{page_slug}", page.getSlug()) + .replace("{page_title}", page.getTitle()) + .replace("{page_summary}", page.getSummary() != null ? page.getSummary() : "") + .replace("{raw_title}", raw.getTitle()) + .replace("{raw_content}", textContent); + Prompt prompt = new Prompt(List.of( + new SystemMessage(createSystem), + new UserMessage(createUser) + )); + 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); + if (pageJson == null) return; + + String content = pageJson.path("content").asText(""); + String summary = pageJson.path("summary").asText(""); + if (!content.isBlank()) { + pageService.updatePageByAi(kb.getId(), page.getSlug(), content, summary, rawIds.get(0)); + log.info("[Wiki] Repaired page: {} (kbId={})", page.getSlug(), kb.getId()); + } + } + + private List parseSourceRawIds(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } + private JsonNode parseJsonResponse(String response) { if (response == null || response.isBlank()) return null; @@ -1172,4 +1880,195 @@ public class WikiProcessingService { return null; } } + + /** + * RFC-051 PR-6b follow-up: KB-level override for structured route output, + * falling back to the global property when the KB hasn't set a preference. + * Parse failures fall back to global too — never block ingest on bad config. + */ + private boolean resolveStructuredRouteFlag(WikiKnowledgeBaseEntity kb) { + boolean fallback = properties.isUseStructuredRoute(); + if (kb == null || kb.getConfigContent() == null) return fallback; + try { + WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); + return config.getUseStructuredRoute() != null + ? config.getUseStructuredRoute() + : fallback; + } catch (Exception e) { + return fallback; + } + } + + /** + * RFC-051 PR-1b: read {@code ingestMode} from KB config JSON. Returns null + * on any parse error or missing field so the caller falls through to eager. + */ + private String resolveIngestMode(WikiKnowledgeBaseEntity kb) { + if (kb == null || kb.getConfigContent() == null) return null; + try { + WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); + return config.getIngestMode(); + } catch (Exception e) { + log.warn("[Wiki] Failed to parse KB config for ingest mode, falling back to eager: {}", e.getMessage()); + return null; + } + } + + /** + * RFC-051 PR-9: returns {@code true} when the caller should bail out of an + * in-flight processing path because the raw material has been deleted. + *

+ * {@link WikiRawMaterialService#delete(Long)} is a logical delete (the + * {@code @TableLogic} column flips to 1), so {@code selectById} returns + * {@code null} as soon as the deletion commits. Sprinkling this check + * right before each LLM call keeps token spend bounded by a single + * in-flight call after the user clicks delete. + * + * @param rawId the raw material id this processing path is about + * @param ctx short string used in the log line + * @return {@code true} if the raw is gone; caller should stop work + */ + private boolean isAborted(Long rawId, String ctx) { + if (rawService.getById(rawId) == null) { + log.info("[Wiki] Aborting {} for raw={}: raw was deleted mid-processing", ctx, rawId); + return true; + } + return false; + } + + /** + * RFC-051 PR-1c: bridge from {@link DocumentPreprocessService.Chunker} to + * the existing sentence-boundary chunker. Returns {@code [start, end]} + * pairs over the supplied text. + */ + private List splitToOffsetPairs(String text) { + List windows = splitIntoChunksWithOffsets(text); + List out = new ArrayList<>(windows.size()); + for (ChunkWithOffset w : windows) { + out.add(new int[]{w.startOffset(), w.endOffset()}); + } + return out; + } + + /** + * Legacy chunk persistence path used by the lazy branch when the + * preprocessor is unavailable or yields nothing usable. Returns the + * persisted chunk count. + */ + private int persistLegacyLazy(Long kbId, Long rawId, String textContent) { + List chunksWithOffset = splitIntoChunksWithOffsets(textContent); + List chunks = chunksWithOffset.stream().map(ChunkWithOffset::text).toList(); + List offsets = chunksWithOffset.stream() + .map(c -> new int[]{c.startOffset(), c.endOffset()}).toList(); + chunkService.persistChunks(kbId, rawId, chunks, offsets); + return chunks.size(); + } + + /** + * RFC-051 PR-1b: lazy ingest — chunk + embed, no page generation. + *

+ * Intentionally minimal: reuses the legacy {@code persistChunks(List, offsets)} + * overload (no structural metadata; that lands in PR-1c with the preprocessor) + * and the existing {@code embedMissingChunks} entry point. Zero pages is the + * expected outcome, not a failure. + */ + private void processLazyIngest(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw) { + Long rawId = raw.getId(); + Long kbId = kb.getId(); + log.info("[Wiki] Lazy ingest starting for raw={}, kbId={}", rawId, kbId); + + rawService.updateProgress(rawId, "lazy", 0, 0); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_STARTED, + java.util.Map.of("rawId", rawId, "phase", "lazy")); + + try { + String textContent = rawService.getTextContent(raw); + if (textContent == null || textContent.isBlank()) { + 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")); + return; + } + + // RFC-051 PR-9: text extraction can take many seconds on large binaries. + // If the user deleted the raw during that window, persisting chunks for a + // tombstoned row is wasted work that the cascade-cleanup already covered. + if (isAborted(rawId, "lazy ingest after extract")) { + kbService.updateStatus(kbId, "active"); + return; + } + + int totalChunks; + // PR-1c: when the preprocessor is on the classpath, normalize + + // attach metadata; otherwise fall back to the legacy chunker. + if (preprocessService != null) { + List drafts = preprocessService.preprocess(raw, textContent, this::splitToOffsetPairs); + if (drafts.isEmpty()) { + log.warn("[Wiki] Lazy preprocess produced 0 drafts for raw={}, falling back to legacy split", rawId); + totalChunks = persistLegacyLazy(kbId, rawId, textContent); + } else { + chunkService.persistChunks(kbId, rawId, drafts); + totalChunks = drafts.size(); + } + } else { + totalChunks = persistLegacyLazy(kbId, rawId, textContent); + } + log.info("[Wiki] Lazy ingest persisted {} chunks for raw={}", totalChunks, rawId); + + // Async embedding — mirror the eager path so a slow embedding model + // does not block the raw from reaching completed. + final Long fKbId = kbId; + WIKI_EXECUTOR.submit(() -> { + try { + int embedded = embeddingService.embedMissingChunks(fKbId); + if (embedded > 0) { + log.info("[Wiki] Lazy async embedding completed: kbId={}, embedded={}", fKbId, embedded); + } + } catch (Exception ex) { + log.warn("[Wiki] Lazy async embedding failed for kbId={}: {}", fKbId, ex.getMessage()); + } + }); + + rawService.updateProcessingStatus(rawId, "completed", null); + if (raw.getContentHash() != null) { + rawService.setLastProcessedHash(rawId, raw.getContentHash()); + } + rawService.updateProgress(rawId, "done", totalChunks, totalChunks); + int pageCount = pageService.countByKbId(kbId); + kbService.setPageCount(kbId, pageCount); + kbService.updateStatus(kbId, "active"); + + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_COMPLETED, + java.util.Map.of( + "rawId", rawId, + "status", "completed", + "totalPages", 0, + "kbPageCount", pageCount, + "totalChunks", totalChunks)); + + // RFC-051 PR-2c: write an activity log entry. Lead with title so the log + // is human-readable; raw id is implied by chunk lineage. + if (logService != null) { + String title = (raw.getTitle() == null || raw.getTitle().isBlank()) + ? ("raw#" + rawId) : raw.getTitle(); + logService.append(kbId, WikiLogService.EventType.INGEST, + "lazy ingest · " + title + " · " + totalChunks + " chunks"); + } + // 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)); + + log.info("[Wiki] Lazy processing completed for raw={}, kbId={}, chunks={}", + rawId, kbId, totalChunks); + } catch (Exception e) { + log.error("[Wiki] Lazy processing failed for raw={}: {}", rawId, e.getMessage(), e); + rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); + kbService.updateStatus(kbId, "active"); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, + java.util.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 010a53e5..a94d071b 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 @@ -88,16 +88,14 @@ public class WikiRawMaterialService { public WikiRawMaterialEntity addText(Long kbId, String title, String content) { String hash = computeHash(content); - // 去重:相同 hash 且已处理过的材料直接返回 + // Dedup: reuse any existing row with the same hash in this KB (any status) WikiRawMaterialEntity existing = rawMapper.selectOne( new LambdaQueryWrapper() .eq(WikiRawMaterialEntity::getKbId, kbId) .eq(WikiRawMaterialEntity::getContentHash, hash) - .eq(WikiRawMaterialEntity::getProcessingStatus, "completed") .last("LIMIT 1")); if (existing != null) { - log.info("[Wiki] Duplicate text detected (hash={}), returning existing id={}", hash, existing.getId()); - return existing; + return handleDuplicate(existing); } WikiRawMaterialEntity entity = new WikiRawMaterialEntity(); @@ -134,25 +132,28 @@ public class WikiRawMaterialService { entity.setFileSize(fileSize); entity.setProcessingStatus("pending"); - // 计算文件内容 hash(用于上传去重) + // Compute hash of original upload bytes (for dedup). RFC-051: hash raw bytes + // directly — the previous `new String(bytes, UTF_8)` round-trip produced unstable + // hashes for binary files (PDF/Office) because invalid UTF-8 sequences become + // replacement characters, collapsing distinct files into the same hash. try { byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(sourcePath)); - entity.setContentHash(computeHash(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))); + entity.setContentHash(computeHashOfBytes(bytes)); } catch (Exception e) { log.warn("[Wiki] Could not compute file hash for dedup: {}", e.getMessage()); } - // 去重:相同 hash 且已处理过的材料直接返回已有记录 + // Dedup: reuse any existing row with the same hash in this KB (any status) if (entity.getContentHash() != null) { WikiRawMaterialEntity existing = rawMapper.selectOne( new LambdaQueryWrapper() .eq(WikiRawMaterialEntity::getKbId, kbId) .eq(WikiRawMaterialEntity::getContentHash, entity.getContentHash()) - .eq(WikiRawMaterialEntity::getProcessingStatus, "completed") .last("LIMIT 1")); if (existing != null) { - log.info("[Wiki] Duplicate file detected (hash={}), returning existing id={}", entity.getContentHash(), existing.getId()); - return existing; + // Clean up the newly uploaded file — we won't use it + cleanupFile(sourcePath); + return handleDuplicate(existing); } } @@ -221,12 +222,21 @@ public class WikiRawMaterialService { rawMapper.updateById(entity); } + /** + * Cache the extracted text for a raw material. + *

+ * RFC-051: this method no longer touches {@code contentHash}. The previous + * behavior overwrote the original-upload hash with an extracted-text hash, + * which broke upload dedup (re-uploading the same file would compute a hash + * over raw bytes but find a row whose hash had been replaced with extracted + * text). The {@code contentHash} field is now an immutable identity for the + * uploaded artifact; downstream short-circuiting uses {@code lastProcessedHash}. + */ @Transactional - public void updateExtractedText(Long id, String extractedText, String contentHash) { + public void updateExtractedText(Long id, String extractedText) { WikiRawMaterialEntity entity = rawMapper.selectById(id); if (entity == null) return; entity.setExtractedText(extractedText); - entity.setContentHash(contentHash); rawMapper.updateById(entity); } @@ -318,8 +328,8 @@ public class WikiRawMaterialService { log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)", text.length(), entity.getSourcePath()); } else { - // 完整提取结果:缓存以避免重复提取 - updateExtractedText(entity.getId(), text, computeHash(text)); + // Full extraction: cache to avoid re-extracting on subsequent calls. + updateExtractedText(entity.getId(), text); } log.info("[Wiki] Extracted text from {}: {} chars, method={}, truncated={}", entity.getSourcePath(), text.length(), json.getStr("method"), truncated); @@ -334,6 +344,65 @@ public class WikiRawMaterialService { return entity.getOriginalContent(); } + /** + * Recover raw materials stuck in 'processing' status after a server restart. + * Resets them to 'pending', clears stale progress fields, and optionally + * fires processing events so they get picked up automatically. + * + * @return number of recovered rows + */ + @Transactional + public int recoverStuckRawMaterialsOnStartup() { + List stuck = rawMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getProcessingStatus, "processing")); + if (stuck.isEmpty()) return 0; + + for (WikiRawMaterialEntity raw : stuck) { + raw.setProcessingStatus("pending"); + raw.setProgressPhase(null); + raw.setProgressTotal(0); + raw.setProgressDone(0); + raw.setErrorMessage(null); + rawMapper.updateById(raw); + + if (properties.isAutoProcessOnUpload()) { + eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), raw.getKbId())); + } + log.info("[Wiki] Recovered stuck processing raw material: id={}, kbId={}", raw.getId(), raw.getKbId()); + } + return stuck.size(); + } + + /** + * Handle a duplicate upload: decide what to do based on the existing row's status. + * - completed → return as-is (no reprocessing needed) + * - partial / failed → reprocess (partial enters resume branch) + * - pending / processing → return as-is (already queued or running) + */ + private WikiRawMaterialEntity handleDuplicate(WikiRawMaterialEntity existing) { + String prevStatus = existing.getProcessingStatus(); + log.info("[Wiki] Duplicate file detected, reusing id={}, prevStatus={}", existing.getId(), prevStatus); + + if ("partial".equals(prevStatus) || "failed".equals(prevStatus)) { + reprocess(existing.getId()); + } + // completed / pending / processing → return as-is + return existing; + } + + /** + * Delete a file from disk if it exists (cleanup for dedup-discarded uploads). + */ + private void cleanupFile(String path) { + if (path == null) return; + try { + java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get(path)); + } catch (Exception e) { + log.warn("[Wiki] Failed to clean up duplicate upload file {}: {}", path, e.getMessage()); + } + } + private String computeHash(String content) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -344,4 +413,19 @@ public class WikiRawMaterialService { return null; } } + + /** + * SHA-256 over raw bytes. Used for file uploads so that PDF/Office binaries + * produce a stable identity hash regardless of UTF-8 round-tripping. + */ + private String computeHashOfBytes(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(bytes); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + log.warn("[Wiki] Failed to compute byte hash: {}", e.getMessage()); + return null; + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java new file mode 100644 index 00000000..47a5bd0d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java @@ -0,0 +1,124 @@ +package vip.mate.wiki.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.dto.*; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.relation.RelationSignalStrategy; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.repository.WikiPageMapper; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * RFC-029: Wiki relation service — computes multi-signal structural + * relevance between pages using all registered {@link RelationSignalStrategy} beans. + */ +@Slf4j +@Service +public class WikiRelationService { + + private final List signals; + private final WikiPageMapper pageMapper; + private final WikiPageService pageService; + private final WikiPageCitationMapper citationMapper; + + public WikiRelationService(List signals, + WikiPageMapper pageMapper, + WikiPageService pageService, + WikiPageCitationMapper citationMapper) { + this.signals = signals; + this.pageMapper = pageMapper; + this.pageService = pageService; + this.citationMapper = citationMapper; + } + + /** + * Find pages related to a seed page, ranked by multi-signal score. + */ + public List relatedPages(Long kbId, String seedSlug, int topK) { + WikiPageEntity seed = pageService.getBySlug(kbId, seedSlug); + if (seed == null) return List.of(); + + Map totalScores = new HashMap<>(); + Map> signalHits = new HashMap<>(); + + for (RelationSignalStrategy signal : signals) { + try { + signal.score(seed.getId(), kbId).forEach((pid, s) -> { + totalScores.merge(pid, s, Double::sum); + signalHits.computeIfAbsent(pid, k -> new ArrayList<>()).add(signal.signalName()); + }); + } catch (Exception e) { + log.warn("[WikiRelation] Signal '{}' failed for seed={}: {}", + signal.signalName(), seedSlug, e.getMessage()); + } + } + + List topIds = totalScores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(topK) + .map(Map.Entry::getKey) + .toList(); + + if (topIds.isEmpty()) return List.of(); + Map liteMap = pageMapper.selectBatchLite(topIds) + .stream().collect(Collectors.toMap(WikiPageLite::id, l -> l)); + + return topIds.stream() + .filter(liteMap::containsKey) + // RFC-051 PR-2: hide system pages (overview / log) from related results. + .filter(pid -> !liteMap.get(pid).isSystem()) + .map(pid -> new RelatedPageResult( + liteMap.get(pid).slug(), + liteMap.get(pid).title(), + liteMap.get(pid).summary(), + totalScores.get(pid), + signalHits.getOrDefault(pid, List.of()))) + .toList(); + } + + /** + * Explain the relation between two pages with a per-signal breakdown. + */ + public RelationExplanation explain(Long kbId, String slugA, String slugB) { + WikiPageEntity a = pageService.getBySlug(kbId, slugA); + WikiPageEntity b = pageService.getBySlug(kbId, slugB); + if (a == null || b == null) return RelationExplanation.notFound(); + + List breakdown = new ArrayList<>(); + double total = 0; + for (RelationSignalStrategy signal : signals) { + try { + Double score = signal.score(a.getId(), kbId).get(b.getId()); + if (score != null && score > 0) { + breakdown.add(new SignalScore(signal.signalName(), signal.weight(), score)); + total += score; + } + } catch (Exception e) { + log.warn("[WikiRelation] Signal '{}' failed for explain {}<->{}: {}", + signal.signalName(), slugA, slugB, e.getMessage()); + } + } + return new RelationExplanation(slugA, slugB, total, breakdown); + } + + /** + * Find all pages derived from a given raw material. + */ + public List pagesByRawId(Long rawId) { + List pageIds = citationMapper.listPageIdsByRawId(rawId); + if (pageIds.isEmpty()) return List.of(); + return pageMapper.selectBatchLite(pageIds); + } + + /** + * Find all pages that cite a given chunk. + */ + public List pagesByChunkId(Long chunkId) { + List pageIds = citationMapper.listPageIdsByChunkId(chunkId); + if (pageIds.isEmpty()) return List.of(); + return pageMapper.selectBatchLite(pageIds); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java index 38b0aa10..82cb55d2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.i18n.I18nService; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.wiki.model.WikiRawMaterialEntity; @@ -50,6 +51,7 @@ public class WikiResearchService { private final ModelConfigService modelConfigService; private final AgentGraphBuilder agentGraphBuilder; private final ChatStreamTracker streamTracker; + private final I18nService i18n; private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build(); private static final ExecutorService EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -73,8 +75,8 @@ public class WikiResearchService { // Stage 1: Plan List questions = planStage(topic); if (questions.isEmpty()) { - broadcast(sessionId, "research.error", Map.of("message", "主题无法分解为可研究的子问题")); - return new ResearchResult(topic, List.of(), "无法为该主题生成研究计划。"); + broadcast(sessionId, "research.error", Map.of("message", i18n.msg("research.broadcast.no_plan"))); + return new ResearchResult(topic, List.of(), i18n.msg("research.fallback.no_plan")); } broadcast(sessionId, "research.plan", Map.of( "questions", questions.stream().map(q -> Map.of("question", q.question, "intent", q.intent)).toList() @@ -83,8 +85,8 @@ public class WikiResearchService { // Stage 2: Retrieve + Draft (并行) List

sections = draftStage(kbId, questions, topK, sessionId); if (sections.stream().allMatch(s -> s.content == null || s.content.isBlank())) { - broadcast(sessionId, "research.error", Map.of("message", "所有子问题都未能起草出内容")); - return new ResearchResult(topic, sections, "没有足够的材料回答该主题。"); + broadcast(sessionId, "research.error", Map.of("message", i18n.msg("research.broadcast.draft_all_empty"))); + return new ResearchResult(topic, sections, i18n.msg("research.fallback.no_materials")); } // Stage 3: Compose @@ -97,8 +99,8 @@ public class WikiResearchService { return new ResearchResult(topic, sections, report); } catch (Exception e) { log.error("[Research] Failed: kbId={}, topic={}: {}", kbId, topic, e.getMessage(), e); - broadcast(sessionId, "research.error", Map.of("message", e.getMessage() != null ? e.getMessage() : "研究失败")); - return new ResearchResult(topic, List.of(), "研究过程失败: " + e.getMessage()); + broadcast(sessionId, "research.error", Map.of("message", e.getMessage() != null ? e.getMessage() : i18n.msg("research.broadcast.failed"))); + return new ResearchResult(topic, List.of(), i18n.msg("research.fallback.failed", e.getMessage())); } } @@ -174,10 +176,13 @@ public class WikiResearchService { List hits = hybridRetriever.searchChunks(kbId, q.question, topK); if (hits.isEmpty()) { - return new Section(q.question, "现有材料中未找到与该问题相关的内容。", List.of()); + return new Section(q.question, i18n.msg("research.fallback.no_materials_for_question"), List.of()); } - // 装配材料文本(带编号) + // Assemble material snippets with neutral [M1]/[M2]... markers so the + // LLM is not biased toward any output language. The same `[Mn]` token + // is the citation format the draft prompt asks for, so the model can + // refer to materials without translation. StringBuilder materials = new StringBuilder(); List refs = new ArrayList<>(); Map rawTitleCache = new HashMap<>(); @@ -188,8 +193,7 @@ public class WikiResearchService { WikiRawMaterialEntity raw = rawService.getById(id); return raw != null ? raw.getTitle() : "unknown"; }); - materials.append("### 材料 ").append(i + 1) - .append("(来自《").append(rawTitle).append("》)\n") + materials.append("### [M").append(i + 1).append("] Source: ").append(rawTitle).append("\n") .append(hit.snippet()) .append("\n\n"); refs.add(new MaterialRef(i + 1, hit.chunkId(), hit.rawId(), rawTitle)); @@ -203,7 +207,7 @@ public class WikiResearchService { String content = callLlm(systemPrompt, userPrompt, "draft: " + q.question); if (content == null || content.isBlank()) { - content = "现有材料不足以回答该子问题。"; + content = i18n.msg("research.fallback.draft_empty"); } return new Section(q.question, content, refs); @@ -217,7 +221,9 @@ public class WikiResearchService { for (int i = 0; i < sections.size(); i++) { Section s = sections.get(i); - sectionsText.append("### 子问题 ").append(i + 1).append(":").append(s.question).append("\n"); + // Use neutral [Q1]/[Q2] tokens — keeps prompt and (in compose-failure + // fallback) the report itself language-independent. + sectionsText.append("### [Q").append(i + 1).append("] ").append(s.question).append("\n"); sectionsText.append(s.content).append("\n\n"); for (MaterialRef ref : s.materialRefs) { usedMaterials.putIfAbsent(ref.index, ref.rawTitle); @@ -226,7 +232,7 @@ public class WikiResearchService { StringBuilder materialsRef = new StringBuilder(); usedMaterials.forEach((idx, title) -> - materialsRef.append("- 材料 ").append(idx).append(":").append(title).append("\n")); + materialsRef.append("- [M").append(idx).append("] ").append(title).append("\n")); String systemPrompt = PromptLoader.loadPrompt("research/compose-system"); String userPrompt = PromptLoader.loadPrompt("research/compose-user") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java new file mode 100644 index 00000000..f83e52b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiScaffoldService.java @@ -0,0 +1,130 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiPageMapper; + +/** + * RFC-051 PR-2: idempotent system-page scaffold for a knowledge base. + *

+ * A KB always has two system pages once {@link #ensureScaffold(Long)} has run: + *

    + *
  • {@code overview} — entry point describing scope and recent updates.
  • + *
  • {@code log} — append-only ingest / compile / edit record.
  • + *
+ * Both carry {@code page_type='system'} and {@code locked=1} so neither AI + * tools nor batch deletes can remove them. The deterministic-rebuild logic + * for the overview body and the activity-log writer land as follow-ups; this + * skeleton ships only the create-if-missing path. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiScaffoldService { + + public static final String SYSTEM_PAGE_TYPE = "system"; + public static final String OVERVIEW_SLUG = "overview"; + public static final String LOG_SLUG = "log"; + + private final WikiPageService pageService; + private final WikiPageMapper pageMapper; + + /** + * Make sure both system pages exist for {@code kbId}. Re-creates them with + * default content if missing; otherwise refreshes their protection flags + * (in case an earlier version stored them without {@code locked=1}). + *

+ * Safe to call repeatedly: it issues at most two SELECTs and one or two + * INSERT/UPDATEs. Throws nothing; logs on failure. + */ + public void ensureScaffold(Long kbId) { + if (kbId == null) return; + try { + ensureSystemPage(kbId, OVERVIEW_SLUG, "Overview", DEFAULT_OVERVIEW); + ensureSystemPage(kbId, LOG_SLUG, "Log", initialLog()); + } catch (Exception e) { + log.warn("[WikiScaffold] ensureScaffold failed for kbId={}: {}", kbId, e.getMessage()); + } + } + + private void ensureSystemPage(Long kbId, String slug, String title, String content) { + WikiPageEntity existing = pageService.getBySlug(kbId, slug); + if (existing == null) { + WikiPageEntity created = pageService.createPage(kbId, slug, title, content, + summaryOf(content), null, SYSTEM_PAGE_TYPE); + // createPage doesn't set locked; flip it on now via a targeted update. + pageMapper.update(null, + new LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, created.getId()) + .set(WikiPageEntity::getLocked, 1)); + log.info("[WikiScaffold] Created system page slug={} for kbId={}", slug, kbId); + return; + } + // Heal previously-created system pages that lack the locked flag. + boolean needsLockFix = existing.getLocked() == null || existing.getLocked() != 1; + boolean needsTypeFix = !SYSTEM_PAGE_TYPE.equals(existing.getPageType()); + if (needsLockFix || needsTypeFix) { + LambdaUpdateWrapper upd = new LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, existing.getId()); + if (needsLockFix) upd.set(WikiPageEntity::getLocked, 1); + if (needsTypeFix) upd.set(WikiPageEntity::getPageType, SYSTEM_PAGE_TYPE); + pageMapper.update(null, upd); + log.info("[WikiScaffold] Repaired protection flags for slug={} kbId={}", slug, kbId); + } + } + + private String summaryOf(String content) { + if (content == null) return ""; + String oneline = content.replaceAll("\\s+", " ").trim(); + return oneline.length() > 200 ? oneline.substring(0, 200) + "..." : oneline; + } + + private String initialLog() { + return "# Log\n\n## " + java.time.LocalDate.now() + " init\n\n- System pages initialized.\n"; + } + + /** + * Default scaffold for the overview page. + *

+ * Two distinct marker regions: + *

    + *
  • {@code mate:overview:v1} — deterministic stats block, owned by + * {@link WikiOverviewService} and rebuilt synchronously on every + * successful ingest.
  • + *
  • {@code mate:overview:narrative:v1} — LLM-narrated 2-3 sentence + * summary, owned by {@code WikiNarrativeService}, rewritten + * asynchronously after a debounced {@code WikiKbDirtyEvent}.
  • + *
+ * Anything outside both marker pairs is user-authored prose and is + * preserved verbatim by both writers. + */ + private static final String DEFAULT_OVERVIEW = """ + # Overview + + + ## Scope + + - Sources: 0 + - Wiki pages: 0 + - Chunks: 0 + - Last ingest: - + + ## Recent Updates + + _No sources ingested yet._ + + ## Coverage + + - Pages with citations: 0 + - Pages with wikilinks: 0 + - Isolated pages: 0 + + + + _Narrative summary will appear after the first ingest._ + + """; +} 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 bf680716..3d8e7d5f 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 @@ -5,47 +5,85 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import vip.mate.wiki.dto.*; +import vip.mate.wiki.job.WikiProcessingJobService; +import vip.mate.wiki.job.event.WikiJobCreatedEvent; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; -import vip.mate.wiki.service.HybridRetriever; -import vip.mate.wiki.service.WikiKnowledgeBaseService; -import vip.mate.wiki.service.WikiPageService; -import vip.mate.wiki.service.WikiRawMaterialService; +import vip.mate.wiki.repository.WikiRawMaterialMapper; +import vip.mate.wiki.service.*; -import java.util.List; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; /** - * Wiki 知识库工具 + * Wiki knowledge base tools for agent conversations. *

- * 供 Agent 在对话中按需读取 Wiki 页面内容。 - * kbId 通过 agentId 自动解析,LLM 无需传递。 + * All tools auto-resolve kbId from agentId; LLM never needs to pass it directly. * * @author MateClaw Team */ @Slf4j @Component -@RequiredArgsConstructor public class WikiTool { private final WikiPageService pageService; private final WikiKnowledgeBaseService kbService; private final WikiRawMaterialService rawService; private final HybridRetriever hybridRetriever; + private final ObjectMapper objectMapper; + + @Autowired(required = false) + private WikiRelationService relationService; + + @Autowired(required = false) + private WikiProcessingJobService jobService; + + @Autowired(required = false) + private ApplicationEventPublisher eventPublisher; + + @Autowired(required = false) + private WikiRawMaterialMapper rawMaterialMapper; + + /** RFC-051 PR-4: optional on-demand compile. Tool surface skipped when missing. */ + @Autowired(required = false) + private WikiCompileService compileService; + + public WikiTool(WikiPageService pageService, + WikiKnowledgeBaseService kbService, + WikiRawMaterialService rawService, + HybridRetriever hybridRetriever, + ObjectMapper objectMapper) { + this.pageService = pageService; + this.kbService = kbService; + this.rawService = rawService; + this.hybridRetriever = hybridRetriever; + this.objectMapper = objectMapper; + } + + // ==================== RFC-032: Enhanced wiki_read_page ==================== @Tool(description = """ - 读取 Wiki 知识库中指定页面的完整内容。 - 当系统提示词中的 Wiki 页面摘要不够详细时,使用此工具获取完整内容。 - 返回 Markdown 格式的页面内容,包含 [[双向链接]] 和来源原始文件信息。 + Read a wiki page. Use maxChars to limit size (recommended: 3000-6000 for most tasks). + Use sectionHeading to read only one section by its heading text. + 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. """) public String wiki_read_page( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "页面标识符 (slug)") String slug) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug") String slug, + @ToolParam(description = "Max characters to return (null = full page)", required = false) Integer maxChars, + @ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading) { if (slug == null || slug.isBlank()) { return error("slug is required"); @@ -61,38 +99,71 @@ public class WikiTool { return error("Page not found: " + slug); } - // Agent 引用追踪 pageService.trackReference(kbId, slug); + String content = page.getContent(); + + if (sectionHeading != null && !sectionHeading.isBlank()) { + content = extractSection(content, sectionHeading); + } + if (maxChars != null && maxChars > 0) { + content = applyMaxChars(content, maxChars); + } + JSONObject result = JSONUtil.createObj() .set("title", page.getTitle()) .set("slug", page.getSlug()) .set("version", page.getVersion()) .set("lastUpdatedBy", page.getLastUpdatedBy()) - .set("content", page.getContent()) + .set("content", content) .set("sourceFiles", resolveSourceFiles(page.getSourceRawIds())); return result.toString(); } + // ==================== RFC-032: Enhanced wiki_list_pages ==================== + @Tool(description = """ - 列出 Wiki 知识库中的所有页面。 - 返回页面列表,包含标题、slug 和摘要。 + List wiki pages. Add query to filter by title keyword (max 30 results). + Without query returns all pages (use only for small KBs). """) public String wiki_list_pages( - @ToolParam(description = "当前 Agent 的 ID") Long agentId) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Title keyword filter (optional)", required = false) String query) { Long kbId = resolveKbId(agentId); if (kbId == null) { return error("No wiki knowledge base found for this agent"); } - List pages = pageService.listSummaries(kbId); + List pages; + if (query != null && !query.isBlank()) { + List ids = pageService.searchPages(kbId, query).stream() + .filter(p -> !"system".equals(p.getPageType())) + .map(WikiPageEntity::getId).limit(30).toList(); + if (ids.isEmpty()) { + pages = List.of(); + } else { + pages = pageService.listSummaries(kbId).stream() + .filter(p -> !"system".equals(p.getPageType())) + .filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId()))) + .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType())) + .toList(); + } + } else { + // RFC-051 PR-2: hide system pages (overview / log) from default listings. + // Agents can still wiki_read_page("overview") explicitly. + pages = pageService.listSummaries(kbId).stream() + .filter(p -> !"system".equals(p.getPageType())) + .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary(), p.getPageType())) + .toList(); + } + JSONArray arr = new JSONArray(); - for (WikiPageEntity page : pages) { + for (WikiPageLite page : pages) { arr.add(JSONUtil.createObj() - .set("title", page.getTitle()) - .set("slug", page.getSlug()) - .set("summary", page.getSummary())); + .set("slug", page.slug()) + .set("title", page.title()) + .set("summary", page.summary())); } return JSONUtil.createObj() @@ -102,15 +173,19 @@ public class WikiTool { .toString(); } + // ==================== RFC-032: Enhanced wiki_search_pages ==================== + @Tool(description = """ - 在 Wiki 知识库中搜索页面。 - 支持三种模式:keyword(关键词匹配)、semantic(语义向量相似度)、hybrid(两者融合,默认)。 - 返回匹配的页面列表及其来源文件。 + Search wiki pages. Returns snippet so you can judge relevance without reading the full page. + Default topK=5 is sufficient for most queries. + Each result includes "slug" and "title" — use wiki_read_page to get full content. + When using wiki information in your answer, always cite the source page title. """) public String wiki_search_pages( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "搜索关键词或自然语言问题") String query, - @ToolParam(description = "搜索模式:keyword | semantic | hybrid(默认 hybrid)", required = false) String mode) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Search query") String query, + @ToolParam(description = "Mode: keyword|semantic|hybrid (default: hybrid)", required = false) String mode, + @ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK) { if (query == null || query.isBlank()) { return error("query is required"); @@ -121,41 +196,45 @@ public class WikiTool { return error("No wiki knowledge base found for this agent"); } - // RFC-011:走混合检索 - List hits = hybridRetriever.searchPages(kbId, query, mode, 20); + int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5; + List results = hybridRetriever.search(kbId, query, mode, k); - // Agent 引用追踪 - for (HybridRetriever.PageHit h : hits) { - pageService.trackReference(kbId, h.slug()); + for (PageSearchResult r : results) { + pageService.trackReference(kbId, r.slug()); } JSONArray arr = new JSONArray(); - for (HybridRetriever.PageHit hit : hits) { + for (PageSearchResult r : results) { arr.add(JSONUtil.createObj() - .set("title", hit.title()) - .set("slug", hit.slug()) - .set("summary", hit.summary()) - .set("score", String.format("%.4f", hit.score()))); + .set("slug", r.slug()) + .set("title", r.title()) + .set("snippet", r.snippet() != null ? r.snippet() : r.summary()) + .set("matchedBy", r.matchedBy()) + .set("reason", r.reason() != null ? r.reason() : "") + .set("score", String.format("%.4f", r.score()))); } return JSONUtil.createObj() .set("kbId", kbId) .set("query", query) .set("mode", mode != null ? mode : "hybrid") - .set("matchCount", hits.size()) + .set("matchCount", results.size()) .set("pages", arr) .toString(); } + // ==================== RFC-032: N+1 fixed wiki_semantic_search ==================== + @Tool(description = """ - 在 Wiki 知识库中进行 chunk 级语义搜索。 - 返回与查询语义最接近的原始文本片段(chunk),包含相似度分数。 - 当 wiki_search_pages 返回的页面摘要不够具体时,使用此工具获取精确的源文本证据。 + Chunk-level semantic search in the wiki knowledge base. + Returns raw text fragments closest to the query with similarity scores and source page title. + Use when wiki_search_pages results are not specific enough. + When using retrieved content in your answer, cite the source page title shown in each result. """) public String wiki_semantic_search( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "自然语言查询") String query, - @ToolParam(description = "返回条数(默认 5)", required = false) Integer topK) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Natural language query") String query, + @ToolParam(description = "Max results (default 5)", required = false) Integer topK) { if (query == null || query.isBlank()) { return error("query is required"); @@ -178,15 +257,30 @@ public class WikiTool { .toString(); } + // RFC-032: Batch-fetch raw titles (N+1 fix) + Set rawIds = hits.stream().map(HybridRetriever.ChunkHit::rawId).collect(Collectors.toSet()); + Map rawTitles; + if (rawMaterialMapper != null && !rawIds.isEmpty()) { + rawTitles = rawMaterialMapper.selectBatchTitles(rawIds) + .stream().collect(Collectors.toMap(RawTitleRef::id, RawTitleRef::title)); + } else { + rawTitles = Map.of(); + } + JSONArray arr = new JSONArray(); for (HybridRetriever.ChunkHit hit : hits) { - // 解析 raw material 标题 - WikiRawMaterialEntity raw = rawService.getById(hit.rawId()); - arr.add(JSONUtil.createObj() + cn.hutool.json.JSONObject obj = JSONUtil.createObj() .set("chunkId", hit.chunkId()) - .set("rawTitle", raw != null ? raw.getTitle() : "unknown") + .set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown")) .set("snippet", hit.snippet()) - .set("score", String.format("%.4f", hit.score()))); + .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); } return JSONUtil.createObj() @@ -198,13 +292,12 @@ public class WikiTool { } @Tool(description = """ - 追溯 Wiki 页面的来源原始文件。 - 查询指定页面是由哪些原始文档生成的,返回文件名、类型、路径等信息。 - 用于回答"这个内容出自哪篇文档"类的问题。 + Trace the source raw materials for a wiki page. + Returns file names, types, and paths of the original documents. """) public String wiki_trace_source( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "页面标识符 (slug)") String slug) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug") String slug) { if (slug == null || slug.isBlank()) { return error("slug is required"); @@ -228,14 +321,13 @@ public class WikiTool { } @Tool(description = """ - 在 Wiki 知识库中创建新页面。 - 用于保存任务执行结果、分析报告、会议纪要等有价值的信息。 - 内容使用 Markdown 格式。页面标识符 (slug) 会从标题自动生成。 + Create a new wiki page. Used to save task results, analysis reports, etc. + Content should be Markdown. Slug is auto-generated from title. """) public String wiki_create_page( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "页面标题") String title, - @ToolParam(description = "页面内容 (Markdown 格式)") String content) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page title") String title, + @ToolParam(description = "Page content (Markdown)") String content) { if (title == null || title.isBlank()) { return error("title is required"); @@ -249,7 +341,6 @@ public class WikiTool { return error("No wiki knowledge base found for this agent. Create one first."); } - // 从标题生成 slug String slug = title.toLowerCase() .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-") .replaceAll("^-|-$", ""); @@ -257,17 +348,13 @@ public class WikiTool { slug = "page-" + System.currentTimeMillis(); } - // 检查 slug 是否已存在 WikiPageEntity existing = pageService.getBySlug(kbId, slug); if (existing != null) { slug = slug + "-" + System.currentTimeMillis() % 10000; } - // 生成摘要(取前 200 字符) String summary = content.length() > 200 ? content.substring(0, 200) + "..." : content; - WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null); - log.info("[WikiTool] Created page: {} (slug={}, kbId={})", title, slug, kbId); return JSONUtil.createObj() @@ -279,13 +366,149 @@ public class WikiTool { .toString(); } + // ==================== RFC-051 PR-4: on-demand compile + batch read ==================== + @Tool(description = """ - 删除一个 AI 生成的 Wiki 页面。无法删除人工维护的页面(lastUpdatedBy = 'manual')。 - 用于清理过时、冗余或不准确的 Wiki 页面。 + Compile (or update) a single wiki page about a topic from existing chunks. + Use this AFTER lazy ingest when search has surfaced relevant content but no + page exists yet. The page will cite only the evidence chunks the compile + prompt actually used — not every chunk of the source raw material. + Set slug to control the page slug; otherwise it's derived from the topic. + """) + public String wiki_compile_page( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Topic to compile a page about (natural language)") String topic, + @ToolParam(description = "Optional explicit slug for the page", required = false) String slug, + @ToolParam(description = "Max evidence chunks (default 8, max 20)", required = false) Integer maxEvidenceChunks) { + + if (topic == null || topic.isBlank()) { + return error("topic is required"); + } + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + if (compileService == null) return error("Compile service not available"); + + try { + WikiCompileService.CompileResult res = compileService.compilePage(kbId, topic, slug, maxEvidenceChunks); + // RFC-051 follow-up: distinguish "no source material" from a hard error + // so the agent can decide whether to retry, fall back to search, or tell + // the user there's nothing on this topic. + if (res.evidenceChunkCount() == 0) { + return JSONUtil.createObj() + .set("ok", true) + .set("compiled", false) + .set("reason", "no_evidence") + .set("message", "No chunks matched the topic. Try wiki_search_pages, or upload source material first.") + .set("evidenceChunks", 0) + .toString(); + } + return JSONUtil.createObj() + .set("ok", true) + .set("compiled", true) + .set("slug", res.slug()) + .set("title", res.title()) + .set("evidenceChunks", res.evidenceChunkCount()) + .set("created", res.created()) + .toString(); + } catch (IllegalStateException | IllegalArgumentException e) { + return error(e.getMessage()); + } catch (Exception e) { + log.warn("[WikiTool] wiki_compile_page failed: {}", e.getMessage()); + return error("Compile failed: " + e.getMessage()); + } + } + + @Tool(description = """ + Read multiple wiki pages in one call. Prefer this over multiple wiki_read_page + calls when you already know the slugs you need. The response is capped per + page; protected/system pages can still be read explicitly here. + """) + public String wiki_read_many( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Comma-separated slugs (max 10)") String slugs, + @ToolParam(description = "Max chars returned per page (default 2000, max 8000)", required = false) Integer maxCharsPerPage) { + + if (slugs == null || slugs.isBlank()) return error("slugs is required"); + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + + int cap = (maxCharsPerPage == null || maxCharsPerPage <= 0) ? 2000 : Math.min(8000, maxCharsPerPage); + List slugList = Arrays.stream(slugs.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).limit(10).toList(); + if (slugList.isEmpty()) return error("No valid slugs supplied"); + + JSONArray arr = new JSONArray(); + for (String s : slugList) { + WikiPageEntity page = pageService.getBySlug(kbId, s); + if (page == null) { + arr.add(JSONUtil.createObj().set("slug", s).set("found", false)); + continue; + } + String content = page.getContent() == null ? "" : page.getContent(); + boolean truncated = content.length() > cap; + if (truncated) content = content.substring(0, cap) + "\n…(truncated)"; + arr.add(JSONUtil.createObj() + .set("slug", s) + .set("found", true) + .set("title", page.getTitle()) + .set("summary", page.getSummary()) + .set("content", content) + .set("truncated", truncated)); + pageService.trackReference(kbId, s); + } + return JSONUtil.createObj() + .set("kbId", kbId) + .set("requestedCount", slugList.size()) + .set("pages", arr) + .toString(); + } + + @Tool(description = """ + Archive a wiki page so it stops showing up in list / search / related + results, without destroying it. Use this when a page is no longer + relevant but its history (citations, raw lineage) should stay queryable. + System pages (overview / log) cannot be archived. + """) + public String wiki_archive_page( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug to archive") String slug) { + return setArchivedTool(agentId, slug, true, "archived"); + } + + @Tool(description = """ + Unarchive a previously archived wiki page so it shows up in default + list / search / related results again. No-op when the page wasn't archived. + """) + public String wiki_unarchive_page( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug to unarchive") String slug) { + return setArchivedTool(agentId, slug, false, "unarchived"); + } + + private String setArchivedTool(Long agentId, String slug, boolean archive, String verb) { + if (slug == null || slug.isBlank()) return error("slug is required"); + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + boolean changed; + try { + changed = pageService.setArchived(kbId, slug, archive); + } catch (Exception e) { + return error(verb + " failed: " + e.getMessage()); + } + return JSONUtil.createObj() + .set("ok", true) + .set("slug", slug) + .set("changed", changed) + .set("message", changed ? "Page " + verb : "Page already in that state (or not found)") + .toString(); + } + + @Tool(description = """ + Delete an AI-generated wiki page. Cannot delete manually curated pages. """) public String wiki_delete_page( - @ToolParam(description = "当前 Agent 的 ID") Long agentId, - @ToolParam(description = "要删除的页面 slug") String slug) { + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug to delete") String slug) { if (slug == null || slug.isBlank()) { return error("slug is required"); @@ -301,11 +524,17 @@ public class WikiTool { return error("Page not found: " + slug); } - // 安全保护:禁止删除人工维护的页面 if ("manual".equals(page.getLastUpdatedBy())) { return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI."); } + // RFC-051 PR-2: refuse to delete system pages (overview/log) or any + // user-locked page, even when the agent has tool access. + if (WikiPageService.isProtected(page)) { + return error("Cannot delete protected page: " + page.getTitle() + + (page.getLocked() != null && page.getLocked() == 1 ? " (locked)" : " (system)")); + } + pageService.delete(kbId, slug); log.info("[WikiTool] Deleted page: {} (slug={}, kbId={})", page.getTitle(), slug, kbId); @@ -317,24 +546,104 @@ public class WikiTool { .toString(); } - /** - * 通过 agentId 自动解析关联的知识库 ID - *

- * 查找逻辑:Agent 专属 KB + 公共 KB(agent_id IS NULL),取第一个。 - */ + // ==================== RFC-029: Relation tools ==================== + + @Tool(description = """ + Find pages structurally related to a given page (shared sources, links, + semantic similarity). More reliable than keyword search for discovering connected knowledge. + """) + public String wiki_related_pages( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug") String slug, + @ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK) { + + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + if (relationService == null) return error("Relation service not available"); + + int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5; + List results = relationService.relatedPages(kbId, slug, k); + + JSONArray arr = new JSONArray(); + for (RelatedPageResult r : results) { + arr.add(JSONUtil.createObj() + .set("slug", r.slug()) + .set("title", r.title()) + .set("score", String.format("%.2f", r.score())) + .set("signals", r.signals())); + } + + return JSONUtil.createObj() + .set("slug", slug) + .set("relatedCount", results.size()) + .set("pages", arr) + .toString(); + } + + @Tool(description = """ + Explain why two wiki pages are related. Returns signal breakdown with scores. + """) + public String wiki_explain_relation( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "First page slug") String slugA, + @ToolParam(description = "Second page slug") String slugB) { + + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + if (relationService == null) return error("Relation service not available"); + + RelationExplanation ex = relationService.explain(kbId, slugA, slugB); + if (ex.breakdown().isEmpty()) return slugA + " and " + slugB + " have no detected relation."; + + StringBuilder sb = new StringBuilder("Relation score: ") + .append(String.format("%.2f", ex.totalScore())).append("\n"); + ex.breakdown().forEach(s -> sb.append(" ").append(s.signal()) + .append(": ").append(String.format("%.2f", s.score())).append("\n")); + return sb.toString(); + } + + // ==================== RFC-031: Enrichment tool ==================== + + @Tool(description = """ + Trigger lightweight wikilink enrichment for a specific page. + Does NOT regenerate content — only adds [[wikilink]] cross-references. + """) + public String wiki_enrich_page( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Page slug") String slug) { + + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + if (jobService == null || eventPublisher == null) return error("Job service not available"); + + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) return error("Page not found: " + slug); + + Long rawId = 0L; + try { + List rawIds = objectMapper.readValue( + page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]", + new TypeReference>() {}); + if (!rawIds.isEmpty()) rawId = rawIds.get(0); + } catch (Exception ignored) {} + + WikiProcessingJobEntity job = jobService.createLightEnrich(kbId, rawId); + eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId())); + return "Wikilink enrichment queued for: " + slug; + } + + // ==================== Helpers ==================== + private Long resolveKbId(Long agentId) { List kbs = kbService.listByAgentId(agentId); return kbs.isEmpty() ? null : kbs.get(0).getId(); } - /** - * 将 sourceRawIds JSON 数组解析为原始文件信息列表 - */ private JSONArray resolveSourceFiles(String sourceRawIdsJson) { JSONArray result = new JSONArray(); if (sourceRawIdsJson == null || sourceRawIdsJson.isBlank()) return result; try { - List rawIds = new ObjectMapper().readValue(sourceRawIdsJson, new TypeReference>() {}); + List rawIds = objectMapper.readValue(sourceRawIdsJson, new TypeReference>() {}); for (Long rawId : rawIds) { WikiRawMaterialEntity raw = rawService.getById(rawId); if (raw != null) { @@ -351,6 +660,30 @@ public class WikiTool { return result; } + /** + * RFC-032: Extract a section from markdown content by heading text. + */ + private String extractSection(String content, String heading) { + if (content == null) return ""; + String escaped = Pattern.quote(heading.trim()); + Pattern p = Pattern.compile( + "(?m)^(#{1,3})\\s+" + escaped + "\\b.*?(?=^#{1,3}\\s|\\Z)", + Pattern.DOTALL | Pattern.MULTILINE + ); + Matcher m = p.matcher(content); + return m.find() ? m.group().strip() : content; + } + + /** + * RFC-032: Truncate content with a helpful message. + */ + private String applyMaxChars(String text, int maxChars) { + if (text == null || text.length() <= maxChars) return text; + return text.substring(0, maxChars) + + "\n\n[Content truncated at " + maxChars + " chars. " + + "Use sectionHeading param to read a specific section.]"; + } + private String error(String message) { return JSONUtil.createObj().set("error", message).toString(); } 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 5112b53d..06128861 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 @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.agent.model.AgentEntity; import vip.mate.approval.ApprovalPlaceholderUtil; +import vip.mate.approval.MetadataDecision; import vip.mate.agent.repository.AgentMapper; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.MessageContentPart; @@ -20,6 +21,7 @@ import vip.mate.workspace.conversation.vo.MessageVO; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; @@ -431,7 +433,9 @@ public class ConversationService { return objectMapper.readValue(message.getContentParts(), new TypeReference>() {}); } catch (Exception e) { log.warn("Failed to parse content_parts for message {}: {}", message.getId(), e.getMessage()); - return List.of(); + return List.of(MessageContentPart.parseError( + message.getId() != null ? message.getId().toString() : "unknown", + e.getMessage() != null ? e.getMessage() : "unknown error")); } } @@ -448,8 +452,8 @@ public class ConversationService { } switch (part.getType()) { case "text" -> appendSegment(text, part.getText()); - case "thinking", "tool_call" -> { /* skip — frontend reads these from contentParts directly */ } - case "file" -> appendSegment(text, "[附件] " + safe(part.getFileName())); + case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ } + case "file" -> appendSegment(text, renderFilePart(part)); default -> appendSegment(text, part.getText()); } } @@ -491,6 +495,23 @@ public class ConversationService { return rendered; } + /** + * Render a "file" content part for the LLM prompt. The original filename can be + * non-ASCII (Chinese, emoji, …); the upload pipeline sanitizes those characters + * to underscores when storing on disk, so the LLM-visible name and the on-disk + * name diverge. Surface the actual server-side path here so any tool the LLM + * 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) { + String name = safe(part.getFileName()); + String path = safe(part.getPath()); + if (path.isBlank()) { + return "[附件] " + name; + } + return "[附件] " + name + "(路径: " + path + ")"; + } + private void appendSegment(StringBuilder builder, String text) { String safeText = safe(text); if (safeText.isBlank()) { @@ -511,6 +532,206 @@ public class ConversationService { *

* 在 replay 前调用,确保 LLM 上下文中不包含任何审批相关文本。 */ + /** + * Reconcile persisted assistant-message state when one or more pending approvals + * leave the {@code pending} status (approve / deny / timeout / superseded / consumed). + *

+ * For each assistant message in the conversation whose + * {@code metadata.pendingApproval.pendingId} appears in {@code resolvedPendingIds} + * and whose {@code metadata.pendingApproval.status == "pending_approval"}, this + * method updates three fields atomically (within a single transaction): + *

    + *
  1. {@code metadata.pendingApproval.status} → {@code decision.pendingApprovalStatus}
  2. + *
  3. {@code metadata.currentPhase} flips {@code awaiting_approval} → {@code resolved}
  4. + *
  5. {@code MessageEntity.status} flips {@code awaiting_approval} + * → {@code decision.messageStatus} (one of the existing terminal states the + * frontend Message.status union supports)
  6. + *
+ * Without this synchronization, a page refresh re-hydrates the stale + * {@code pending_approval} status from message metadata and the UI pops a ghost + * approval banner for an approval the user already settled. See RFC-067 §4.1.5. + *

+ * Idempotent: messages whose metadata does not match, or whose status already moved + * off {@code pending_approval}, are left untouched. Timeout / superseded callers + * pass {@link MetadataDecision#DENIED}; the more specific terminal status lives + * on {@code mate_tool_approval.status} for audit (see RFC-067 §4.4.1). + * + * @param conversationId target conversation + * @param resolvedPendingIds pendingIds whose owning message metadata should be reconciled + * @param decision the metadata-layer decision to apply + * @return number of messages whose state was rewritten + */ + @Transactional + public int markPendingApprovalsResolved(String conversationId, + java.util.Set resolvedPendingIds, + MetadataDecision decision) { + if (conversationId == null || resolvedPendingIds == null || resolvedPendingIds.isEmpty()) { + return 0; + } + if (decision == null) { + throw new IllegalArgumentException("decision must not be null"); + } + List messages = listMessages(conversationId); + int rewritten = 0; + for (MessageEntity msg : messages) { + if (!"assistant".equals(msg.getRole())) continue; + String raw = msg.getMetadata(); + if (raw == null || raw.isBlank() || !raw.contains("pendingApproval")) continue; + + try { + // H2's JSON column returns the metadata as a JSON-encoded string + // (wrapped + escaped) when read through MyBatis. MessageVO.parseMetadataToObject + // (the read-to-frontend path) already handles this; we mirror the same + // unwrap here. Without it, readValue tokenizes the leading `"` as a + // String token and explodes with "Cannot construct LinkedHashMap from + // String value", silently turning every approve / deny / Stop sweep + // into a no-op (messagesRewritten=0). + String json = raw.trim(); + if (json.startsWith("\"") && json.endsWith("\"")) { + json = objectMapper.readValue(json, String.class); + } + java.util.Map meta = objectMapper.readValue(json, + new TypeReference>() {}); + Object pa = meta.get("pendingApproval"); + if (!(pa instanceof java.util.Map)) continue; + @SuppressWarnings("unchecked") + java.util.Map pendingApproval = (java.util.Map) pa; + Object pid = pendingApproval.get("pendingId"); + if (pid == null || !resolvedPendingIds.contains(String.valueOf(pid))) continue; + Object pendingStatus = pendingApproval.get("status"); + if (!"pending_approval".equals(String.valueOf(pendingStatus))) continue; + + pendingApproval.put("status", decision.pendingApprovalStatus); + meta.put("pendingApproval", pendingApproval); + + Object phase = meta.get("currentPhase"); + if ("awaiting_approval".equals(String.valueOf(phase))) { + meta.put("currentPhase", "resolved"); + } + + // RFC-067 §4.10 (PR 9): flip the matching toolCall + segment entries + // inside this message's metadata. Both DENIED and APPROVED need this + // because the LLM streamed tool_call_started → segment.status='running' + // before the user's decision arrived, and replay creates a NEW assistant + // message rather than updating the original — so without this fix the + // gate message's tool card stays as an orange spinner forever. + // DENIED → success=false + result='[已拒绝]' → red ✗ + // APPROVED → success=true + result='[已批准]' → green ✓ on the gate + // row; the actual execution result still appears in the + // replayed assistant message that follows. + Object toolName = pendingApproval.get("toolName"); + Object toolArgs = pendingApproval.get("arguments"); + String tnStr = toolName == null ? null : String.valueOf(toolName); + String taStr = toolArgs == null ? null : String.valueOf(toolArgs); + flipResolvedToolCalls(meta, tnStr, taStr, decision); + flipResolvedSegments(meta, tnStr, taStr, decision); + + msg.setMetadata(objectMapper.writeValueAsString(meta)); + if ("awaiting_approval".equals(msg.getStatus())) { + msg.setStatus(decision.messageStatus); + } + messageMapper.updateById(msg); + rewritten++; + } catch (Exception e) { + String preview = raw.length() > 200 ? raw.substring(0, 200) + "..." : raw; + log.warn("[ConversationService] Failed to rewrite pendingApproval status for message {} " + + "(rawLen={}, preview={}): {}", + msg.getId(), raw.length(), preview, e.getMessage()); + } + } + if (rewritten > 0) { + log.info("[ConversationService] Reconciled {} message(s) in conversation {} " + + "to decision={} (cleared {} ghost pendings)", + rewritten, conversationId, decision, resolvedPendingIds.size()); + } + return rewritten; + } + + /** + * Flip the gate message's tool-call entry to a terminal state matching the + * approval decision (RFC-067 §4.10). + *

+ * Driven by {@link MetadataDecision}: + *

    + *
  • {@link MetadataDecision#APPROVED} → {@code status='completed'} + + * {@code success=true} + {@code result='[已批准]'}. The actual tool + * execution result appears in the replayed assistant message that + * follows — not on this gate row.
  • + *
  • {@link MetadataDecision#DENIED} → {@code status='completed'} + + * {@code success=false} + {@code result='[已拒绝]'}. MessageBubble + * renders this as a red ✗.
  • + *
+ * Both paths flip status off {@code awaiting_approval} / {@code running} so + * MessageBubble's icon precedence (running > awaiting_approval > success + * branches) can reach the right terminal icon. Without the flip the card + * stays as an orange spinner forever — replay creates a new message + * instead of overwriting the gate row, so nothing else updates it. + * Best-effort: if metadata.toolCalls is missing or no entry matches, this + * is a silent no-op. + */ + @SuppressWarnings("unchecked") + private void flipResolvedToolCalls(java.util.Map meta, + String toolName, String toolArgs, + MetadataDecision decision) { + Object tc = meta.get("toolCalls"); + if (!(tc instanceof java.util.List)) return; + boolean approved = decision == MetadataDecision.APPROVED; + String resultText = approved ? "[已批准]" : "[已拒绝]"; + for (Object entry : (java.util.List) tc) { + if (!(entry instanceof java.util.Map)) continue; + java.util.Map call = (java.util.Map) entry; + if (!matchesNameAndArgs(call.get("name"), call.get("arguments"), toolName, toolArgs)) continue; + Object status = call.get("status"); + if ("awaiting_approval".equals(String.valueOf(status)) + || "running".equals(String.valueOf(status))) { + call.put("status", "completed"); + } + call.put("success", approved ? Boolean.TRUE : Boolean.FALSE); + call.put("result", resultText); + } + } + + /** + * Same terminal-state flip as {@link #flipResolvedToolCalls} but on the + * streaming-segments timeline. Segments use {@code toolName} / {@code toolArgs} + * + {@code toolSuccess} / {@code toolResult} field names (not + * {@code name} / {@code arguments} / {@code success} / {@code result}); the + * shape is otherwise symmetric. + */ + @SuppressWarnings("unchecked") + private void flipResolvedSegments(java.util.Map meta, + String toolName, String toolArgs, + MetadataDecision decision) { + Object segs = meta.get("segments"); + if (!(segs instanceof java.util.List)) return; + boolean approved = decision == MetadataDecision.APPROVED; + String resultText = approved ? "[已批准]" : "[已拒绝]"; + for (Object entry : (java.util.List) segs) { + if (!(entry instanceof java.util.Map)) continue; + java.util.Map seg = (java.util.Map) entry; + if (!"tool_call".equals(String.valueOf(seg.get("type")))) continue; + if (!matchesNameAndArgs(seg.get("toolName"), seg.get("toolArgs"), toolName, toolArgs)) continue; + Object status = seg.get("status"); + if ("awaiting_approval".equals(String.valueOf(status)) + || "running".equals(String.valueOf(status))) { + seg.put("status", "completed"); + } + seg.put("toolSuccess", approved ? Boolean.TRUE : Boolean.FALSE); + seg.put("toolResult", resultText); + } + } + + private static boolean matchesNameAndArgs(Object actualName, Object actualArgs, + String expectedName, String expectedArgs) { + if (expectedName == null || actualName == null) return false; + if (!expectedName.equals(String.valueOf(actualName))) return false; + // Arguments equality: pendingApproval stores them as the JSON-stringified form + // produced by the tool-call creator, identical to what's recorded on the + // toolCall / segment entry. A null comparator on either side falls through. + if (expectedArgs == null) return true; + return expectedArgs.equals(String.valueOf(actualArgs)); + } + @Transactional public void removeApprovalPlaceholders(String conversationId) { List messages = listMessages(conversationId); @@ -571,7 +792,17 @@ public class ConversationService { * 清理会话关联的附件文件 */ public void cleanAttachmentFiles(String conversationId) { - Path dir = UPLOAD_ROOT.resolve(conversationId); + Path dir; + try { + dir = UPLOAD_ROOT.resolve(conversationId); + } catch (InvalidPathException e) { + // Conversation id contains characters illegal on this filesystem + // (e.g. ':' in cron: on Windows). No attachments could + // ever have been written under such an id on this OS, so there + // is nothing to clean. + log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId); + return; + } if (!Files.exists(dir)) { return; } 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 e002927e..5b6fa1bd 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 @@ -54,6 +54,5 @@ public class ConversationEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } 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 34477876..237e1126 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 @@ -100,4 +100,15 @@ public class MessageContentPart { part.setText(jsonPayload); return part; } + + /** + * Create a parse_error content part to surface content_parts deserialization failures + * instead of silently returning an empty list. + */ + public static MessageContentPart parseError(String messageId, String cause) { + MessageContentPart part = new MessageContentPart(); + part.setType("parse_error"); + part.setText("[Message content parse failed] Message ID: " + messageId + " | Cause: " + cause); + return part; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java index 46a49eff..8de74692 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageEntity.java @@ -62,6 +62,5 @@ public class MessageEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java index 35547b86..74eebb49 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java @@ -9,11 +9,19 @@ import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import java.util.List; + /** - * 工作区 Schema 迁移 + * Workspace schema bootstrap. *

- * 确保默认工作区(id=1, slug='default')存在。 - * 在 DatabaseBootstrapRunner (@Order(1)) 之后执行。 + * Ensures the default workspace (id=1, slug='default') exists, and performs a + * first-run-only bootstrap of an admin owner. This is not a + * reconciliation loop: once the default workspace has any owner, subsequent + * startups make no membership changes — operators may legitimately remove an + * admin from the default workspace and that decision must persist across + * restarts (see issue #29). + *

+ * Runs after {@code DatabaseBootstrapRunner} ({@code @Order(1)}). * * @author MateClaw Team */ @@ -60,26 +68,41 @@ public class WorkspaceSchemaMigration implements ApplicationRunner { } /** - * 确保所有现有用户都是默认工作区的成员 + * First-run-only bootstrap: if the default workspace has no owner yet, pick + * the lowest-id active admin and add them as owner. If an owner already + * exists (or was deliberately removed and re-installed by an operator), do + * nothing. If no admin exists at all, log a warning and skip — failing + * startup here would be worse than the recoverable "no owner" state. */ private void ensureDefaultWorkspaceMembership() { try { - // 查找不在默认工作区中的用户 - int inserted = jdbcTemplate.update(""" - INSERT INTO mate_workspace_member (id, workspace_id, user_id, role, create_time, update_time, deleted) - SELECT u.id, 1, u.id, CASE WHEN u.role = 'admin' THEN 'owner' ELSE u.role END, NOW(), NOW(), 0 - FROM mate_user u - WHERE u.deleted = 0 - AND NOT EXISTS ( - SELECT 1 FROM mate_workspace_member wm - WHERE wm.workspace_id = 1 AND wm.user_id = u.id AND wm.deleted = 0 - ) - """); - if (inserted > 0) { - log.info("Added {} existing user(s) to default workspace", inserted); + Integer ownerCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM mate_workspace_member " + + "WHERE workspace_id = 1 AND role = 'owner' AND deleted = 0", + Integer.class); + if (ownerCount != null && ownerCount > 0) { + return; } + List adminIds = jdbcTemplate.queryForList( + "SELECT id FROM mate_user WHERE deleted = 0 AND role = 'admin' " + + "ORDER BY id ASC LIMIT 1", + Long.class); + if (adminIds.isEmpty()) { + log.warn("Default workspace has no owner and no admin user exists; " + + "skipping bootstrap. Operator must assign an owner manually."); + return; + } + Long adminId = adminIds.get(0); + jdbcTemplate.update( + "INSERT INTO mate_workspace_member " + + "(id, workspace_id, user_id, role, create_time, update_time, deleted) " + + "VALUES (?, 1, ?, 'owner', NOW(), NOW(), 0)", + adminId, adminId); + log.info("Bootstrapped admin user {} as owner of default workspace", adminId); } catch (DataAccessException e) { - log.debug("Skipping default workspace membership init: {}", e.getMessage()); + // Defensive guard: tables may not exist yet during very early startup, + // or the insert may collide with a soft-deleted row's primary key. + log.debug("Skipping default workspace owner bootstrap: {}", e.getMessage()); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java index 263443e2..20989b84 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java @@ -45,6 +45,5 @@ public class WorkspaceEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java index 27f5c70c..fc584fcf 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java @@ -43,6 +43,5 @@ public class WorkspaceMemberEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java index 8ec886a9..1e1e493f 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java @@ -209,7 +209,7 @@ public class WorkspaceService { */ public void requirePermission(Long workspaceId, Long userId, String minRole) { if (!hasPermission(workspaceId, userId, minRole)) { - throw new MateClawException("err.workspace.insufficient_permission", "权限不足:需要 " + minRole + " 或更高角色"); + throw new MateClawException("err.workspace.insufficient_permission", 403, "权限不足:需要 " + minRole + " 或更高角色"); } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java index e67f9dac..373dbc76 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/document/model/WorkspaceFileEntity.java @@ -42,6 +42,5 @@ public class WorkspaceFileEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index cd189fd0..8859daf5 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -13,6 +13,9 @@ spring: multipart: max-file-size: 100MB max-request-size: 200MB + threads: + virtual: + enabled: true profiles: active: dev @@ -54,6 +57,9 @@ spring: max-tokens: 4096 # Spring AI 1.1.x 会话记忆配置(使用内嵌 H2 时无需额外配置) chat: + observations: + log-prompt: false + log-completion: false memory: repository: jdbc: @@ -78,11 +84,6 @@ mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl - global-config: - db-config: - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 # SpringDoc OpenAPI springdoc: @@ -138,16 +139,26 @@ mateclaw: enabled: true # 自适应降级(连续 miss 后短路到 NoOp,冷却后恢复) miss-threshold: 5 cool-down-ms: 60000 + # RFC-009 P3.3: per-provider health tracker for the multi-model failover chain. + # When a provider hits failure-threshold consecutive failures it enters a cooldown + # window during which the chain walker skips it, avoiding repeated 5-retry stalls + # against a known-broken provider on every conversation turn. + failover: + health: + enabled: true + failure-threshold: 3 + cooldown-ms: 300000 # MateClaw Agent 配置 mate: agent: graph: observation: - max-single-observation-chars: 8000 - max-total-observation-chars: 24000 - large-result-threshold: 6000 - min-rounds-for-summarize: 3 + # 与 GraphObservationProperties.java 默认值对齐,参考 openclaw token-budget 设计 + max-single-observation-chars: 16000 + max-total-observation-chars: 200000 + large-result-threshold: 32000 + min-rounds-for-summarize: 25 head-ratio: 0.4 truncation-marker: "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n" tool: @@ -156,6 +167,25 @@ mate: per-category: shell: 120 web: 30 + # RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget). + # Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized + # single results to disk; Layer 3 enforces an aggregate cap on the combined + # response size of one tool turn. The full output is preserved on disk and + # the in-context preview points the agent at the spill file (read_file tool). + tool-result: + enabled: true + per-result-threshold-chars: 16000 # was 4000 — prevents WebSearch spill-to-disk + per-turn-budget-chars: 32000 # was 16000 — headroom for multi-tool turns + preview-head-chars: 800 + storage-base-dir: "" + # Retrieval-style tools that must NEVER be spilled. Spilling read_file's + # output causes a recursion: the agent reads the spill path, that read also + # exceeds the threshold, gets spilled to a new path, agent reads that one, + # ad infinitum until MAX_TOOL_CALLS_PER_STEP is hit. Add MCP-provided + # readers here if they have the same role. + excluded-tools: + - read_file + - read_workspace_memory_file conversation: window: # 测试时临时调低:2000 token ≈ 2000 中文字,3 轮对话即可触发压缩 @@ -175,3 +205,24 @@ mate: upload-dir: ./data/wiki-uploads # 上传文件存储目录 max-scan-files: 500 # 目录扫描最大文件数 max-scan-file-size: 52428800 # 扫描时跳过大于此大小的文件(字节,默认 50MB) + # Dream v2 feature flags — production defaults after GA validation + memory: + # Phase 1: lifecycle mediator wiring + lifecycle-mediator-enabled: true + dream: + focused-enabled: true + archive-enabled: true + archive-keep-days: 30 + max-candidates-per-dream: 100 + # Phase 2: SOUL auto-evolution and provider decorators + soul-update-interval: 20 # 20 writes trigger one SOUL.md LLM update (0 = off) + provider-retry-attempts: 1 # 1 = no retry (enable when external providers added) + provider-metrics-enabled: false # actuator dependency now present; enable when external providers added + # Phase 3: fact projection + fact: + projection-enabled: true + projection-rebuild-cron: "0 */30 * * * ?" + llm-extraction-enabled: false # placeholder impl, enable after Phase 3 L4+ + contradiction-check-enabled: false # experimental simple detection, enable after LLM batch impl + trust-half-life-days: 60 + forget-enabled: true diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 2d3b62b0..78d0c23b 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -10,21 +10,21 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n KEY (id) VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react', 'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.', - NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0); + NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0); -- Default Agent: Task Planner (Plan-Execute mode) MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000002, 'Task Planner', 'Task planning assistant for complex multi-step tasks', 'plan_execute', 'You are a professional task planning and execution assistant. You excel at breaking complex goals into executable steps and completing them systematically.', - NULL, 20, TRUE, '📋', 'planning,task', NOW(), NOW(), 0); + NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0); -- StateGraph ReAct Agent (StateGraph architecture) MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react', 'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.', - NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0); + NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0); -- ==================== Local Model Providers (displayed first) ==================== @@ -112,12 +112,24 @@ VALUES ('zhipu-intl', 'Zhipu AI (International)', '', 'OpenAIChatModel', '', 'ht MERGE 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) KEY (provider_id) -VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()); +VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()); + +MERGE 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) +KEY (provider_id) +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()); MERGE 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) KEY (provider_id) VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()); +-- RFC-062: Anthropic Claude Code OAuth provider. Credentials live on local +-- disk (Keychain / ~/.claude/.credentials.json), not in this row — leave +-- api_key + oauth_access_token blank. Bearer-auth requests bypass model +-- discovery + connection check, hence both FALSE. +MERGE 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) +KEY (provider_id) +VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()); + -- ==================== Local model pre-configs (Ollama, disabled by default) ==================== 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 @@ -213,6 +225,9 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (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), @@ -237,15 +252,37 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (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, 'Doubao 1.5 Pro 256K', 'volcengine', 'doubao-1.5-pro-256k', 'Doubao flagship model with 256K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000231, 'Doubao 1.5 Pro 32K', 'volcengine', 'doubao-1.5-pro-32k', 'Doubao flagship model with 32K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000232, 'Doubao 1.5 Lite 32K', 'volcengine', 'doubao-1.5-lite-32k', 'Doubao lite model, cost-effective', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', 'Doubao multimodal vision model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', 'Doubao deep reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', 'Doubao lite reasoning model', 0.7, 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); +(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 (direct OpenAI / Azure / OpenRouter / ChatGPT) +(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). Sonnet/Opus. +-- 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); -- Default system settings MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) @@ -301,6 +338,22 @@ MERGE INTO mate_system_setting (id, setting_key, setting_value, description, cre KEY (id) VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG instance base URL (auto-configured in Docker)', NOW(), NOW()); +-- Speech-to-text (STT) defaults — enabled out of the box so users only need to configure an API key. +-- Skip-if-exists on setting_key (NOT MERGE BY id) so we don't trip the +-- UNIQUE index when an existing user has the row at a runtime-assigned id +-- from toggling the UI before this seed shipped. V46 covers the same. +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 MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) @@ -389,6 +442,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000018, 'CronJobTool', 'Scheduled Tasks', 'Create, list, enable/disable, and delete scheduled tasks (cron jobs) through chat. Supports 5-field cron expressions for flexible scheduling.', 'builtin', 'cronJobTool', '⏰', TRUE, TRUE, NOW(), NOW(), 0); +-- Built-in tool: DOCX Render (RFC-045 — in-process Apache POI, millisecond .docx creation) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +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); + -- 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, @@ -452,6 +510,11 @@ VALUES ( ); -- 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 MERGE 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. MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) KEY (id) 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); @@ -512,6 +575,46 @@ MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, KEY (id) 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); +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +-- 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. @@ -1822,7 +1925,7 @@ SELECT 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', 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 6905fc70..66bbbf51 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -9,21 +9,21 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni 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, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react', 'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.', - NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0) + NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); -- Default Agent: 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', 'Task planning assistant for complex multi-step tasks', 'plan_execute', 'You are a professional task planning and execution assistant. You excel at breaking complex goals into executable steps and completing them systematically.', - NULL, 20, TRUE, '📋', 'planning,task', NOW(), NOW(), 0) + NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); -- StateGraph ReAct Agent (StateGraph architecture) 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, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react', 'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.', - NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0) + NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); -- ==================== Local Model Providers (displayed first) ==================== @@ -58,6 +58,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, VALUES ('aliyun-codingplan', 'Aliyun Coding Plan', 'sk-sp', 'OpenAIChatModel', '', 'https://coding.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time); @@ -102,6 +106,18 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, VALUES ('openrouter', 'OpenRouter', 'sk-or-', 'OpenAIChatModel', '', 'https://openrouter.ai/api/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time); @@ -111,13 +127,23 @@ VALUES ('zhipu-intl', 'Zhipu AI (International)', '', 'OpenAIChatModel', '', 'ht ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +VALUES ('volcengine', 'Volcano Engine', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(update_time); +-- RFC-062: Anthropic Claude Code OAuth provider. Credentials live on local +-- disk (Keychain / ~/.claude/.credentials.json), 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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(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) @@ -173,6 +199,13 @@ VALUES (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), +(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), (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), @@ -214,6 +247,9 @@ VALUES (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), @@ -230,6 +266,22 @@ VALUES (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), @@ -238,15 +290,37 @@ VALUES (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, 'Doubao 1.5 Pro 256K', 'volcengine', 'doubao-1.5-pro-256k', 'Doubao flagship model with 256K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000231, 'Doubao 1.5 Pro 32K', 'volcengine', 'doubao-1.5-pro-32k', 'Doubao flagship model with 32K context', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000232, 'Doubao 1.5 Lite 32K', 'volcengine', 'doubao-1.5-lite-32k', 'Doubao lite model, cost-effective', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', 'Doubao multimodal vision model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', 'Doubao deep reasoning model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', 'Doubao lite reasoning model', 0.7, 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) +(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) 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); -- Default system settings @@ -303,6 +377,26 @@ INSERT INTO mate_system_setting (id, setting_key, setting_value, description, cr VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG instance base URL (auto-configured in Docker)', NOW(), NOW()) ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(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 (FROM DUAL ... 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() +FROM DUAL +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() +FROM DUAL +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() +FROM DUAL +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) @@ -390,6 +484,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name 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 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: 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 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, @@ -451,6 +550,11 @@ VALUES ( ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), transport=VALUES(transport), url=VALUES(url), headers_json=VALUES(headers_json), command=VALUES(command), args_json=VALUES(args_json), env_json=VALUES(env_json), cwd=VALUES(cwd), enabled=VALUES(enabled), connect_timeout_seconds=VALUES(connect_timeout_seconds), read_timeout_seconds=VALUES(read_timeout_seconds), last_status=VALUES(last_status), last_error=VALUES(last_error), last_connected_time=VALUES(last_connected_time), tool_count=VALUES(tool_count), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); @@ -511,6 +615,46 @@ INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author 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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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. @@ -1820,7 +1964,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', 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 150d92c0..746f28de 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -9,21 +9,21 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni 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, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react', '你是 MateClaw,一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。', - NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0) + NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); -- 默认 Agent:任务规划助手(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, 'Task Planner', '任务规划助手,适合复杂多步骤任务', 'plan_execute', '你是一个专业的任务规划和执行助手。你擅长将复杂目标分解为可执行的步骤,并逐步完成。请用中文回复。', - NULL, 20, TRUE, '📋', 'planning,task', NOW(), NOW(), 0) + NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); -- StateGraph ReAct Agent(支持 StateGraph 架构) 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, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent,支持显式推理循环和工具调用', 'react', '你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。', - NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0) + NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); -- ==================== 本地模型 Provider(优先展示) ==================== @@ -58,6 +58,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, VALUES ('aliyun-codingplan', 'Aliyun Coding Plan', 'sk-sp', 'OpenAIChatModel', '', 'https://coding.dashscope.aliyuncs.com/v1', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time); @@ -102,6 +106,18 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, VALUES ('openrouter', 'OpenRouter', 'sk-or-', 'OpenAIChatModel', '', 'https://openrouter.ai/api/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time); @@ -111,13 +127,23 @@ VALUES ('zhipu-intl', 'Zhipu AI (International)', '', 'OpenAIChatModel', '', 'ht ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()) +VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), api_key=VALUES(api_key), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), is_custom=VALUES(is_custom), is_local=VALUES(is_local), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), auth_type=VALUES(auth_type), update_time=VALUES(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) @@ -173,6 +199,13 @@ VALUES (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), +(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), (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), @@ -214,6 +247,9 @@ VALUES (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), @@ -230,6 +266,22 @@ VALUES (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), @@ -238,15 +290,37 @@ VALUES (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, 'Doubao 1.5 Pro 256K', 'volcengine', 'doubao-1.5-pro-256k', '豆包旗舰模型,256K 超长上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000231, 'Doubao 1.5 Pro 32K', 'volcengine', 'doubao-1.5-pro-32k', '豆包旗舰模型,32K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000232, 'Doubao 1.5 Lite 32K', 'volcengine', 'doubao-1.5-lite-32k', '豆包轻量模型,高性价比', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', '豆包多模态视觉模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', '豆包深度推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', '豆包轻量推理模型', 0.7, 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) +(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) 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); -- 默认系统设置 @@ -303,6 +377,25 @@ INSERT INTO mate_system_setting (id, setting_key, setting_value, description, cr VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG 实例地址(Docker 部署时自动配置)', NOW(), NOW()) ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time); +-- 语音识别(STT)默认配置 —— 默认启用,用户只需在模型管理中配置 OpenAI / DashScope API Key 即可使用 +-- 用 setting_key 的 skip-if-exists 写法(FROM DUAL ... 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() +FROM DUAL +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() +FROM DUAL +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() +FROM DUAL +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) @@ -390,6 +483,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000018, 'CronJobTool', '定时任务', '通过对话创建、查看、启停和删除定时任务。支持 5 字段 cron 表达式,灵活设定执行时间。', 'builtin', 'cronJobTool', '⏰', 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); +-- 内置工具: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 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, @@ -453,6 +551,11 @@ VALUES ( ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), transport=VALUES(transport), url=VALUES(url), headers_json=VALUES(headers_json), command=VALUES(command), args_json=VALUES(args_json), env_json=VALUES(env_json), cwd=VALUES(cwd), enabled=VALUES(enabled), connect_timeout_seconds=VALUES(connect_timeout_seconds), read_timeout_seconds=VALUES(read_timeout_seconds), last_status=VALUES(last_status), last_error=VALUES(last_error), last_connected_time=VALUES(last_connected_time), tool_count=VALUES(tool_count), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); @@ -513,6 +616,46 @@ INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author 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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(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. @@ -1106,13 +1249,13 @@ UPDATE mate_skill SET skill_content = '# Steve Jobs · 思维操作系统 -- 1. Web Console(默认启用) 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, +VALUES (1000000001, 'Web 控制台', 'web', 1000000001, '', '{}', TRUE, '默认 Web 控制台渠道,通过浏览器 SSE 流式交互', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), agent_id=VALUES(agent_id), bot_prefix=VALUES(bot_prefix), config_json=VALUES(config_json), enabled=VALUES(enabled), description=VALUES(description), update_time=VALUES(update_time), deleted=VALUES(deleted); -- 2. 钉钉(默认禁用,需配置 client_id/client_secret) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000002, 'DingTalk Bot', 'dingtalk', 1000000001, '', '{ +VALUES (1000000002, '钉钉机器人', 'dingtalk', 1000000001, '', '{ "client_id": "", "client_secret": "", "robot_code": "", @@ -1132,7 +1275,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), ag -- 3. 飞书(默认禁用,需配置 app_id/app_secret) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000003, 'Feishu Bot', 'feishu', 1000000001, '', '{ +VALUES (1000000003, '飞书机器人', 'feishu', 1000000001, '', '{ "app_id": "", "app_secret": "", "encrypt_key": "", @@ -1151,7 +1294,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), ag -- 4. Telegram(默认禁用,需配置 bot_token) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000004, 'Telegram Bot', 'telegram', 1000000001, '', '{ +VALUES (1000000004, 'Telegram 机器人', 'telegram', 1000000001, '', '{ "bot_token": "", "http_proxy": "", "show_typing": true, @@ -1169,7 +1312,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), ag -- 5. Discord(默认禁用,需配置 bot_token) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000005, 'Discord Bot', 'discord', 1000000001, '!mc ', '{ +VALUES (1000000005, 'Discord 机器人', 'discord', 1000000001, '!mc ', '{ "bot_token": "", "http_proxy": "", "dm_policy": "open", @@ -1186,7 +1329,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), ag -- 6. 企业微信智能机器人(默认禁用,需配置 bot_id/secret) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ +VALUES (1000000006, '企业微信机器人', 'wecom', 1000000001, '', '{ "bot_id": "", "secret": "", "welcome_text": "", @@ -1207,7 +1350,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), ag -- 7. QQ 机器人(默认禁用,需配置 app_id/client_secret) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000007, 'QQ Bot', 'qq', 1000000001, '', '{ +VALUES (1000000007, 'QQ 机器人', 'qq', 1000000001, '', '{ "app_id": "", "client_secret": "", "markdown_enabled": true, @@ -1244,7 +1387,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), channel_type=VALUES(channel_type), ag -- 9. Slack(默认禁用,需配置 bot_token / app_token) INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) -VALUES (1000000009, 'Slack Bot', 'slack', 1000000001, '', '{ +VALUES (1000000009, 'Slack 机器人', 'slack', 1000000001, '', '{ "bot_token": "", "app_token": "", "signing_secret": "", @@ -1589,7 +1732,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1736,7 +1879,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1822,7 +1965,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', 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 e945efdd..d7c27a55 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -10,21 +10,21 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n KEY (id) VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react', '你是 MateClaw,一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。', - NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0); + NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0); -- 默认 Agent:任务规划助手(Plan-Execute 模式) MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000002, 'Task Planner', '任务规划助手,适合复杂多步骤任务', 'plan_execute', '你是一个专业的任务规划和执行助手。你擅长将复杂目标分解为可执行的步骤,并逐步完成。请用中文回复。', - NULL, 20, TRUE, '📋', 'planning,task', NOW(), NOW(), 0); + NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0); -- StateGraph ReAct Agent(支持 StateGraph 架构) MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent,支持显式推理循环和工具调用', 'react', '你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。', - NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0); + NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0); -- ==================== 本地模型 Provider(优先展示) ==================== @@ -112,12 +112,22 @@ VALUES ('zhipu-intl', 'Zhipu AI (International)', '', 'OpenAIChatModel', '', 'ht MERGE 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) KEY (provider_id) -VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, TRUE, NOW(), NOW()); +VALUES ('volcengine', 'Volcano Engine (火山引擎)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()); + +MERGE 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) +KEY (provider_id) +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()); MERGE 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) KEY (provider_id) VALUES ('openai-chatgpt', 'OpenAI ChatGPT (OAuth)', '', 'ChatGPTChatModel', '', 'https://chatgpt.com/backend-api', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()); +-- RFC-062:Anthropic Claude Code OAuth 订阅 provider。凭据存储在本地磁盘 +-- (macOS Keychain 或 ~/.claude/.credentials.json),不写入该行。 +MERGE 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) +KEY (provider_id) +VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth 订阅)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()); + -- ==================== 本地模型预配置(Ollama,默认禁用,用户拉取后启用) ==================== 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) @@ -219,6 +229,9 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (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), @@ -243,15 +256,37 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (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, 'Doubao 1.5 Pro 256K', 'volcengine', 'doubao-1.5-pro-256k', '豆包旗舰模型,256K 超长上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000231, 'Doubao 1.5 Pro 32K', 'volcengine', 'doubao-1.5-pro-32k', '豆包旗舰模型,32K 上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000232, 'Doubao 1.5 Lite 32K', 'volcengine', 'doubao-1.5-lite-32k', '豆包轻量模型,高性价比', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000233, 'Doubao 1.5 Vision Pro 32K', 'volcengine', 'doubao-1.5-vision-pro-32k', '豆包多模态视觉模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000234, 'Doubao 1.5 Thinking Pro', 'volcengine', 'doubao-1.5-thinking-pro', '豆包深度推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000235, 'Doubao 1.5 Thinking Lite', 'volcengine', 'doubao-1.5-thinking-lite', '豆包轻量推理模型', 0.7, 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); +(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); -- 默认系统设置 MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time) @@ -307,6 +342,22 @@ MERGE INTO mate_system_setting (id, setting_key, setting_value, description, cre KEY (id) VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG 实例地址(Docker 部署时自动配置)', NOW(), NOW()); +-- 语音识别(STT)默认配置 —— 默认启用,用户只需在模型管理中配置 OpenAI / DashScope API Key 即可使用 +-- 用 setting_key 的 skip-if-exists 写法(不再走 MERGE BY id),避免与 +-- 旧版 UI 已经写入的运行时 snowflake id 在 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'); + -- 内置工具:日期时间 MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) @@ -395,6 +446,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000018, 'CronJobTool', '定时任务', '通过对话创建、查看、启停和删除定时任务。支持 5 字段 cron 表达式,灵活设定执行时间。', 'builtin', 'cronJobTool', '⏰', TRUE, TRUE, NOW(), NOW(), 0); +-- 内置工具:DOCX 渲染(RFC-045 — 进程内 Apache POI,毫秒级新建 .docx) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000019, 'DocxRenderTool', 'DOCX 渲染', '将 Markdown 直接渲染为 .docx 并返回一次性下载链接。进程内 Apache POI 实现,无需 Node.js 子进程;支持标题、加粗、列表、表格。新建文档场景的首选工具。', 'builtin', 'docxRenderTool', '📝', 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, @@ -458,6 +514,11 @@ VALUES ( ); -- 内置技能:从 MateClaw 迁移的技能元数据 +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These MERGE 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. MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0); @@ -518,6 +579,46 @@ MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, KEY (id) 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); +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +-- 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. @@ -1112,13 +1213,13 @@ UPDATE mate_skill SET skill_content = '# Steve Jobs · 思维操作系统 -- 1. Web Console(默认启用) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000001, 'Web Console', 'web', 1000000001, '', '{}', TRUE, +VALUES (1000000001, 'Web 控制台', 'web', 1000000001, '', '{}', TRUE, '默认 Web 控制台渠道,通过浏览器 SSE 流式交互', NOW(), NOW(), 0); -- 2. 钉钉(默认禁用,需配置 client_id/client_secret) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000002, 'DingTalk Bot', 'dingtalk', 1000000001, '', '{ +VALUES (1000000002, '钉钉机器人', 'dingtalk', 1000000001, '', '{ "client_id": "", "client_secret": "", "robot_code": "", @@ -1138,7 +1239,7 @@ VALUES (1000000002, 'DingTalk Bot', 'dingtalk', 1000000001, '', '{ -- 3. 飞书(默认禁用,需配置 app_id/app_secret) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000003, 'Feishu Bot', 'feishu', 1000000001, '', '{ +VALUES (1000000003, '飞书机器人', 'feishu', 1000000001, '', '{ "app_id": "", "app_secret": "", "encrypt_key": "", @@ -1157,7 +1258,7 @@ VALUES (1000000003, 'Feishu Bot', 'feishu', 1000000001, '', '{ -- 4. Telegram(默认禁用,需配置 bot_token) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000004, 'Telegram Bot', 'telegram', 1000000001, '', '{ +VALUES (1000000004, 'Telegram 机器人', 'telegram', 1000000001, '', '{ "bot_token": "", "http_proxy": "", "show_typing": true, @@ -1175,7 +1276,7 @@ VALUES (1000000004, 'Telegram Bot', 'telegram', 1000000001, '', '{ -- 5. Discord(默认禁用,需配置 bot_token) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000005, 'Discord Bot', 'discord', 1000000001, '!mc ', '{ +VALUES (1000000005, 'Discord 机器人', 'discord', 1000000001, '!mc ', '{ "bot_token": "", "http_proxy": "", "dm_policy": "open", @@ -1192,7 +1293,7 @@ VALUES (1000000005, 'Discord Bot', 'discord', 1000000001, '!mc ', '{ -- 6. 企业微信智能机器人(默认禁用,需配置 bot_id/secret) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ +VALUES (1000000006, '企业微信机器人', 'wecom', 1000000001, '', '{ "bot_id": "", "secret": "", "welcome_text": "", @@ -1213,7 +1314,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ -- 7. QQ 机器人(默认禁用,需配置 app_id/client_secret) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000007, 'QQ Bot', 'qq', 1000000001, '', '{ +VALUES (1000000007, 'QQ 机器人', 'qq', 1000000001, '', '{ "app_id": "", "client_secret": "", "markdown_enabled": true, @@ -1250,7 +1351,7 @@ VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{ -- 9. Slack(默认禁用,需配置 bot_token / app_token) MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) KEY (id) -VALUES (1000000009, 'Slack Bot', 'slack', 1000000001, '', '{ +VALUES (1000000009, 'Slack 机器人', 'slack', 1000000001, '', '{ "bot_token": "", "app_token": "", "signing_secret": "", @@ -1595,7 +1696,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1742,7 +1843,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1828,7 +1929,7 @@ SELECT 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V1__baseline_schema.sql b/mateclaw-server/src/main/resources/db/migration/h2/V1__baseline_schema.sql index 6d1cb0c8..8f04dd15 100644 --- a/mateclaw-server/src/main/resources/db/migration/h2/V1__baseline_schema.sql +++ b/mateclaw-server/src/main/resources/db/migration/h2/V1__baseline_schema.sql @@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS mate_model_provider ( name VARCHAR(128) NOT NULL, api_key_prefix VARCHAR(32), chat_model VARCHAR(64), - api_key VARCHAR(256), + api_key VARCHAR(512), base_url VARCHAR(512), generate_kwargs TEXT, is_custom BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V20__purge_soft_deleted_rows.sql b/mateclaw-server/src/main/resources/db/migration/h2/V20__purge_soft_deleted_rows.sql new file mode 100644 index 00000000..a177a81c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/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/h2/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql new file mode 100644 index 00000000..9e87813c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql @@ -0,0 +1,14 @@ +-- 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 +-- +-- Seed: keep DashScope as priority 1 so existing deployments preserve the +-- single-fallback-to-DashScope behavior the hardcoded path used to provide. + +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS fallback_priority INT DEFAULT 0; + +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/h2/V23__wiki_relation_model.sql b/mateclaw-server/src/main/resources/db/migration/h2/V23__wiki_relation_model.sql new file mode 100644 index 00000000..a68e5a4a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V23__wiki_relation_model.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS mate_wiki_page_citation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + page_id BIGINT NOT NULL, + chunk_id BIGINT NOT NULL, + paragraph_idx INT NOT NULL DEFAULT 0, + anchor_text VARCHAR(512), + confidence DECIMAL(4,3) NOT NULL DEFAULT 1.000, + created_by VARCHAR(32) NOT NULL DEFAULT 'system', + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wpc_page ON mate_wiki_page_citation (page_id); +CREATE INDEX IF NOT EXISTS idx_wpc_chunk ON mate_wiki_page_citation (chunk_id); + +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS page_type VARCHAR(32) NOT NULL DEFAULT 'concept'; +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS purpose_hint TEXT; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V24__wiki_processing_job.sql b/mateclaw-server/src/main/resources/db/migration/h2/V24__wiki_processing_job.sql new file mode 100644 index 00000000..4630a74a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V24__wiki_processing_job.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS mate_wiki_processing_job ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + kb_id BIGINT NOT NULL, + raw_id BIGINT NOT NULL, + job_type VARCHAR(32) NOT NULL DEFAULT 'heavy_ingest', + stage VARCHAR(64) NOT NULL DEFAULT 'queued', + status VARCHAR(32) NOT NULL DEFAULT 'queued', + primary_model_id BIGINT, + current_model_id BIGINT, + fallback_chain_json TEXT, + retry_count INT NOT NULL DEFAULT 0, + max_retries INT NOT NULL DEFAULT 3, + error_code VARCHAR(64), + error_message TEXT, + resume_from_stage VARCHAR(64), + meta_json TEXT, + started_at DATETIME(3), + finished_at DATETIME(3), + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_wpj_raw ON mate_wiki_processing_job (raw_id); +CREATE INDEX IF NOT EXISTS idx_wpj_status ON mate_wiki_processing_job (status); +CREATE INDEX IF NOT EXISTS idx_wpj_kb ON mate_wiki_processing_job (kb_id, status); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V25__agent_provider_preference.sql b/mateclaw-server/src/main/resources/db/migration/h2/V25__agent_provider_preference.sql new file mode 100644 index 00000000..985f99d2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V25__agent_provider_preference.sql @@ -0,0 +1,26 @@ +-- RFC-009 Phase 4 PR-3: per-agent provider preferences +-- +-- Lets each agent declare an ordered list of preferred provider ids. Empty +-- table for an agent (no rows) means "use the global fallback chain order" +-- — fully backwards compatible with pre-PR-3 behavior. When rows exist, +-- listed providers are tried in ascending sort_order before any non-listed +-- provider is considered. +-- +-- This is purely a routing hint. The runtime walker still gates each entry +-- through AvailableProviderPool / ProviderHealthTracker — a preferred +-- provider that is HARD-removed or in cooldown is still skipped. + +CREATE TABLE IF NOT EXISTS mate_agent_provider_preference ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + provider_id VARCHAR(128) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider + ON mate_agent_provider_preference(agent_id, provider_id); +CREATE INDEX IF NOT EXISTS idx_agent_provider_order + ON mate_agent_provider_preference(agent_id, sort_order); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V26__dream_report.sql b/mateclaw-server/src/main/resources/db/migration/h2/V26__dream_report.sql new file mode 100644 index 00000000..9fb567b3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/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 DATETIME NOT NULL, + finished_at DATETIME 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 DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_dream_agent_time ON mate_dream_report(agent_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_dream_agent_mode ON mate_dream_report(agent_id, mode, started_at DESC); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V27__memory_recall_review_fields.sql b/mateclaw-server/src/main/resources/db/migration/h2/V27__memory_recall_review_fields.sql new file mode 100644 index 00000000..d25ccd9d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V27__memory_recall_review_fields.sql @@ -0,0 +1,4 @@ +-- Dream v2: candidate state machine fields (rfc-035 §4.1.4) +-- Phase 1 writes values only; filtering enabled in Phase 2. +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 DATETIME; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V28__morning_card_seen.sql b/mateclaw-server/src/main/resources/db/migration/h2/V28__morning_card_seen.sql new file mode 100644 index 00000000..eac7ec12 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V28__morning_card_seen.sql @@ -0,0 +1,14 @@ +-- 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 AUTO_INCREMENT, + user_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + last_seen_at DATETIME NOT NULL, + last_report_id BIGINT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_morning_card_user_agent + ON mate_morning_card_seen(user_id, agent_id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V29__memory_fact_projection.sql b/mateclaw-server/src/main/resources/db/migration/h2/V29__memory_fact_projection.sql new file mode 100644 index 00000000..66eaf306 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V29__memory_fact_projection.sql @@ -0,0 +1,56 @@ +-- 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 AUTO_INCREMENT, + agent_id BIGINT NOT NULL, + source_ref VARCHAR(512) NOT NULL, -- canonical source: "structured/user.md#section_key" or "MEMORY.md#heading" + category VARCHAR(64), -- user_pref, project, tool, general + subject VARCHAR(256), -- entity subject + predicate VARCHAR(256), -- relationship or property + object_value TEXT, -- entity object or value + confidence DOUBLE DEFAULT 1.0, -- extraction confidence [0..1] + trust DOUBLE DEFAULT 0.5, -- derived trust score (feedback + decay) + -- Accumulated columns (NOT overwritten by projection rebuild) + last_used_at DATETIME, + use_count INT DEFAULT 0, + -- Metadata + extracted_by VARCHAR(32) DEFAULT 'pattern', -- pattern | llm + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_fact_agent ON mate_fact(agent_id, deleted); +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); + +-- Entity references (for multi-hop queries) +CREATE TABLE IF NOT EXISTS mate_fact_entity_ref ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + fact_id BIGINT NOT NULL, + entity_name VARCHAR(256) NOT NULL, + entity_type VARCHAR(64), -- person, tool, project, concept + role VARCHAR(32) NOT NULL, -- subject | object + create_time DATETIME 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); + +-- Contradiction tracking (populated by Dream contradiction detection step) +CREATE TABLE IF NOT EXISTS mate_fact_contradiction ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + agent_id BIGINT NOT NULL, + fact_a_id BIGINT NOT NULL, + fact_b_id BIGINT NOT NULL, + description TEXT, + resolution VARCHAR(32), -- null | KEEP_A | KEEP_B | MERGE | IGNORE + resolved_at DATETIME, + resolved_by VARCHAR(64), + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT 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/h2/V30__register_collab_skills.sql b/mateclaw-server/src/main/resources/db/migration/h2/V30__register_collab_skills.sql new file mode 100644 index 00000000..45939741 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V30__register_collab_skills.sql @@ -0,0 +1,36 @@ +-- 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 + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); + +MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) +KEY (id) +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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V31__register_docx_render_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V31__register_docx_render_tool.sql new file mode 100644 index 00000000..e53a35aa --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V31__register_docx_render_tool.sql @@ -0,0 +1,5 @@ +-- V31: Register DocxRenderTool as built-in tool (RFC-045) +-- Idempotent: MERGE INTO updates existing row when 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 (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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V32__bailian_team_provider.sql b/mateclaw-server/src/main/resources/db/migration/h2/V32__bailian_team_provider.sql new file mode 100644 index 00000000..1404418d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V32__bailian_team_provider.sql @@ -0,0 +1,37 @@ +-- 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. + +MERGE 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) +KEY (provider_id) +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()); + +-- Chat models +MERGE 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) +KEY (id) +VALUES (1000000400, 'Qwen 3.6 Plus', 'bailian-team', 'qwen3.6-plus', '百炼团队套餐 — 千问旗舰推理模型,支持视觉理解与文本生成', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) +VALUES (1000000401, 'DeepSeek V3.2', 'bailian-team', 'deepseek-v3.2', '百炼团队套餐 — DeepSeek 最新推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) +VALUES (1000000402, 'GLM-5', 'bailian-team', 'glm-5', '百炼团队套餐 — 智谱 GLM-5 文本生成模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +-- Image generation models (temperature/max_tokens/top_p not applicable) +MERGE 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) +KEY (id) +VALUES (1000000403, 'Qwen Image 2.0', 'bailian-team', 'qwen-image-2.0', '百炼团队套餐 — 千问图片生成模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) +VALUES (1000000404, 'Qwen Image 2.0 Pro', 'bailian-team', 'qwen-image-2.0-pro', '百炼团队套餐 — 千问图片生成旗舰模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) +VALUES (1000000405, 'Wan 2.7 Image', 'bailian-team', 'wan2.7-image', '百炼团队套餐 — 万相图片生成模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) +VALUES (1000000406, 'Wan 2.7 Image Pro', 'bailian-team', 'wan2.7-image-pro', '百炼团队套餐 — 万相图片生成旗舰模型', NULL, NULL, NULL, TRUE, TRUE, FALSE, 'image', NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V33__expand_api_key_column.sql b/mateclaw-server/src/main/resources/db/migration/h2/V33__expand_api_key_column.sql new file mode 100644 index 00000000..0bf535c9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/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 VARCHAR(512) NOT NULL DEFAULT ''; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V34__siliconflow_opencode_providers.sql b/mateclaw-server/src/main/resources/db/migration/h2/V34__siliconflow_opencode_providers.sql new file mode 100644 index 00000000..51458306 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V34__siliconflow_opencode_providers.sql @@ -0,0 +1,67 @@ +-- 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 ────────────────────────────────────────────────────────────── +MERGE 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) +KEY (provider_id) +VALUES ('siliconflow-cn', '硅基流动 (China)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.cn/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()); + +MERGE 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) +KEY (provider_id) +VALUES ('siliconflow-intl', '硅基流动 (International)', 'sk-', 'OpenAIChatModel', '', 'https://api.siliconflow.com/v1', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW()); + +MERGE 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) +KEY (provider_id) +VALUES ('opencode', 'OpenCode', '', 'OpenAIChatModel', '', 'https://opencode.ai/zen/v1', '{}', FALSE, FALSE, FALSE, TRUE, TRUE, FALSE, NOW(), NOW()); + +-- ── SiliconFlow CN ───────────────────────────────────────────────────────── +MERGE 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) +KEY (id) VALUES (1000000500, 'DeepSeek V3', 'siliconflow-cn', 'deepseek-ai/DeepSeek-V3', '硅基流动 — DeepSeek V3,综合能力强,有免费额度', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) VALUES (1000000501, 'DeepSeek R1', 'siliconflow-cn', 'deepseek-ai/DeepSeek-R1', '硅基流动 — DeepSeek R1 推理模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) VALUES (1000000502, 'Qwen3 235B A22B', 'siliconflow-cn', 'Qwen/Qwen3-235B-A22B', '硅基流动 — 千问3旗舰 MoE 模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) VALUES (1000000503, 'Qwen3 30B A3B', 'siliconflow-cn', 'Qwen/Qwen3-30B-A3B', '硅基流动 — 千问3高性价比 MoE 模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) VALUES (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); + +MERGE 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) +KEY (id) VALUES (1000000505, 'DeepSeek V3 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-V3', '硅基流动 Pro — DeepSeek V3 优先调度版', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) VALUES (1000000506, 'DeepSeek R1 Pro', 'siliconflow-cn', 'Pro/deepseek-ai/DeepSeek-R1', '硅基流动 Pro — DeepSeek R1 推理优先调度版', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +-- ── SiliconFlow INTL ──────────────────────────────────────────────────────── +MERGE 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) +KEY (id) 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); + +MERGE 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) +KEY (id) VALUES (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); + +MERGE 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) +KEY (id) VALUES (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); + +MERGE 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) +KEY (id) VALUES (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); + +MERGE 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) +KEY (id) VALUES (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); + +MERGE 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) +KEY (id) VALUES (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); + +MERGE 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) +KEY (id) VALUES (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); + +-- ── OpenCode ─────────────────────────────────────────────────────────────── +MERGE 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) +KEY (id) VALUES (1000000520, 'Big Pickle', 'opencode', 'big-pickle', 'OpenCode 免费模型 — Big Pickle', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, 'chat', NOW(), NOW(), 0); + +MERGE 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) +KEY (id) VALUES (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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql b/mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql new file mode 100644 index 00000000..92daf8e0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql @@ -0,0 +1,7 @@ +-- 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 (JSON) and time so the UI can render them +-- and offer a rescan control. +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_result TEXT DEFAULT NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_time DATETIME DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql b/mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql new file mode 100644 index 00000000..e6f9ec4e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql @@ -0,0 +1,30 @@ +-- 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. +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS name_zh VARCHAR(128) DEFAULT NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS name_en VARCHAR(128) DEFAULT NULL; + +-- 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/h2/V37__wiki_page_source_entries.sql b/mateclaw-server/src/main/resources/db/migration/h2/V37__wiki_page_source_entries.sql new file mode 100644 index 00000000..80667ea8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/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. + +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS source_entries VARCHAR(4096) NULL + COMMENT 'JSON array of {rawId, rawTitle} pairs — canonical source lineage (RFC-047)'; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V38__wiki_chunk_content_mediumtext.sql b/mateclaw-server/src/main/resources/db/migration/h2/V38__wiki_chunk_content_mediumtext.sql new file mode 100644 index 00000000..2e233ccb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V38__wiki_chunk_content_mediumtext.sql @@ -0,0 +1,3 @@ +-- V38: Expand mate_wiki_chunk.content to CLOB (H2 equivalent of MEDIUMTEXT) +-- H2's TEXT is already effectively unbounded, but align with MySQL change for consistency. +ALTER TABLE mate_wiki_chunk ALTER COLUMN content CLOB NOT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V39__rfc051_chunk_metadata.sql b/mateclaw-server/src/main/resources/db/migration/h2/V39__rfc051_chunk_metadata.sql new file mode 100644 index 00000000..d6aaacbe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V39__rfc051_chunk_metadata.sql @@ -0,0 +1,7 @@ +-- V39: RFC-051 PR-1a — chunk structural metadata. +-- All columns are nullable; NULL means "unknown" for legacy chunks. token_count +-- backfill happens asynchronously via WikiChunkTokenBackfillJob. +ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS page_number INT DEFAULT NULL; +ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS token_count INT DEFAULT NULL; +ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS header_breadcrumb VARCHAR(1024) DEFAULT NULL; +ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS source_section VARCHAR(512) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql new file mode 100644 index 00000000..3b1d81e5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V40__rfc051_page_locked.sql @@ -0,0 +1,4 @@ +-- V40: RFC-051 PR-2 — page protection flag. +-- locked=1 blocks AI / tool / UI deletion and batch cleanup. Combined with +-- page_type='system' for the built-in overview / log pages. +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS locked TINYINT NOT NULL DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V41__rfc051_page_archived.sql b/mateclaw-server/src/main/resources/db/migration/h2/V41__rfc051_page_archived.sql new file mode 100644 index 00000000..e7e8c586 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V41__rfc051_page_archived.sql @@ -0,0 +1,4 @@ +-- V41: RFC-051 PR-7 — soft-archive flag. +-- archived=1 hides a page from list / search / related results without +-- destroying it, so re-ingesting the source raw doesn't regenerate it. +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS archived TINYINT NOT NULL DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V42__claude_47_gpt_55_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V42__claude_47_gpt_55_models.sql new file mode 100644 index 00000000..3dfed586 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V42__claude_47_gpt_55_models.sql @@ -0,0 +1,25 @@ +-- 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-{en,zh,mysql-en,mysql-zh}.sql; this migration covers operators who +-- already have V1 baseline + earlier versions applied. +-- +-- 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 +-- 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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V43__rfc062_claude_code_oauth_provider.sql b/mateclaw-server/src/main/resources/db/migration/h2/V43__rfc062_claude_code_oauth_provider.sql new file mode 100644 index 00000000..86624151 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V43__rfc062_claude_code_oauth_provider.sql @@ -0,0 +1,16 @@ +-- 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-{en,zh,mysql-en,mysql-zh}.sql via DatabaseBootstrapRunner; +-- this migration is for operators upgrading from <= V42. +-- +-- MERGE INTO is the H2 idempotent upsert; running this twice is a no-op. + +MERGE 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) +KEY (provider_id) +VALUES ('anthropic-claude-code', 'Anthropic Claude Code (OAuth)', '', 'ClaudeCodeChatModel', '', 'https://api.anthropic.com', '{}', FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, 'oauth', NOW(), NOW()); + +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 +(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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V44__fix_claude_sonnet_47_does_not_exist.sql b/mateclaw-server/src/main/resources/db/migration/h2/V44__fix_claude_sonnet_47_does_not_exist.sql new file mode 100644 index 00000000..4cf55e42 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V44__fix_claude_sonnet_47_does_not_exist.sql @@ -0,0 +1,32 @@ +-- 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"}. +-- +-- Reference: hermes-agent anthropic_adapter.py _ANTHROPIC_OUTPUT_LIMITS +-- (lines 65-93) lists 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. +-- When/if Anthropic ships Sonnet 4.7, a future migration can switch back. + +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/h2/V45__deepseek_v4_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql new file mode 100644 index 00000000..b38908a6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql @@ -0,0 +1,15 @@ +-- Add DeepSeek V4 (flash + pro) model entries to mate_model_config for +-- existing deployments. New installs pick these up via DatabaseBootstrapRunner +-- from data-{en,zh,mysql-en,mysql-zh}.sql; this migration covers operators +-- already on V44. +-- +-- Reference: openclaw extensions/deepseek/models.ts:28-81 — V4 supports +-- reasoning_effort + thinking control. NULL temperature/top_p marks the model +-- as thinking-managed (DeepSeekV4ThinkingDecorator handles the per-request +-- thinking field injection). + +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 +(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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V46__stt_default_enabled.sql b/mateclaw-server/src/main/resources/db/migration/h2/V46__stt_default_enabled.sql new file mode 100644 index 00000000..d179dc97 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V46__stt_default_enabled.sql @@ -0,0 +1,31 @@ +-- Default-enable STT on existing deployments. New installs run V46 first +-- (table empty, INSERT succeeds), then DatabaseBootstrapRunner re-runs the +-- same statements from data-{en,zh,mysql-en,mysql-zh}.sql with the same +-- skip-if-exists semantics, so this is idempotent across both paths. +-- +-- Why default to enabled: STT requires a recording UI gesture to even be +-- exercised, so leaving it off by default just makes "the mic button does +-- nothing" the most-reported support issue. Once enabled, it still does +-- nothing dangerous unless the user has also configured an OpenAI / DashScope +-- API key — those are the real gating credentials. +-- +-- Idiom: INSERT ... SELECT ... WHERE NOT EXISTS, keyed on setting_key. +-- Earlier versions of this migration used MERGE INTO ... KEY (id), which +-- crashed on deployments where the user had already toggled STT in the UI: +-- their row landed at a runtime-assigned snowflake id, then this migration +-- tried to insert a fresh row at id=1000000020 and tripped the UNIQUE index +-- on setting_key. Skip-if-exists preserves whatever value the user picked +-- (don't override an explicit "off" with "on"). See +-- https://git.mate.vip/mate/MateClaw issue noted 2026-04-26. + +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/h2/V47__agent_max_iterations_100.sql b/mateclaw-server/src/main/resources/db/migration/h2/V47__agent_max_iterations_100.sql new file mode 100644 index 00000000..3175bb33 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V47__agent_max_iterations_100.sql @@ -0,0 +1,16 @@ +-- V47: Bump default agents' max_iterations to 100 (QwenPaw-style 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, matching QwenPaw's _MAX_MAX_ITERATIONS upper bound. +-- 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/h2/V48__agent_max_iterations_and_agents_md_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V48__agent_max_iterations_and_agents_md_tools.sql new file mode 100644 index 00000000..1e045cf5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V48__agent_max_iterations_and_agents_md_tools.sql @@ -0,0 +1,35 @@ +-- V48: Two unrelated agent runtime fixes that surfaced in the same dogfood session. +-- +-- 1) max_iterations: V47 only matched rows still holding the original seed (25 / 20). +-- User-customized rows (any value other than 25/20) were silently skipped, so the +-- StateGraph ReAct agent kept running with maxIterations=25 even after V47. +-- V48 widens the condition: any seeded agent row with max_iterations < 100 gets +-- bumped to 100, matching the QwenPaw-style ceiling. Custom rows above 100 still +-- get clamped at runtime by AgentGraphBuilder. +-- +-- 2) AGENTS.md tool guidance: the workspace's seeded AGENTS.md only mentioned +-- `execute_shell_command` and `read_file`, leading the LLM to write document +-- chapters by piping printf / heredoc through the shell (which silently folds +-- multi-line strings into a single line on this host) instead of using the +-- `write_file` tool that's actually registered. After 25+ iterations of failed +-- shell tricks the agent ran out of budget without ever calling renderDocxFromFiles. +-- Replace the relevant line so write_file / edit_file are surfaced. Other +-- workspace files (SOUL.md, MEMORY.md, etc.) are untouched. + +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 等),不要用文本描述你要做什么。', + '需要执行文件操作或命令时,直接调用对应的工具:' || CHAR(10) + || '- 读文件 → `read_file`' || CHAR(10) + || '- 写新文件或覆盖整个文件 → `write_file`(一次写完整内容,不要用 printf / heredoc / echo 拼)' || CHAR(10) + || '- 修改已有文件局部内容 → `edit_file`' || CHAR(10) + || '- 执行 shell 命令 → `execute_shell_command`' || CHAR(10) + || '不要用文本描述你要做什么。' + ) +WHERE filename = 'AGENTS.md' + AND content LIKE '%execute_shell_command、read_file%'; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V49__reenable_write_edit_file_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V49__reenable_write_edit_file_tools.sql new file mode 100644 index 00000000..23ae7664 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V49__reenable_write_edit_file_tools.sql @@ -0,0 +1,24 @@ +-- V49: Re-enable WriteFileTool and EditFileTool. +-- +-- These two tool beans were observed disabled in production +-- (mate_tool.enabled = FALSE for bean_name in writeFileTool / editFileTool), +-- which makes ToolRegistry skip them during agent toolset construction. +-- The LLM still sees a tool named `write_file` from some other surface +-- (likely a name collision through Spring AI's tool discovery from a +-- non-disabled bean's @Tool annotation), and ToolGuard accepts the call — +-- so the request reaches approval. But after approval, the replay path +-- looks the callback up in toolCallbackMap and gets nothing, returning +-- "Tool not found: write_file" to the LLM. The LLM then falls back to +-- assembling files via execute_shell_command + printf / heredoc, which +-- silently collapses multi-line content on this host and burns the +-- iteration budget without producing usable output. +-- +-- The right fix is to make sure the tool the LLM sees is the one the +-- executor can run. Re-enable both rows; if a future install really wants +-- write/edit disabled, the user can flip the toggle in the admin UI again. +-- +-- Idempotent: only flips rows currently disabled. + +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/h2/V50__fix_agents_md_tool_guidance_concat.sql b/mateclaw-server/src/main/resources/db/migration/h2/V50__fix_agents_md_tool_guidance_concat.sql new file mode 100644 index 00000000..5117edbe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V50__fix_agents_md_tool_guidance_concat.sql @@ -0,0 +1,64 @@ +-- V50: Fix V48's broken AGENTS.md UPDATE on H2. +-- +-- V48 used `||` to concatenate strings in the H2 SET clause, but the H2 +-- connection runs in `MODE=MySQL`, where `||` is LOGICAL OR, not string +-- concatenation. The result is that V48's REPLACE() set `content` to a +-- garbage boolean expression, corrupting AGENTS.md for the seeded agents +-- and sending the LLM back to `cat < '' 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. +-- IN-subquery (not EXISTS) reads better in both H2 and MySQL EXPLAIN, and the +-- distinct set is small (≤ N providers, not N messages). +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 >= DATEADD('DAY', -30, CURRENT_TIMESTAMP) + ); + +-- 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/h2/V56__volcengine_plan_provider.sql b/mateclaw-server/src/main/resources/db/migration/h2/V56__volcengine_plan_provider.sql new file mode 100644 index 00000000..9322d32a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V56__volcengine_plan_provider.sql @@ -0,0 +1,40 @@ +-- V56: register Volcano Ark Coding Plan as a separate provider with its own +-- pre-seeded model catalog. +-- +-- Ark exposes a dedicated endpoint for the "Coding Plan" subscription — +-- ark.cn-beijing.volces.com/api/coding/v3 — distinct from the general +-- /api/v3 catalog. The same Volcano account / API key works against both +-- endpoints, but the model id sets are different (Coding Plan exposes +-- `ark-code-latest`, `doubao-seed-code`, coding-tuned Kimi/GLM, etc.). +-- Splitting into a sibling provider lets users keep a chat-tuned and a +-- coding-tuned default side by side. +-- +-- The /v3 suffix is handled by the generalized OpenAI-compatible path +-- resolver — no special completionsPath override needed. + +MERGE 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) +KEY (provider_id) +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() +); + +-- Pre-seed the publicly listed Coding Plan model ids. Date suffixes are +-- part of the official id and must stay verbatim. Users can refresh via +-- discovery once the API key is set. +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 + (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); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V57__cron_run_delivery_status.sql b/mateclaw-server/src/main/resources/db/migration/h2/V57__cron_run_delivery_status.sql new file mode 100644 index 00000000..f47a4d59 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V57__cron_run_delivery_status.sql @@ -0,0 +1,18 @@ +-- RFC-063r §2.9: cron run delivery state machine. +-- +-- delivery_status drives the SQL-CAS that prevents duplicate proactive sends +-- (replaces RFC-063 v1's Caffeine 30-min TTL idempotency). 4-state machine: +-- NONE — no delivery strategy (web-origin cron) +-- PENDING — claimed by AbstractCronResultDelivery#claimRun +-- DELIVERED — markDelivered after successful proactiveSend +-- NOT_DELIVERED — markNotDelivered (or stale-cleanup timeout) +-- +-- delivery_target VARCHAR(512) covers both IM userId (≤64) and Feishu +-- sessionWebhook URLs (~200-256). Index (delivery_status, started_at) is +-- the exact predicate of CronRunStaleCleanup.sweep(). + +ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_status VARCHAR(16) DEFAULT 'NONE' NOT NULL; +ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_target VARCHAR(512); +ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_error VARCHAR(500); + +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/h2/V58__cron_channel_binding.sql b/mateclaw-server/src/main/resources/db/migration/h2/V58__cron_channel_binding.sql new file mode 100644 index 00000000..bcf4dfeb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V58__cron_channel_binding.sql @@ -0,0 +1,12 @@ +-- RFC-063r §2.9: bind a cron job to its originating channel + delivery target. +-- +-- channel_id is the only field that needs an index (operations team queries +-- "all jobs delivering to channel X"). delivery_config is a JSON column — +-- targetId / threadId / accountId are persisted as a structured value via +-- MyBatis Plus JacksonTypeHandler so future delivery-target fields don't +-- require schema changes. + +ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS channel_id BIGINT; +ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS delivery_config TEXT; + +CREATE INDEX IF NOT EXISTS idx_cron_channel ON mate_cron_job(channel_id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V59__approval_chat_origin_snapshot.sql b/mateclaw-server/src/main/resources/db/migration/h2/V59__approval_chat_origin_snapshot.sql new file mode 100644 index 00000000..79266441 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V59__approval_chat_origin_snapshot.sql @@ -0,0 +1,6 @@ +-- RFC-063r §2.12: persist the originating ChatOrigin as a JSON snapshot on the +-- approval row. Used by ChannelMessageRouter.replayApprovedToolCall + web +-- ApprovalController so cross-process / cross-restart approval replays still +-- preserve the channel binding (Memento pattern). + +ALTER TABLE mate_tool_approval ADD COLUMN IF NOT EXISTS chat_origin TEXT; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V60__fix_invalid_workspace_member_roles.sql b/mateclaw-server/src/main/resources/db/migration/h2/V60__fix_invalid_workspace_member_roles.sql new file mode 100644 index 00000000..c569cdb1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/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/h2/V61__agent_creator_user_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V61__agent_creator_user_id.sql new file mode 100644 index 00000000..8d15b911 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V61__agent_creator_user_id.sql @@ -0,0 +1,6 @@ +-- 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). + +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS creator_user_id BIGINT; + +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/h2/V62__cron_job_workspace_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V62__cron_job_workspace_id.sql new file mode 100644 index 00000000..3756cd01 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V62__cron_job_workspace_id.sql @@ -0,0 +1,6 @@ +-- RFC-083: workspace-isolate cron jobs +-- (issue: https://github.com/matevip/mateclaw/issues/37). + +ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; + +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/mysql/V1__baseline_schema.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V1__baseline_schema.sql index c00f11d0..58165b66 100644 --- a/mateclaw-server/src/main/resources/db/migration/mysql/V1__baseline_schema.sql +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V1__baseline_schema.sql @@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS mate_model_provider ( name VARCHAR(128) NOT NULL, api_key_prefix VARCHAR(32), chat_model VARCHAR(64), - api_key VARCHAR(256), + api_key VARCHAR(512), base_url VARCHAR(512), generate_kwargs TEXT, is_custom TINYINT(1) NOT NULL DEFAULT 0, diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V20__purge_soft_deleted_rows.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V20__purge_soft_deleted_rows.sql new file mode 100644 index 00000000..a177a81c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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/mysql/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql new file mode 100644 index 00000000..ddf09921 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql @@ -0,0 +1,25 @@ +-- 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. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_model_provider' + AND COLUMN_NAME = 'fallback_priority'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_model_provider ADD COLUMN fallback_priority INT DEFAULT 0', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 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/mysql/V23__wiki_relation_model.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V23__wiki_relation_model.sql new file mode 100644 index 00000000..be190cca --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V23__wiki_relation_model.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS mate_wiki_page_citation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + page_id BIGINT NOT NULL, + chunk_id BIGINT NOT NULL, + paragraph_idx INT NOT NULL DEFAULT 0, + anchor_text VARCHAR(512), + confidence DECIMAL(4,3) NOT NULL DEFAULT 1.000, + created_by VARCHAR(32) NOT NULL DEFAULT 'system', + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0, + INDEX idx_wpc_page (page_id), + INDEX idx_wpc_chunk (chunk_id) +); + +SET @c1 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'page_type'); +SET @s1 = IF(@c1 = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN page_type VARCHAR(32) NOT NULL DEFAULT ''concept''', + 'SELECT 1'); +PREPARE p1 FROM @s1; EXECUTE p1; DEALLOCATE PREPARE p1; + +SET @c2 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'purpose_hint'); +SET @s2 = IF(@c2 = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN purpose_hint TEXT', + 'SELECT 1'); +PREPARE p2 FROM @s2; EXECUTE p2; DEALLOCATE PREPARE p2; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V24__wiki_processing_job.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V24__wiki_processing_job.sql new file mode 100644 index 00000000..db18e4a9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V24__wiki_processing_job.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS mate_wiki_processing_job ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + kb_id BIGINT NOT NULL, + raw_id BIGINT NOT NULL, + job_type VARCHAR(32) NOT NULL DEFAULT 'heavy_ingest', + stage VARCHAR(64) NOT NULL DEFAULT 'queued', + status VARCHAR(32) NOT NULL DEFAULT 'queued', + primary_model_id BIGINT, + current_model_id BIGINT, + fallback_chain_json TEXT, + retry_count INT NOT NULL DEFAULT 0, + max_retries INT NOT NULL DEFAULT 3, + error_code VARCHAR(64), + error_message TEXT, + resume_from_stage VARCHAR(64), + meta_json TEXT, + started_at DATETIME(3), + finished_at DATETIME(3), + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0, + INDEX idx_wpj_raw (raw_id), + INDEX idx_wpj_status (status), + INDEX idx_wpj_kb (kb_id, status) +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V25__agent_provider_preference.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V25__agent_provider_preference.sql new file mode 100644 index 00000000..941f6e2d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 TINYINT(1) NOT NULL DEFAULT 1, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_agent_provider (agent_id, provider_id), + KEY idx_agent_provider_order (agent_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V26__dream_report.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V26__dream_report.sql new file mode 100644 index 00000000..b4847093 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 DATETIME NOT NULL, + finished_at DATETIME 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 DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT 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/mysql/V27__memory_recall_review_fields.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V27__memory_recall_review_fields.sql new file mode 100644 index 00000000..f18ad019 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V27__memory_recall_review_fields.sql @@ -0,0 +1,18 @@ +-- Dream v2: candidate state machine fields (rfc-035 §4.1.4) +-- Phase 1 writes values only; filtering enabled in Phase 2. +-- MySQL does not support ADD COLUMN IF NOT EXISTS; use INFORMATION_SCHEMA guard. +SET @db_name = DATABASE(); + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'review_count'); +SET @stmt = IF(@col_exists = 0, + 'ALTER TABLE mate_memory_recall ADD COLUMN review_count INT DEFAULT 0', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'last_reviewed_at'); +SET @stmt = IF(@col_exists = 0, + 'ALTER TABLE mate_memory_recall ADD COLUMN last_reviewed_at DATETIME', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V28__morning_card_seen.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V28__morning_card_seen.sql new file mode 100644 index 00000000..10e89c42 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 AUTO_INCREMENT, + user_id BIGINT NOT NULL, + agent_id BIGINT NOT NULL, + last_seen_at DATETIME NOT NULL, + last_report_id BIGINT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + UNIQUE KEY uk_user_agent (user_id, agent_id) +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V29__memory_fact_projection.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V29__memory_fact_projection.sql new file mode 100644 index 00000000..c10edb26 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 AUTO_INCREMENT, + 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 DEFAULT 1.0, + trust DOUBLE DEFAULT 0.5, + last_used_at DATETIME, + use_count INT DEFAULT 0, + extracted_by VARCHAR(32) DEFAULT 'pattern', + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT DEFAULT 0, + INDEX idx_fact_agent (agent_id, deleted), + INDEX idx_fact_agent_source (agent_id, source_ref(255)), + INDEX idx_fact_agent_subject (agent_id, subject(255)) +); + +CREATE TABLE IF NOT EXISTS mate_fact_entity_ref ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + fact_id BIGINT NOT NULL, + entity_name VARCHAR(256) NOT NULL, + entity_type VARCHAR(64), + role VARCHAR(32) NOT NULL, + create_time DATETIME NOT NULL, + INDEX idx_fact_ref_entity (entity_name(128), entity_type), + INDEX idx_fact_ref_fact (fact_id) +); + +CREATE TABLE IF NOT EXISTS mate_fact_contradiction ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + agent_id BIGINT NOT NULL, + fact_a_id BIGINT NOT NULL, + fact_b_id BIGINT NOT NULL, + description TEXT, + resolution VARCHAR(32), + resolved_at DATETIME, + resolved_by VARCHAR(64), + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted TINYINT DEFAULT 0, + INDEX idx_contradiction_agent (agent_id, resolution) +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V30__register_collab_skills.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V30__register_collab_skills.sql new file mode 100644 index 00000000..d7c3be0a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V30__register_collab_skills.sql @@ -0,0 +1,80 @@ +-- 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 DUPLICATE KEY UPDATE + description = VALUES(description), + skill_type = VALUES(skill_type), + icon = VALUES(icon), + version = VALUES(version), + author = VALUES(author), + config_json = VALUES(config_json), + enabled = VALUES(enabled), + builtin = VALUES(builtin), + tags = VALUES(tags), + update_time = VALUES(update_time), + deleted = VALUES(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 DUPLICATE KEY UPDATE + description = VALUES(description), + skill_type = VALUES(skill_type), + icon = VALUES(icon), + version = VALUES(version), + author = VALUES(author), + config_json = VALUES(config_json), + enabled = VALUES(enabled), + builtin = VALUES(builtin), + tags = VALUES(tags), + update_time = VALUES(update_time), + deleted = VALUES(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 DUPLICATE KEY UPDATE + description = VALUES(description), + skill_type = VALUES(skill_type), + icon = VALUES(icon), + version = VALUES(version), + author = VALUES(author), + config_json = VALUES(config_json), + enabled = VALUES(enabled), + builtin = VALUES(builtin), + tags = VALUES(tags), + update_time = VALUES(update_time), + deleted = VALUES(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 DUPLICATE KEY UPDATE + description = VALUES(description), + skill_type = VALUES(skill_type), + icon = VALUES(icon), + version = VALUES(version), + author = VALUES(author), + config_json = VALUES(config_json), + enabled = VALUES(enabled), + builtin = VALUES(builtin), + tags = VALUES(tags), + update_time = VALUES(update_time), + deleted = VALUES(deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V31__register_docx_render_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V31__register_docx_render_tool.sql new file mode 100644 index 00000000..4d45e1a5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), bean_name=VALUES(bean_name), icon=VALUES(icon), update_time=VALUES(update_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V32__bailian_team_provider.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V32__bailian_team_provider.sql new file mode 100644 index 00000000..69319b1e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), model_type=VALUES(model_type), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), model_type=VALUES(model_type), update_time=VALUES(update_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V33__expand_api_key_column.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V33__expand_api_key_column.sql new file mode 100644 index 00000000..96828bf6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 MODIFY COLUMN api_key VARCHAR(512) NOT NULL DEFAULT ''; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V34__siliconflow_opencode_providers.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V34__siliconflow_opencode_providers.sql new file mode 100644 index 00000000..2a4e6522 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), base_url=VALUES(base_url), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), api_key_prefix=VALUES(api_key_prefix), chat_model=VALUES(chat_model), base_url=VALUES(base_url), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), model_type=VALUES(model_type), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), model_type=VALUES(model_type), update_time=VALUES(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 DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), update_time=VALUES(update_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql new file mode 100644 index 00000000..22be7976 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql @@ -0,0 +1,26 @@ +-- 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 (JSON) 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. + +SET @c1 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'security_scan_result'); +SET @s1 := IF(@c1 = 0, + 'ALTER TABLE mate_skill ADD COLUMN security_scan_result TEXT DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt1 FROM @s1; EXECUTE stmt1; DEALLOCATE PREPARE stmt1; + +SET @c2 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'security_scan_time'); +SET @s2 := IF(@c2 = 0, + 'ALTER TABLE mate_skill ADD COLUMN security_scan_time DATETIME DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt2 FROM @s2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql new file mode 100644 index 00000000..4f25092d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql @@ -0,0 +1,49 @@ +-- 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. + +SET @c1 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'name_zh'); +SET @s1 := IF(@c1 = 0, + 'ALTER TABLE mate_skill ADD COLUMN name_zh VARCHAR(128) DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt1 FROM @s1; EXECUTE stmt1; DEALLOCATE PREPARE stmt1; + +SET @c2 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'name_en'); +SET @s2 := IF(@c2 = 0, + 'ALTER TABLE mate_skill ADD COLUMN name_en VARCHAR(128) DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt2 FROM @s2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2; + +-- 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/mysql/V37__wiki_page_source_entries.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V37__wiki_page_source_entries.sql new file mode 100644 index 00000000..fee60f5f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V37__wiki_page_source_entries.sql @@ -0,0 +1,19 @@ +-- 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. + +SET @col_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'source_entries' +); + +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN source_entries JSON NULL COMMENT ''JSON array of {rawId, rawTitle} pairs — canonical source lineage (RFC-047)''', + 'SELECT 1' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V38__wiki_chunk_content_mediumtext.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V38__wiki_chunk_content_mediumtext.sql new file mode 100644 index 00000000..e98c7898 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V38__wiki_chunk_content_mediumtext.sql @@ -0,0 +1,11 @@ +-- V38: Expand mate_wiki_chunk.content from TEXT (64KB) to MEDIUMTEXT (16MB) +-- TEXT max = 65,535 bytes; a 30,000-char Chinese chunk needs ~90,000 bytes (3 bytes/char UTF-8). +-- MEDIUMTEXT supports up to 16,777,215 bytes, safely covering any realistic chunk size. +SET @c := (SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_wiki_chunk' + AND COLUMN_NAME = 'content'); +SET @s := IF(@c = 'text', + 'ALTER TABLE mate_wiki_chunk MODIFY COLUMN content MEDIUMTEXT NOT NULL', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V39__rfc051_chunk_metadata.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V39__rfc051_chunk_metadata.sql new file mode 100644 index 00000000..5730847d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V39__rfc051_chunk_metadata.sql @@ -0,0 +1,17 @@ +-- V39: RFC-051 PR-1a — chunk structural metadata. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_chunk' AND COLUMN_NAME = 'page_number'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_chunk ADD COLUMN page_number INT DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_chunk' AND COLUMN_NAME = 'token_count'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_chunk ADD COLUMN token_count INT DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_chunk' AND COLUMN_NAME = 'header_breadcrumb'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_chunk ADD COLUMN header_breadcrumb VARCHAR(1024) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_chunk' AND COLUMN_NAME = 'source_section'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_chunk ADD COLUMN source_section VARCHAR(512) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql new file mode 100644 index 00000000..0aaeb394 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V40__rfc051_page_locked.sql @@ -0,0 +1,5 @@ +-- V40: RFC-051 PR-2 — page protection flag. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' AND COLUMN_NAME = 'locked'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_page ADD COLUMN locked TINYINT 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/V41__rfc051_page_archived.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V41__rfc051_page_archived.sql new file mode 100644 index 00000000..303ce2c3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V41__rfc051_page_archived.sql @@ -0,0 +1,4 @@ +-- V41: RFC-051 PR-7 — soft-archive flag. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' AND COLUMN_NAME = 'archived'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_page ADD COLUMN archived TINYINT 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/V42__claude_47_gpt_55_models.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V42__claude_47_gpt_55_models.sql new file mode 100644 index 00000000..1aba166e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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 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 +-- 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 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/V43__rfc062_claude_code_oauth_provider.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V43__rfc062_claude_code_oauth_provider.sql new file mode 100644 index 00000000..dcd148af --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V43__rfc062_claude_code_oauth_provider.sql @@ -0,0 +1,34 @@ +-- 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 DUPLICATE KEY UPDATE is the MySQL 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 DUPLICATE KEY UPDATE + name = VALUES(name), + chat_model = VALUES(chat_model), + base_url = VALUES(base_url), + auth_type = VALUES(auth_type), + update_time = VALUES(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 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/V44__fix_claude_sonnet_47_does_not_exist.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V44__fix_claude_sonnet_47_does_not_exist.sql new file mode 100644 index 00000000..27be8b1a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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"}. +-- +-- Reference: hermes-agent anthropic_adapter.py _ANTHROPIC_OUTPUT_LIMITS +-- (lines 65-93) lists 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/mysql/V45__deepseek_v4_models.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V45__deepseek_v4_models.sql new file mode 100644 index 00000000..4be5b4f2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V45__deepseek_v4_models.sql @@ -0,0 +1,20 @@ +-- 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 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/V46__stt_default_enabled.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V46__stt_default_enabled.sql new file mode 100644 index 00000000..3f14519f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V46__stt_default_enabled.sql @@ -0,0 +1,24 @@ +-- 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() +FROM DUAL +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() +FROM DUAL +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() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM mate_system_setting WHERE setting_key = 'sttFallbackEnabled'); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V47__agent_max_iterations_100.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V47__agent_max_iterations_100.sql new file mode 100644 index 00000000..3175bb33 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V47__agent_max_iterations_100.sql @@ -0,0 +1,16 @@ +-- V47: Bump default agents' max_iterations to 100 (QwenPaw-style 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, matching QwenPaw's _MAX_MAX_ITERATIONS upper bound. +-- 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/mysql/V48__agent_max_iterations_and_agents_md_tools.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V48__agent_max_iterations_and_agents_md_tools.sql new file mode 100644 index 00000000..057cc993 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V48__agent_max_iterations_and_agents_md_tools.sql @@ -0,0 +1,23 @@ +-- V48 (MySQL): see h2/V48 for full rationale. +-- Same SQL semantics; CHAR(10) and CONCAT-style string building work identically here, +-- but MySQL's `||` is logical OR by default — use CONCAT() instead. + +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 等),不要用文本描述你要做什么。', + CONCAT( + '需要执行文件操作或命令时,直接调用对应的工具:', CHAR(10), + '- 读文件 → `read_file`', CHAR(10), + '- 写新文件或覆盖整个文件 → `write_file`(一次写完整内容,不要用 printf / heredoc / echo 拼)', CHAR(10), + '- 修改已有文件局部内容 → `edit_file`', CHAR(10), + '- 执行 shell 命令 → `execute_shell_command`', CHAR(10), + '不要用文本描述你要做什么。' + ) + ) +WHERE filename = 'AGENTS.md' + AND content LIKE '%execute_shell_command、read_file%'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V49__reenable_write_edit_file_tools.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V49__reenable_write_edit_file_tools.sql new file mode 100644 index 00000000..638f4b0a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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/mysql/V50__fix_agents_md_tool_guidance_concat.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V50__fix_agents_md_tool_guidance_concat.sql new file mode 100644 index 00000000..81a764f6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V50__fix_agents_md_tool_guidance_concat.sql @@ -0,0 +1,52 @@ +-- V50 (MySQL): mirror of h2/V50. MySQL's `||` is also OR by default, +-- but V48 mysql version already used CONCAT() correctly so this is a +-- safe-no-op rewrite to keep the two seed branches in sync. + +UPDATE mate_workspace_file +SET content = CONCAT( + '## 记忆', CHAR(10), + CHAR(10), + '你的记忆由数据库工作区文件提供连续性:', CHAR(10), + CHAR(10), + '- `PROFILE.md`:稳定用户画像与协作偏好', CHAR(10), + '- `MEMORY.md`:长期事实、经验教训、工具设置、反复出现的模式', CHAR(10), + '- `memory/YYYY-MM-DD.md`:当日事件、观察、一次性上下文', CHAR(10), + CHAR(10), + '### 记忆策略', CHAR(10), + CHAR(10), + '- 稳定信息进入 `PROFILE.md` 或 `MEMORY.md`', CHAR(10), + '- 临时事件进入 `memory/YYYY-MM-DD.md`', CHAR(10), + '- 修改前先读取原文,优先做增量编辑而不是整篇重写', CHAR(10), + '- 避免记录敏感信息,除非用户明确要求', CHAR(10), + CHAR(10), + '### 主动召回', CHAR(10), + CHAR(10), + '- 遇到历史偏好、旧决策、持续任务、用户习惯时,优先查看工作区记忆', CHAR(10), + '- 不确定具体发生日期时,检查相关 `memory/YYYY-MM-DD.md`', CHAR(10), + CHAR(10), + '## 安全', CHAR(10), + CHAR(10), + '- 绝不泄露私密数据。', CHAR(10), + '- 拿不准的事情,先确认。', CHAR(10), + CHAR(10), + '## 边界', CHAR(10), + CHAR(10), + '- 私密的保持私密。', CHAR(10), + '- 需要执行文件操作或命令时,**必须**调用对应的工具:', CHAR(10), + ' - 读文件 → `read_file`', CHAR(10), + ' - 写新文件或覆盖整个文件 → `write_file`(一次写完整内容,不要用 printf / heredoc / echo / cat << EOF 拼字符串)', CHAR(10), + ' - 修改已有文件局部内容 → `edit_file`', CHAR(10), + ' - 执行 shell 命令 → `execute_shell_command`', CHAR(10), + ' 禁止用 shell 命令绕过 `write_file` 写文件。系统会自动对危险操作弹出审批确认。', CHAR(10), + '- 拿不准就先问。', CHAR(10), + CHAR(10), + '## 风格', CHAR(10), + CHAR(10), + '该简洁就简洁,重要时详细。', CHAR(10), + CHAR(10), + '## 连续性', CHAR(10), + CHAR(10), + '每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。', CHAR(10) +) +WHERE filename = 'AGENTS.md' + AND agent_id IN (1000000001, 1000000002, 1000000003); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V51__remove_write_edit_file_from_toolguard.sql b/mateclaw-server/src/main/resources/db/migration/mysql/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/mysql/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/mysql/V52__feishu_default_connection_mode.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V52__feishu_default_connection_mode.sql new file mode 100644 index 00000000..ee8a2b3f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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/mysql/V53__feishu_connection_mode_recover.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V53__feishu_connection_mode_recover.sql new file mode 100644 index 00000000..fe44b113 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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, MySQL-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 LOCATE('"connection_mode"', 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 (LOCATE('"connection_mode": "websocket"', config_json) = 0 + AND LOCATE('"connection_mode":"websocket"', config_json) = 0) + ); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V54__channel_names_to_chinese_for_zh_locale.sql b/mateclaw-server/src/main/resources/db/migration/mysql/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/mysql/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/mysql/V55__provider_enabled.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V55__provider_enabled.sql new file mode 100644 index 00000000..ea6d7c22 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V55__provider_enabled.sql @@ -0,0 +1,51 @@ +-- 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 ─────────────────────────────────────────────────── +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_model_provider' + AND COLUMN_NAME = 'enabled'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_model_provider ADD COLUMN enabled BOOLEAN DEFAULT FALSE', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ── Index supporting Rule 3's 30-day usage lookup ────────────────────────── +SET @i := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_message' + AND INDEX_NAME = 'idx_message_runtime_provider_time'); +SET @s := IF(@i = 0, + 'CREATE INDEX idx_message_runtime_provider_time ON mate_message(runtime_provider, create_time)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ── 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 INSTR(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 >= DATE_SUB(NOW(), INTERVAL 30 DAY) + ); + +-- ── 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/mysql/V56__volcengine_plan_provider.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V56__volcengine_plan_provider.sql new file mode 100644 index 00000000..5e3409c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V56__volcengine_plan_provider.sql @@ -0,0 +1,41 @@ +-- 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 DUPLICATE KEY UPDATE + name = VALUES(name), + chat_model = VALUES(chat_model), + base_url = VALUES(base_url), + generate_kwargs = VALUES(generate_kwargs), + support_model_discovery = VALUES(support_model_discovery), + support_connection_check = VALUES(support_connection_check), + freeze_url = VALUES(freeze_url), + require_api_key = VALUES(require_api_key), + update_time = VALUES(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 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/V57__cron_run_delivery_status.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V57__cron_run_delivery_status.sql new file mode 100644 index 00000000..4e19b522 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V57__cron_run_delivery_status.sql @@ -0,0 +1,22 @@ +-- 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). + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_status'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_status VARCHAR(16) NOT NULL DEFAULT ''NONE''', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_target'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_target VARCHAR(512)', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_error'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_error VARCHAR(500)', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND INDEX_NAME = 'idx_cron_run_pending_started'); +SET @s := IF(@c = 0, 'CREATE INDEX idx_cron_run_pending_started ON mate_cron_job_run(delivery_status, started_at)', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V58__cron_channel_binding.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V58__cron_channel_binding.sql new file mode 100644 index 00000000..e310e3d3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V58__cron_channel_binding.sql @@ -0,0 +1,13 @@ +-- RFC-063r §2.9: bind a cron job to its originating channel (MySQL dialect). + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND COLUMN_NAME = 'channel_id'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job ADD COLUMN channel_id BIGINT', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND COLUMN_NAME = 'delivery_config'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job ADD COLUMN delivery_config TEXT', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND INDEX_NAME = 'idx_cron_channel'); +SET @s := IF(@c = 0, 'CREATE INDEX idx_cron_channel ON mate_cron_job(channel_id)', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V59__approval_chat_origin_snapshot.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V59__approval_chat_origin_snapshot.sql new file mode 100644 index 00000000..7311c020 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V59__approval_chat_origin_snapshot.sql @@ -0,0 +1,5 @@ +-- RFC-063r §2.12: persist ChatOrigin Memento snapshot on approval (MySQL dialect). + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_tool_approval' AND COLUMN_NAME = 'chat_origin'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_tool_approval ADD COLUMN chat_origin TEXT', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V60__fix_invalid_workspace_member_roles.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V60__fix_invalid_workspace_member_roles.sql new file mode 100644 index 00000000..c569cdb1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/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/mysql/V61__agent_creator_user_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V61__agent_creator_user_id.sql new file mode 100644 index 00000000..3913056a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V61__agent_creator_user_id.sql @@ -0,0 +1,10 @@ +-- 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). + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent' AND COLUMN_NAME = 'creator_user_id'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_agent ADD COLUMN creator_user_id BIGINT', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent' AND INDEX_NAME = 'idx_agent_creator_user'); +SET @s := IF(@c = 0, 'CREATE INDEX idx_agent_creator_user ON mate_agent(creator_user_id)', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V62__cron_job_workspace_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V62__cron_job_workspace_id.sql new file mode 100644 index 00000000..203dc8fe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V62__cron_job_workspace_id.sql @@ -0,0 +1,20 @@ +-- RFC-083: workspace-isolate cron jobs +-- (issue: https://github.com/matevip/mateclaw/issues/37). + +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_cron_job' + AND COLUMN_NAME = 'workspace_id'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_cron_job ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_cron_job' + AND INDEX_NAME = 'idx_cron_job_workspace'); +SET @stmt := IF(@idx_exists = 0, + 'CREATE INDEX idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql b/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql deleted file mode 100644 index ec6de9c0..00000000 --- a/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql +++ /dev/null @@ -1,63 +0,0 @@ --- ==================== 内置工具同步(MySQL / MariaDB 专用) ==================== --- 每次启动都执行,INSERT ... ON DUPLICATE KEY UPDATE 是幂等的。 --- 新增内置工具时在此文件追加一条 INSERT,重启后即生效。 - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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', '📝', FALSE, 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), builtin=VALUES(builtin), update_time=NOW(); - -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', '✏️', FALSE, 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); - -INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -VALUES (1000000015, 'DatasourceTool', '数据源查询', '查询外部数据源的元数据:列出可用数据源、查看表列表、查看表结构(列名/类型/注释)。支持 MySQL、PostgreSQL、ClickHouse。', 'builtin', 'datasourceTool', '🗄', 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), builtin=VALUES(builtin), update_time=NOW(); - -INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -VALUES (1000000016, 'SqlQueryTool', 'SQL 查询', '在外部数据源上执行只读 SQL 查询。仅允许 SELECT 语句,自动添加 LIMIT 保护,结果格式化为表格展示。', 'builtin', 'sqlQueryTool', '📊', 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), builtin=VALUES(builtin), update_time=NOW(); - -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 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), builtin=VALUES(builtin), update_time=NOW(); diff --git a/mateclaw-server/src/main/resources/db/tools-sync.sql b/mateclaw-server/src/main/resources/db/tools-sync.sql deleted file mode 100644 index ea96a26c..00000000 --- a/mateclaw-server/src/main/resources/db/tools-sync.sql +++ /dev/null @@ -1,70 +0,0 @@ --- ==================== 内置工具同步(每次启动都执行,MERGE 是幂等的) ==================== --- 只包含 mate_tool 注册,不包含工作区文件等用户数据。 --- 新增内置工具时在此文件追加一条 MERGE,重启后即生效,无需重建数据库。 - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000001, 'DateTimeTool', '日期时间', '获取当前日期和时间信息', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000002, 'WebSearchTool', '网络搜索', '在互联网上搜索实时信息', 'builtin', 'webSearchTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000003, 'ShellExecuteTool', '命令执行', '在本地服务器上执行 Shell 命令。用于执行系统命令、查看文件、运行脚本等操作。危险操作会触发审批确认。', 'builtin', 'shellExecuteTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000004, 'ReadFileTool', '读取文件', '读取指定文件的内容,支持按行范围读取,自动截断超大输出。', 'builtin', 'readFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0); - --- WriteFileTool / EditFileTool 默认禁用(enabled=FALSE),MERGE 只在 id 不存在时才插入默认值 --- 如果用户已经手动在 UI 改为启用,此处的 MERGE 不会把 enabled 重置 --- (注意:MERGE KEY(id) 在 id 已存在时会覆写 enabled,所以保持与 data.sql 一致的默认值即可) -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', FALSE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', FALSE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000007, 'SkillFileTool', '技能文件读取', '读取技能包内的文件(SKILL.md/references/scripts),列出技能文件目录树。支持 read_skill_file 和 list_skill_files 两个工具。', 'builtin', 'skillFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000008, 'SkillScriptTool', '技能脚本执行', '执行技能包 scripts/ 目录下的脚本(Python/Bash/Node),路径严格限制在技能目录内。', 'builtin', 'skillScriptTool', '⚡', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000009, 'FileTypeDetectorTool', '文件类型检测', '检测文件的 MIME 类型和类别,区分文本文件和 PDF/Office 文档,帮助选择合适的读取工具。', 'builtin', 'fileTypeDetectorTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000010, 'DocumentExtractTool', '文档文本提取', '从 PDF、Word、Excel、PowerPoint 等 Office 文档中提取纯文本内容。支持 fallback 链:系统命令优先,Java 实现兜底。', 'builtin', 'documentExtractTool', '📄', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000011, 'WorkspaceMemoryTool', '工作区记忆', '读写数据库中的工作区 Markdown 文档,用于维护 PROFILE.md、MEMORY.md 和 memory/YYYY-MM-DD.md 等持久记忆。', 'builtin', 'workspaceMemoryTool', '🧠', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000014, 'DelegateAgentTool', 'Agent 委派', '委派任务给其他 Agent 执行,实现多 Agent 协作。支持按名称调用目标 Agent,在独立会话中运行并返回结果。', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000015, 'DatasourceTool', '数据源查询', '查询外部数据源的元数据:列出可用数据源、查看表列表、查看表结构(列名/类型/注释)。支持 MySQL、PostgreSQL、ClickHouse。', 'builtin', 'datasourceTool', '🗄', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000016, 'SqlQueryTool', 'SQL 查询', '在外部数据源上执行只读 SQL 查询。仅允许 SELECT 语句,自动添加 LIMIT 保护,结果格式化为表格展示。', 'builtin', 'sqlQueryTool', '📊', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000017, 'WikiTool', 'Wiki 知识库', '读取、搜索 Wiki 知识库中的结构化页面,并追溯原始来源文件。支持 wiki_read_page、wiki_list_pages、wiki_search_pages、wiki_trace_source 四个工具。', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0); - -MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) -KEY (id) -VALUES (1000000018, 'CronJobTool', '定时任务', '通过对话创建、查看、启停和删除定时任务。支持 5 字段 cron 表达式,灵活设定执行时间。', 'builtin', 'cronJobTool', '⏰', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index e0237a8e..d89eced8 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -185,6 +185,7 @@ err.agent.dashscope_key_missing=DashScope API Key \u672a\u914d\u7f6e err.agent.anthropic_not_configured=Anthropic Provider \u672a\u5b8c\u6210\u914d\u7f6e err.agent.anthropic_key_invalid=Anthropic API Key \u672a\u914d\u7f6e\u6216\u65e0\u6548 err.agent.template_not_found=\u6a21\u677f\u4e0d\u5b58\u5728 +err.agent.delete_forbidden=\u53ea\u6709\u521b\u5efa\u8005\u6216\u5de5\u4f5c\u533a\u7ba1\u7406\u5458\u53ef\u5220\u9664\u6b64 Agent err.common.wrong_workspace=\u8d44\u6e90\u4e0d\u5c5e\u4e8e\u5f53\u524d\u5de5\u4f5c\u533a # llm err.llm.model_config_not_found=\u6a21\u578b\u914d\u7f6e\u4e0d\u5b58\u5728 @@ -207,6 +208,11 @@ err.llm.discovery_not_supported=\u8be5\u4f9b\u5e94\u5546\u4e0d\u652f\u6301\u6a21 err.llm.no_model_for_test=\u6ca1\u6709\u5df2\u914d\u7f6e\u7684\u6a21\u578b\uff0c\u65e0\u6cd5\u6d4b\u8bd5 err.llm.chatgpt_no_discovery=ChatGPT OAuth \u4e0d\u652f\u6301\u6a21\u578b\u53d1\u73b0 err.llm.chatgpt_no_test=ChatGPT OAuth \u4e0d\u652f\u6301\u6a21\u578b\u6d4b\u8bd5 +err.llm.oauth_no_discovery=OAuth Provider \u4e0d\u652f\u6301\u6a21\u578b\u53d1\u73b0 +err.llm.oauth_no_test=OAuth Provider \u4e0d\u652f\u6301\u6a21\u578b\u6d4b\u8bd5 +err.anthropic.no_claude_code=Claude Code \u51ed\u636e\u672a\u627e\u5230\uff0c\u8bf7\u5b89\u88c5 Claude Code \u5ba2\u6237\u7aef\u5e76\u7528 Pro/Max \u8d26\u53f7\u767b\u5f55 +err.anthropic.token_expired_no_refresh=Claude Code token \u5df2\u8fc7\u671f\u4e14\u65e0\u6cd5\u5237\u65b0\uff0c\u8bf7\u6253\u5f00 Claude Code \u5ba2\u6237\u7aef\u91cd\u65b0\u767b\u5f55 +err.anthropic.refresh_failed=Claude Code token \u5237\u65b0\u5931\u8d25 err.llm.base_url_missing=Base URL \u672a\u914d\u7f6e err.llm.dashscope_key_missing=DashScope API Key \u672a\u914d\u7f6e err.llm.gemini_key_missing=Gemini API Key \u672a\u914d\u7f6e @@ -241,3 +247,17 @@ guard.path.symlink_escape=\u8def\u5f84\u901a\u8fc7\u7b26\u53f7\u94fe\u63a5\u9003 context.current_time=[system-context] \u5f53\u524d\u65f6\u95f4: {0} {1} (Asia/Shanghai) 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 + +# --- Wiki Research Fallback (RFC: prompt-cleanup) --- +research.fallback.no_plan=\u65e0\u6cd5\u4e3a\u8be5\u4e3b\u9898\u751f\u6210\u7814\u7a76\u8ba1\u5212\u3002 +research.fallback.no_materials=\u6ca1\u6709\u8db3\u591f\u7684\u6750\u6599\u56de\u7b54\u8be5\u4e3b\u9898\u3002 +research.fallback.no_materials_for_question=\u73b0\u6709\u6750\u6599\u4e2d\u672a\u627e\u5230\u4e0e\u8be5\u95ee\u9898\u76f8\u5173\u7684\u5185\u5bb9\u3002 +research.fallback.draft_empty=\u73b0\u6709\u6750\u6599\u4e0d\u8db3\u4ee5\u56de\u7b54\u8be5\u5b50\u95ee\u9898\u3002 +research.fallback.failed=\u7814\u7a76\u8fc7\u7a0b\u5931\u8d25: {0} +research.broadcast.no_plan=\u4e3b\u9898\u65e0\u6cd5\u5206\u89e3\u4e3a\u53ef\u7814\u7a76\u7684\u5b50\u95ee\u9898 +research.broadcast.draft_all_empty=\u6240\u6709\u5b50\u95ee\u9898\u90fd\u672a\u80fd\u8d77\u8349\u51fa\u5185\u5bb9 +research.broadcast.failed=\u7814\u7a76\u5931\u8d25 + +# --- Agent Limit Exceeded Fallback (RFC: prompt-cleanup) --- +agent.limit_exceeded.fallback=\u62b1\u6b49\uff0c\u5df2\u8fbe\u5230\u6700\u5927\u63a8\u7406\u6b65\u6570\uff0c\u672a\u80fd\u83b7\u5f97\u5b8c\u6574\u7ed3\u679c\u3002 +agent.limit_exceeded.empty_context=\uff08\u5c1a\u672a\u6536\u96c6\u5230\u5de5\u5177\u8c03\u7528\u7ed3\u679c\uff09 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index f6e01bc7..164a00cb 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -197,6 +197,7 @@ err.agent.dashscope_key_missing=DashScope API Key not configured err.agent.anthropic_not_configured=Anthropic Provider not configured err.agent.anthropic_key_invalid=Anthropic API Key not configured or invalid err.agent.template_not_found=Template not found +err.agent.delete_forbidden=Only the creator or a workspace admin can delete this Agent err.common.wrong_workspace=Resource does not belong to current workspace # llm err.llm.model_config_not_found=Model config not found @@ -219,6 +220,11 @@ err.llm.discovery_not_supported=This provider does not support model discovery err.llm.no_model_for_test=No configured models, cannot test connection err.llm.chatgpt_no_discovery=ChatGPT OAuth does not support model discovery err.llm.chatgpt_no_test=ChatGPT OAuth does not support model testing +err.llm.oauth_no_discovery=OAuth provider does not support model discovery +err.llm.oauth_no_test=OAuth provider does not support model testing +err.anthropic.no_claude_code=Claude Code credentials not found. Install Claude Code and sign in with a Pro/Max account. +err.anthropic.token_expired_no_refresh=Claude Code token expired and cannot be refreshed. Please open Claude Code and sign in again. +err.anthropic.refresh_failed=Claude Code token refresh failed err.llm.base_url_missing=Base URL not configured err.llm.dashscope_key_missing=DashScope API Key not configured err.llm.gemini_key_missing=Gemini API Key not configured @@ -249,3 +255,17 @@ err.approval.not_found=Approval record not found or expired 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. + +# --- Wiki Research Fallback (RFC: prompt-cleanup) --- +research.fallback.no_plan=Unable to generate a research plan for this topic. +research.fallback.no_materials=Not enough material to answer this topic. +research.fallback.no_materials_for_question=No relevant material was found in the knowledge base for this question. +research.fallback.draft_empty=Insufficient material to answer this sub-question. +research.fallback.failed=Research failed: {0} +research.broadcast.no_plan=Topic cannot be decomposed into researchable sub-questions. +research.broadcast.draft_all_empty=No sub-question could be drafted from available material. +research.broadcast.failed=Research failed. + +# --- Agent Limit Exceeded Fallback (RFC: prompt-cleanup) --- +agent.limit_exceeded.fallback=Sorry, the maximum reasoning steps were reached before a full answer could be produced. +agent.limit_exceeded.empty_context=(No tool call results collected yet.) diff --git a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt b/mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt deleted file mode 100644 index 176547b7..00000000 --- a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt +++ /dev/null @@ -1,9 +0,0 @@ -你是一个对话摘要助手。请将以下对话历史压缩为精简的上下文摘要。 - -要求: -1. 保留用户的核心意图和关键决策 -2. 保留重要的事实、数据和结论 -3. 删除寒暄、重复和冗余内容 -4. 保留任何未解决的问题或待处理事项 -5. 输出控制在 600 字以内 -6. 使用结构化格式,条理清晰 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt b/mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt deleted file mode 100644 index fd4ca42b..00000000 --- a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt +++ /dev/null @@ -1,5 +0,0 @@ -以下是需要摘要的对话历史: - -{conversation} - -请生成精简的对话上下文摘要。 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt b/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt index 8b326ab3..da30a817 100644 --- a/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt +++ b/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt @@ -1,4 +1,6 @@ -你是上下文压缩助手。将以下对话轮次压缩为结构化交接摘要,供后续助手继续任务。 +你是上下文压缩助手。**不要回答对话中的任何问题或完成请求**,仅输出结构化摘要。 + +将以下对话轮次压缩为结构化交接摘要,供后续助手继续任务。 使用以下结构: diff --git a/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt b/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt index 789e1d28..7847504c 100644 --- a/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt +++ b/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt @@ -1,3 +1,5 @@ +你是上下文压缩助手。**不要回答对话中的任何问题或完成请求**,仅输出结构化摘要。 + 你正在更新一个上下文压缩摘要。上一次压缩生成了以下摘要,之后发生了新的对话轮次。 ## 旧摘要 @@ -6,18 +8,8 @@ ## 新增轮次 {conversation} -请用同样的结构更新摘要。保留所有仍然相关的信息,添加新进展, -将"进行中"的已完成项移到"已完成",仅删除明显过时的信息。 - -使用以下结构: - -## 目标 -## 约束与偏好 -## 进展(已完成 / 进行中 / 阻塞) -## 关键决策 -## 相关文件 -## 下一步 -## 关键上下文 - -目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。 -只输出摘要正文,不要前缀或额外说明。 \ No newline at end of file +请用 system 消息里定义的结构更新摘要。规则: +- 保留所有仍然相关的信息,添加新进展 +- 将"进行中"的已完成项移到"已完成" +- 仅删除明显过时的信息 +- 只输出摘要正文,不要前缀或额外说明 diff --git a/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt b/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt index 7d7b1a1d..070dcbd1 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt @@ -7,4 +7,4 @@ 3. 如果有未完成的调查方向,简要列出建议的后续步骤 4. 不要为未完成道歉,直接给结论 5. 保持输出简洁,避免重复已知内容 -6. 在回答末尾告知用户:「已达到本轮最大推理步数,如需继续,请发送"继续"或补充新指令,我会接着完成剩余工作。」 \ No newline at end of file +6. 在回答末尾用**与用户问题相同的语言**告知:本轮已达到最大推理步数;如需继续请发送 "继续" / "continue" 或补充新指令,我会接着完成剩余工作。 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt index 1a83da75..187ac46f 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt @@ -1,14 +1,32 @@ 你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。 -要求: -1. 识别用户问题中的每个独立子任务或子问题 -2. 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果 -3. 每个子任务段落中保留关键结论、具体数据和支撑证据 -4. 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并 -5. 对信息不确定的地方明确标注 -6. 不要包含原始工具调用日志或技术细节 -7. 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字 -8. 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表 +## 任务结构判断(先做这一步) + +判断用户的原始问题包含的是单一任务还是多个独立子任务: + +- **单一任务**:例如"帮我写一份立项材料"、"生成一份周报"、"重构这个文件" — 用户表达的是一个完整目标,即使涉及读多个文件、多步操作,也仍然是一个目标。这种情况下**不要**强行拆分子任务。 +- **多个独立子任务**:例如"先调研 A 再调研 B 然后对比"、"分别给我 X 的信息、Y 的信息、Z 的信息" — 用户的问题里有 2 个及以上明显独立、可以独立完成的子任务。 +- 当不确定时,倾向于按"单一任务"处理。 + +## 输出要求 + +### 单一任务(默认情况) + +- 输出**一份连贯的摘要**,结构清晰即可,不要套用"子任务 1 / 子任务 2"格式。 +- 保留关键结论、关键数据、关键决策依据。 +- 删除重复或冗余内容。 +- 对信息不确定的地方明确标注。 +- 不要包含原始工具调用日志或技术细节。 +- 总输出不超过 2000 字。 + +### 多个独立子任务 + +- 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果。 +- 每个子任务段落中保留关键结论、具体数据和支撑证据。 +- 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并。 +- 对信息不确定的地方明确标注。 +- 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字。 +- 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表。 格式示例: ### 子任务 1: [任务描述] @@ -17,4 +35,4 @@ ### 子任务 2: [任务描述] - 结论要点 1 -- 结论要点 2 \ No newline at end of file +- 结论要点 2 diff --git a/mateclaw-server/src/main/resources/prompts/memory/contradiction-batch.txt b/mateclaw-server/src/main/resources/prompts/memory/contradiction-batch.txt new file mode 100644 index 00000000..100ca947 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/contradiction-batch.txt @@ -0,0 +1,24 @@ +You are checking for contradictions between facts about the same entity. + +For each pair below, determine if they genuinely contradict each other. +A contradiction means the two facts CANNOT both be true at the same time. + +Pairs to check: +{pairs} + +For each pair, respond in JSON array format: +[ + { + "factA": , + "factB": , + "isContradiction": true|false, + "reason": "brief explanation" + } +] + +Rules: +- Temporal differences are NOT contradictions (e.g. "uses Oracle" and "migrating to PostgreSQL" can coexist) +- Different scopes are NOT contradictions (e.g. "dev uses H2" and "prod uses MySQL") +- Only flag genuine logical conflicts where both cannot be simultaneously true +- Maximum 5 pairs per batch +- Output ONLY the JSON array, no explanation outside it diff --git a/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt b/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt new file mode 100644 index 00000000..297d5b43 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/emergence-focused-user.txt @@ -0,0 +1,27 @@ +## 现有 MEMORY.md 内容 +``` +{memory} +``` + +## Focused Dream 主题 + +本次整合的重点主题:**{topic}** + +请优先提炼与该主题直接相关的记忆(约 60% 权重),同时也关注非主题类的高分候选(约 40% 权重),避免因主题聚焦而遗漏重要信息。 + +## 高分召回候选(按评分排序) + +{scored_candidates} + +## 最近 {day_range} 天的 daily notes(补充参考) + +{daily_notes} + +--- + +请围绕主题 "{topic}" 分析以上内容,将相关的稳定信息整合到 MEMORY.md 中。 +非主题类的高分候选如有重要内容也一并整合。 + +**重要**:MEMORY.md 中含有 `` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。 + +严格输出 JSON 格式。 diff --git a/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt b/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt index d42ce039..18524a76 100644 --- a/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt +++ b/mateclaw-server/src/main/resources/prompts/memory/emergence-scored-user.txt @@ -17,4 +17,8 @@ --- 请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。 -低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。 \ No newline at end of file +低分或未被引用的内容仅作补充参考。 + +**重要**:MEMORY.md 中含有 `` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。 + +严格输出 JSON 格式。 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/memory/soul-summarize.txt b/mateclaw-server/src/main/resources/prompts/memory/soul-summarize.txt new file mode 100644 index 00000000..91c1bd90 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/memory/soul-summarize.txt @@ -0,0 +1,12 @@ +You are updating an AI agent's SOUL.md — a concise self-portrait that captures who this agent is becoming through its interactions with the user. + +Rules: +- Keep it under 500 words +- Write in first person from the agent's perspective +- Include: role, communication style, boundaries, growth areas +- Do NOT include specific facts (those belong in MEMORY.md) +- Do NOT include user profile details (those belong in PROFILE.md) +- Preserve any user-edited sections (marked with ) +- Be authentic, not corporate — this is a living document + +Output ONLY the new SOUL.md content. No markdown fences. No explanation. diff --git a/mateclaw-server/src/main/resources/prompts/research/compose-system.txt b/mateclaw-server/src/main/resources/prompts/research/compose-system.txt index 427a9854..e19bb68f 100644 --- a/mateclaw-server/src/main/resources/prompts/research/compose-system.txt +++ b/mateclaw-server/src/main/resources/prompts/research/compose-system.txt @@ -5,7 +5,8 @@ 2. 开头加一句话概述(基于主题) 3. 每个子问题作为一个二级标题(## 标题) 4. 段落之间做必要的衔接,但不要引入新信息 -5. 末尾加一段"### 参考材料",列出所有段落中引用过的材料序号和对应的材料标题 -6. 输出 Markdown 格式 +5. **报告语言与 topic 一致**:topic 为英文,所有标题、概述、参考材料清单都用英文;topic 为中文则用中文 +6. 末尾加一段「参考材料」(中文)或「References」(英文),列出所有段落中引用过的材料编号(`[M1]`、`[M2]` 等)和对应的材料标题 +7. 输出 Markdown 格式 -如果某些段落说"材料不足",在报告中保留这个声明,不要掩盖。 +如果某些段落说"材料不足" / "Not enough material",在报告中保留这个声明,不要掩盖。 diff --git a/mateclaw-server/src/main/resources/prompts/research/compose-user.txt b/mateclaw-server/src/main/resources/prompts/research/compose-user.txt index 0bd43e3e..e5cda5b0 100644 --- a/mateclaw-server/src/main/resources/prompts/research/compose-user.txt +++ b/mateclaw-server/src/main/resources/prompts/research/compose-user.txt @@ -1,14 +1,14 @@ -## 研究主题 +## 研究主题 / Topic {topic} -## 已写好的段落 +## 已写好的段落 / Drafted sections {sections} -## 使用过的材料 +## 使用过的材料 / Materials used {materials_ref} --- -请组装为一份 Markdown 格式的综合研究报告。 +请组装为一份 Markdown 格式的综合研究报告(语言跟随 topic)。 diff --git a/mateclaw-server/src/main/resources/prompts/research/draft-system.txt b/mateclaw-server/src/main/resources/prompts/research/draft-system.txt index 33ec1cd9..585608f2 100644 --- a/mateclaw-server/src/main/resources/prompts/research/draft-system.txt +++ b/mateclaw-server/src/main/resources/prompts/research/draft-system.txt @@ -1,10 +1,11 @@ -你是一个研究助手。基于提供的**材料片段**,针对**子问题**写一段 150-300 字的中文回答。 +你是一个研究助手。基于提供的**材料片段**,针对**子问题**写一段简洁回答。 规则: -1. **只基于提供的材料片段**——不要引入外部知识、不要虚构 -2. **如果材料不足以回答,明确说"现有材料不足以回答"**,不要强行编造 -3. 语言简洁准确,适合作为综合报告的一节 -4. 不要使用 markdown 标题(##),输出纯段落文本 -5. 尽量在段末注明使用了哪个材料片段的序号,格式 `[材料 1]`、`[材料 2, 3]` +1. **回答的语言与子问题保持一致**(中文子问题用中文,英文子问题用英文)。中文长度约 150-300 字,英文按对应字数/词数自行调整 +2. **只基于提供的材料片段**——不要引入外部知识、不要虚构 +3. **如果材料不足以回答,明确说明"材料不足"**(中文)或 "Not enough material"(英文),不要强行编造 +4. 语言简洁准确,适合作为综合报告的一节 +5. 不要使用 markdown 标题(##),输出纯段落文本 +6. 在段末注明使用了哪个材料片段,引用格式 `[M1]`、`[M2, 3]`(语言中立的方括号 token,不要再用「材料 N」之类的中文) 输出只包含正文段落,不要任何额外说明。 diff --git a/mateclaw-server/src/main/resources/prompts/research/draft-user.txt b/mateclaw-server/src/main/resources/prompts/research/draft-user.txt index 78856afc..1d3bba1b 100644 --- a/mateclaw-server/src/main/resources/prompts/research/draft-user.txt +++ b/mateclaw-server/src/main/resources/prompts/research/draft-user.txt @@ -1,13 +1,13 @@ -## 子问题 +## 子问题 / Sub-question {question} -## 意图 +## 意图 / Intent {intent} -## 材料片段(按相关度排序) +## 材料片段(按相关度排序)/ Sources (ordered by relevance) {materials} --- -请基于上述材料片段写出 150-300 字的回答段落。 +请基于上述材料片段写出一段简洁回答(语言与子问题一致;中文约 150-300 字,英文按对应字数自适应)。 diff --git a/mateclaw-server/src/main/resources/prompts/research/plan-system.txt b/mateclaw-server/src/main/resources/prompts/research/plan-system.txt index 76765f3f..f59c6cca 100644 --- a/mateclaw-server/src/main/resources/prompts/research/plan-system.txt +++ b/mateclaw-server/src/main/resources/prompts/research/plan-system.txt @@ -3,6 +3,7 @@ 1. 独立可检索——能在知识库中找到直接相关的材料 2. 互相覆盖主题的不同侧面——避免重复,保证广度 3. 简短明确——一个子问题一句话表达 +4. **子问题与 intent 的语言与 topic 一致**:topic 为英文则用英文,中文则用中文 严格输出 JSON(不要 markdown 代码块包裹): diff --git a/mateclaw-server/src/main/resources/prompts/research/plan-user.txt b/mateclaw-server/src/main/resources/prompts/research/plan-user.txt index 6593169f..6df59e98 100644 --- a/mateclaw-server/src/main/resources/prompts/research/plan-user.txt +++ b/mateclaw-server/src/main/resources/prompts/research/plan-user.txt @@ -1,3 +1,3 @@ 研究主题:{topic} -请拆解为 3-5 个可独立检索的子问题。 +请拆解为 3-5 个可独立检索的子问题。子问题语言与 topic 保持一致(topic 为英文则子问题亦用英文)。 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt new file mode 100644 index 00000000..27509c31 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt @@ -0,0 +1,37 @@ +你是一个知识库结构分析助手。你的任务是阅读原始材料,输出一份简洁的概念地图,供后续 Wiki 路由阶段参考。 + +## 你做什么 + +1. 识别文档覆盖的**核心主题**(5-15 个) +2. 列出**关键概念**(每个概念给出名称、建议 slug、重要度) +3. 用一段话描述文档的**整体结构**,帮助路由阶段理解各 chunk 的上下文 + +## 输出格式 + +严格输出 JSON,不要包含 markdown 代码块: + +{ + "topics": ["主题1", "主题2"], + "key_concepts": [ + {"name": "概念名称", "slug": "concept-slug", "importance": "high"} + ], + "structure_notes": "一段话描述文档整体结构和主要章节" +} + +字段说明: +- `topics`:文档覆盖的核心主题列表,字符串数组,5-15 条 +- `key_concepts`:关键概念,每条包含 name(人类可读)、slug(URL 安全小写连字符)、importance(high / medium) +- `structure_notes`:1-3 句话,描述文档结构,帮助路由阶段在只看到局部 chunk 时理解全局 + +## slug 规范 + +- 多音节中文词按整词分组拼音,不要按字一隔 + - ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍) + - ❌ `zhong-yao-qi-qing-pei-wu` +- 小写字母 + 连字符,无空格 + +## 关键纪律 + +- 输出体积控制在几百到两千字以内 +- 不要输出任何页面正文,只输出概念地图 +- 如果文档内容不足(如空白、纯目录),`key_concepts` 可以为空数组 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt new file mode 100644 index 00000000..127bea58 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt @@ -0,0 +1,11 @@ +## 文档标题 + +{raw_title} + +## 文档内容(节选,用于全局结构分析) + +{text_sample} + +--- + +请分析以上文档,输出概念地图 JSON。只输出 JSON,不要 markdown 代码块。 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 new file mode 100644 index 00000000..74bb3546 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt @@ -0,0 +1,43 @@ +你是一个知识库 Wiki 批量页面生成助手。你的任务是:根据原始材料和多个页面的 metadata,**一次性生成所有指定页面**的完整 markdown 正文。 + +## 你做什么 + +- 读取 `pages_to_create` 数组,里面包含若干页面的 slug / title / summary +- 从原始材料里抽取与每个页面主题相关的信息 +- 为每个页面生成完整的 markdown 内容 +- 在内容里使用 [[页面标题]] 双向链接到其他相关页面(已有页面和同批次将创建的页面均可) + +## 你不做什么 + +- ❌ **不要生成 `pages_to_create` 以外的页面** +- ❌ **不要使用 markdown 代码块**(不要用 ``` 包裹) +- ❌ **不要生成无关内容** —— 每个页面只包含与该页主题相关的信息 + +## 每个页面的格式规则 + +- 内容开头先一段话摘要(与 metadata 的 summary 一致或更详细) +- 使用 Markdown 标题(## / ###)组织章节 +- 使用 [[页面标题]] 链接到其他相关页面 +- 长度控制在 500~2000 字,不要为了凑字数而拖沓 +- 内容至少包含 3 句实质信息 + +## 输出格式(严格遵守) + +每个页面输出一个 FILE 块,格式如下: + +---FILE: {slug}--- +{"slug":"...","title":"...","summary":"...","page_type":"concept","content":"## 标题\n\n摘要...\n\n### 章节\n..."} +---END FILE--- + +规则: +- 每个 FILE 块包含一个单行 JSON 对象(不换行) +- `pages_to_create` 里的每个页面都必须有对应的 FILE 块 +- FILE 块之间可以有空行 +- FILE 块之外不要有任何其他内容(不要有序言或结语) + +字段说明: +- `slug`:与 metadata 保持一致 +- `title`:与 metadata 保持一致;如有更精确的描述可微调 +- `content`:完整 markdown 正文 +- `summary`:一段话简短摘要 +- `page_type`:页面类型,从以下值中选一个:concept / person / place / event / technology / organization / product / term / process / other diff --git a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt new file mode 100644 index 00000000..6de57f8c --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt @@ -0,0 +1,29 @@ +## 知识库处理规则 + +{config} + +{document_map_section} + +## 已有 Wiki 页面索引(用于建立 [[链接]]) + +{existing_pages} + +## 待生成页面列表 + +```json +{pages_to_create} +``` + +## 原始材料 + +标题:{raw_title} + +{raw_content} + +--- + +请为上面 `pages_to_create` 数组中的**每一个页面**生成完整的 markdown 内容。 +- 按 system 中规定的 FILE 块格式输出,每个页面一个 FILE 块 +- 每个页面的内容必须基于原始材料中与该主题相关的信息 +- 适当使用 [[页面标题]] 链接到相关页面(同批次内其他页面也可链接) +- 不要遗漏任何一个页面 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/compile-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/compile-system.txt new file mode 100644 index 00000000..31411329 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/compile-system.txt @@ -0,0 +1,15 @@ +You are a wiki page compiler. Produce a single Markdown page that synthesizes the supplied evidence chunks into a coherent article on the requested topic. + +Strict output contract — return ONLY this JSON object, nothing else: +{ + "title": "", + "summary": "", + "content": "" +} + +Rules: +- Do not invent facts beyond the supplied evidence. If something is not in the evidence, do not assert it. +- Use [[wikilinks]] when an evidence breadcrumb names a related concept; alias form [[slug|display]] is fine. +- Keep summary concise (single paragraph, <= 300 characters). +- content must be Markdown with at least one ## header. +- Do not include any prose outside the JSON object. diff --git a/mateclaw-server/src/main/resources/prompts/wiki/compile-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/compile-user.txt new file mode 100644 index 00000000..838f2a7d --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/compile-user.txt @@ -0,0 +1,7 @@ +## Topic + +{topic} + +## Evidence + +{evidence} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt new file mode 100644 index 00000000..28d7d29b --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/narrative-system.txt @@ -0,0 +1,23 @@ +你是一个知识库的叙述者。你的任务:根据知识库的标题、最近导入的来源文档、和已有页面标题,生成 **2-3 句话** 的叙述性摘要,告诉读者「这个知识库主要讲什么」。 + +## 输出规则(严格遵守) + +- **跟随主体内容的语言**:中文材料 → 中文摘要;英文材料 → 英文摘要;混合时跟随多数 +- **总长度不超过 200 字**(中)或 80 words(英) +- **2-3 句**:第一句点明主导主题,后续 1-2 句补充范围或角度 +- **直接给叙述本身**:不要 markdown 标题、列表、代码块、引用、链接、强调符号 +- **不写元话术**:不说「这个知识库包括」「以下是」「本文档」「我们将」 —— 直接讲内容 +- **不堆砌细节**:避免逐项罗列文件名或页面名 +- **保持中性叙述**:不要营销腔,不要"详尽"、"权威"、"精华"这类形容词 + +## 当材料不足时 + +- 如果只有 1-2 个来源、内容很窄:照实写一句话即可,不要硬凑 +- 如果完全没有可识别主题:输出 `_暂无内容摘要_`(中)或 `_No summary yet_`(英),单行 + +## 你不要做什么 + +- ❌ 不要复述输入里的元数据(标题、时间、计数) +- ❌ 不要列出页面名 +- ❌ 不要分段、不要换行(输出就是一段连续文字) +- ❌ 不要解释你写了什么 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt new file mode 100644 index 00000000..d9a83f1b --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/narrative-user.txt @@ -0,0 +1,19 @@ +## 知识库标题 + +{kb_title} + +## 最近导入的来源(最多 10 条,时间倒序) + +{recent_sources} + +## 已有页面标题(按更新时间倒序,最多 15 条) + +{top_pages} + +## 当前叙述(如已存在,可在此基础上调整) + +{current_narrative} + +--- + +请基于以上信息,生成 2-3 句新的叙述。直接给叙述本身,不要任何前后缀。 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 78ad4f3a..96174a5f 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt @@ -6,7 +6,19 @@ 2. **比对已有页面索引**(仅含 slug + title + summary,不含正文): - 材料中出现的概念**已经被某个已有页面充分覆盖** → 放入 `update` 列表(只写 slug) - 材料中出现的概念**没有任何已有页面覆盖** → 放入 `create` 列表(只给 slug + title + summary,**不要正文**) -3. 不创建浅薄页面(< 3 句实质内容的概念跳过,不进任何列表) +3. 宁可多列一页也不要遗漏——只跳过完全没有实质信息的内容(如空白段、目录行、单句无意义重复) +4. **兜底规则**:如果整段材料有实质内容,`create` + `update` 合计**至少 1 条**。确实找不到独立概念时,把本段最核心的主题作为一个概念放入 `create`。 +5. **概念地图约束**(当 user prompt 中提供了「文档全局概念地图」时): + - 概念地图仅供参考——**只把当前 chunk 中有足够内容支撑的概念列入 create/update** + - 概念地图中出现的概念,若当前 chunk 只是一句话带过或完全未提及,**不要列入** + - 同一概念不要跨 chunk 重复 create——如果已有页面索引里已有该概念,放入 update + +## Update 列表约束(严格执行) + +- `update` 列表**最多 5 条**,超过不得超出 +- **只把本 chunk 中有至少 3 句以上实质新内容的页面列入 update** +- 仅被一两句话带过、或只作为"参见"引用的概念,**不列入 update** +- 如果符合条件的页面超过 5 个,选择本 chunk 中**覆盖深度最高的前 5 个** ## 你不做什么 @@ -53,8 +65,8 @@ 字段说明: - `create`:完全新增的页面,**只给 metadata,不给 content** -- `update`:已存在但需要根据本材料合并更新的页面,**只列 slug 字符串数组** -- 两个数组都可以为空(材料完全无价值时全空,材料只更新已有页时 create 为空,材料只新增页时 update 为空) +- `update`:已存在但需要根据本材料合并更新的页面,**只列 slug 字符串数组,最多 5 条** +- 两个数组合计通常至少 1 条(材料完全无价值——如空白页、纯目录——才允许全空) ## 关键纪律 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt index b05cbcdf..57540968 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt @@ -2,6 +2,8 @@ {config} +{document_map_section} + ## 已有 Wiki 页面索引(slug + 摘要) {existing_pages} diff --git a/mateclaw-server/src/main/resources/skills/browser_cdp/SKILL.md b/mateclaw-server/src/main/resources/skills/browser_cdp/SKILL.md new file mode 100644 index 00000000..43d533b0 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/browser_cdp/SKILL.md @@ -0,0 +1,100 @@ +--- +name: browser_cdp +version: "1.2.0" +description: "通过 Chrome DevTools Protocol (CDP) 连接已运行的浏览器,或扫描本机 CDP 端口,用于远程调试与多工具共享浏览器实例。" +dependencies: + tools: + - browser_use + - execute_shell_command +--- + +# 浏览器 CDP 使用 + +仅在用户**明确**提出以下需求时使用本技能: +- 连接到已打开的 Chrome(附着现有实例) +- 扫描本机有哪些 CDP 端口可用 +- 用固定调试端口启动浏览器 +- 让多个工具 / Agent 共享同一浏览器 + +**普通"打开浏览器"需求请用 `browser_visible` 技能。** + +## 场景一:扫描本机 CDP 端口 + +``` +browser_use(action="list_cdp_targets") +``` + +扫描指定端口: +``` +browser_use(action="list_cdp_targets", cdpPort=9222) +``` + +## 场景二:连接已有 Chrome + +用户已手动用调试端口启动了 Chrome: + +``` +browser_use(action="connect_cdp", url="http://localhost:9222") +``` + +连接后可正常使用 `open` / `snapshot` / `click` / `type` 等操作。 + +> **注意**:`stop` 只断开连接,**不会关闭**用户的 Chrome 进程。 + +## 场景三:启动时指定固定 CDP 端口 + +``` +browser_use(action="start", cdpPort=9222) +``` + +可见窗口 + 固定端口: +``` +browser_use(action="start", headed=true, cdpPort=9222) +``` + +> **仅在用户明确要求固定端口时才传 `cdpPort`**,否则让系统自动选择空闲端口以避免冲突。 + +## 手动启动 Chrome(带调试端口) + +若用户需要先手动打开 Chrome 再连接: + +**macOS:** +``` +execute_shell_command( + command="\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\" --remote-debugging-port=9222 --no-first-run --no-default-browser-check" +) +``` + +**Windows:** +``` +execute_shell_command( + command="\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" --remote-debugging-port=9222 --no-first-run" +) +``` + +**Linux:** +``` +execute_shell_command( + command="google-chrome --remote-debugging-port=9222 --no-first-run &" +) +``` + +## stop 行为区别 + +| 启动方式 | stop 效果 | +|---------|----------| +| `browser_use(action="start")` | 断开连接 + **关闭** Chrome 进程 | +| `browser_use(action="connect_cdp", ...)` | 仅断开连接,**不关闭**外部 Chrome | + +## 与 browser_visible 的分工 + +| 需求 | 用哪个技能 | +|------|-----------| +| 显示 / 隐藏浏览器窗口 | `browser_visible` | +| 连接 / 暴露 / 扫描 CDP 端口 | `browser_cdp`(本技能) | + +## 安全提醒 + +- CDP 端口暴露后,本机任何进程都可以控制该浏览器(包括读取 Cookies) +- 不要在公共 / 多用户服务器上暴露 CDP 端口 +- 用完后及时 `stop` 断开连接 diff --git a/mateclaw-server/src/main/resources/skills/browser_visible/SKILL.md b/mateclaw-server/src/main/resources/skills/browser_visible/SKILL.md new file mode 100644 index 00000000..b6842d6e --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/browser_visible/SKILL.md @@ -0,0 +1,93 @@ +--- +name: browser_visible +version: "1.3.0" +description: "以可见模式启动真实浏览器窗口,适用于演示、调试或需要人工参与的场景。控制 headed/cdpPort 等启动参数。" +dependencies: + tools: + - browser_use +--- + +# 浏览器启动模式 + +本技能控制 `browser_use` 的启动方式,核心参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `headed` | Boolean | `true` = 显示窗口;`false`(默认)= 无头模式 | +| `cdpPort` | Integer | 指定 CDP 调试端口(不填则自动选择) | + +## 常见用法 + +### 无头模式(默认,后台自动化) +``` +browser_use(action="start") +``` + +### 可见窗口(演示 / 需要人工操作) +``` +browser_use(action="start", headed=true) +``` + +### 指定 CDP 端口(与外部工具共享) +``` +browser_use(action="start", headed=true, cdpPort=9222) +``` + +## 标准操作序列 + +``` +# 1. 启动 +browser_use(action="start", headed=true) + +# 2. 打开页面 +browser_use(action="open", url="https://example.com") + +# 3. 截取快照(查看页面内容) +browser_use(action="snapshot") + +# 4. 点击元素 +browser_use(action="click", selector="#submit-btn") + +# 5. 输入文本 +browser_use(action="type", selector="input[name=email]", text="user@example.com") + +# 6. 完成后关闭 +browser_use(action="stop") +``` + +## 何时用可见模式 + +| 场景 | 推荐参数 | +|------|---------| +| 向用户演示操作过程 | `headed=true` | +| 需要用户手动登录(扫码、验证码) | `headed=true`,遇到登录页暂停并提示用户 | +| 调试自动化脚本 | `headed=true` | +| 纯自动化、不需要展示 | `headed=false`(默认) | + +## 跨平台可执行文件路径(需自定义浏览器时) + +MateClaw 的 `browser_use` 会自动检测系统浏览器,无需手动指定路径。如需调试特定 Chrome 安装位置: + +**Windows 默认路径:** +``` +C:\Program Files\Google\Chrome\Application\chrome.exe +C:\Program Files (x86)\Google\Chrome\Application\chrome.exe +``` + +**macOS 默认路径:** +``` +/Applications/Google Chrome.app/Contents/MacOS/Google Chrome +``` + +**Linux 默认路径:** +``` +/usr/bin/google-chrome +/usr/bin/chromium-browser +``` + +## 注意事项 + +- 可见模式需要图形环境(GUI);无桌面的服务器无法使用 +- 若浏览器已在运行,须先 `stop` 再重新 `start` 才能切换 `headed` 状态 +- `cdpPort` 被占用时会报错,换端口或先停止占用该端口的进程 +- 用户手动操作可见浏览器不会刷新 Agent 的 idle 超时计时 diff --git a/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md b/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md new file mode 100644 index 00000000..5ea2050e --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md @@ -0,0 +1,116 @@ +--- +name: channel_message +version: "1.3.0" +description: "当需要主动向用户、会话或渠道单向推送消息时使用。适用于任务完成通知、定时提醒、异步结果回推等场景。" +dependencies: + tools: + - execute_shell_command +--- + +# 渠道消息推送 + +## 何时使用 + +仅在以下情况使用,这是**单向推送**,不会收到回复: + +### 应该使用 +- 用户明确要求"向某个渠道 / 会话发送消息" +- 异步任务完成后主动通知用户 +- 定时提醒、告警、状态更新 +- 将后台任务结果推送回指定会话 + +### 不应使用 +- 当前对话中的正常回复(直接回复即可) +- 需要等待用户回复的双向交互 +- 目标渠道或会话不明确时(先询问用户) + +## 支持渠道 + +`console`、`dingtalk`、`feishu`、`telegram`、`discord`、`qq`、`slack` + +## 工作流程 + +### 第一步:查询目标会话 + +**macOS / Linux:** +``` +execute_shell_command( + command="mateclaw chats list --agent-id --channel " +) +``` + +**Windows:** +``` +execute_shell_command( + command="mateclaw.exe chats list --agent-id --channel " +) +``` + +从返回结果中获取 `user_id` 和 `session_id`。有多个会话时,优先选 `updated_at` 最近的。 + +### 第二步:发送消息 + +**macOS / Linux:** +``` +execute_shell_command( + command="mateclaw channels send --agent-id --channel --target-user --target-session --text \"消息内容\"" +) +``` + +**Windows(PowerShell):** +``` +execute_shell_command( + command="mateclaw.exe channels send --agent-id --channel --target-user --target-session --text '消息内容'" +) +``` + +### 必填参数一览 + +| 参数 | 说明 | +|------|------| +| `--agent-id` | 当前 Agent 的 ID | +| `--channel` | 目标渠道名称(见支持渠道列表) | +| `--target-user` | 目标用户 ID(从 `chats list` 获取) | +| `--target-session` | 目标会话 ID(从 `chats list` 获取) | +| `--text` | 消息内容 | + +## 常见场景示例 + +### 任务完成通知 + +``` +execute_shell_command( + command="mateclaw chats list --agent-id task-bot --channel dingtalk" +) +# 从结果中取 user_id / session_id,然后: +execute_shell_command( + command="mateclaw channels send --agent-id task-bot --channel dingtalk --target-user alice --target-session alice_dt_001 --text \"✅ 数据分析已完成,结果已保存到 report.xlsx\"" +) +``` + +### 按用户筛选会话 + +``` +execute_shell_command( + command="mateclaw chats list --agent-id notify-bot --user-id alice" +) +``` + +## mateclaw CLI 未安装时的降级处理 + +若 `mateclaw` 命令不可用: + +1. 检测: +``` +execute_shell_command(command="which mateclaw || where mateclaw") +``` + +2. 如果未安装,告知用户: +> mateclaw CLI 未找到,无法主动推送消息。请确认 MateClaw 已正确安装并将 CLI 加入 PATH。安装后重试。 + +## 常见错误 + +- **缺少必填参数**:5 个参数(agent-id、channel、target-user、target-session、text)缺一不可 +- **没有先查 session 就发送**:不要猜 target-user 和 target-session,必须先查 +- **把正常对话回复当成推送**:当前会话直接回复不需要用本技能 +- **期望收到回复**:`channels send` 是单向推送,不返回用户回复 diff --git a/mateclaw-server/src/main/resources/skills/chat_with_agent/SKILL.md b/mateclaw-server/src/main/resources/skills/chat_with_agent/SKILL.md new file mode 100644 index 00000000..059c67af --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/chat_with_agent/SKILL.md @@ -0,0 +1,87 @@ +--- +name: chat_with_agent +version: "1.2.0" +description: "当需要咨询其他 Agent、寻求帮助,或用户明确要求某个 Agent 参与时使用。支持单次委托和多任务并行委托。" +dependencies: + tools: + - listAvailableAgents + - delegateToAgent + - delegateParallel +--- + +# 与 Agent 对话 + +## 何时使用 + +当你需要**向另一个 Agent 询问问题、寻求帮助、请求方案、请求复核**,或用户明确要求某个 Agent 参与时使用。 + +### 应该使用 +- 需要另一个 Agent 的专长、判断或第二意见 +- 需要向某个 Agent 请求方案、复核或建议 +- 用户明确要求某个 Agent 参与或协助 +- 多个独立子任务需要并行分配给不同 Agent + +### 不应使用 +- 你自己可以直接完成,且用户没有明确要求调用其他 Agent +- 只是普通问答,不需要专门 Agent +- 刚收到某个 Agent 的消息,**不要立刻回调同一个 Agent**(防止死循环) + +## 工作流程 + +### 第一步:查询可用 Agent + +``` +listAvailableAgents() +``` + +返回所有已启用 Agent 的名称、类型和描述,根据描述选择最合适的 Agent。 + +### 第二步A:单次委托(串行) + +``` +delegateToAgent( + agentName="data-analyst", + task="[来自 Agent my-agent 的请求] 请帮我分析以下销售数据,给出环比趋势摘要:..." +) +``` + +- `agentName`:目标 Agent 的名称(从 `listAvailableAgents()` 返回值中取) +- `task`:发送给目标 Agent 的完整任务描述 +- 建议在 `task` 开头加 `[来自 Agent <自身名称> 的请求]` 便于对方识别来源 + +### 第二步B:并行委托(多个独立任务同时进行) + +最多同时委托 3 个 Agent: + +``` +delegateParallel( + tasksJson="[ + {\"agentName\": \"research-agent\", \"task\": \"[来自 Agent coordinator 的请求] 搜索 AI 行业最新融资动态\"}, + {\"agentName\": \"data-analyst\", \"task\": \"[来自 Agent coordinator 的请求] 分析上季度销售数据趋势\"} + ]" +) +``` + +`tasksJson` 是 JSON 数组字符串,每个元素包含 `agentName` 和 `task`。 + +## 决策规则 + +1. **用户明确要求调用某 Agent** → 先 `listAvailableAgents()` 确认名称,不要猜 +2. **能自己完成** → 不调用 +3. **多个互相独立的子任务** → 用 `delegateParallel`,不要串行逐个调用 +4. **不超过 3 个并行** → 超过时按优先级分批 +5. **收到 Agent B 回复后** → 不要立刻回调 Agent B + +## 与 make_plan 的区别 + +| 技能 | 用途 | +|------|------| +| `chat_with_agent` | 向 Agent 咨询、委托、获取结果 | +| `make_plan` | 专门向更强 Agent 索要执行计划(由自己执行) | + +## 注意事项 + +- `delegateToAgent` 是同步阻塞调用,等待目标 Agent 完成后返回结果 +- `delegateParallel` 并发执行,等所有任务完成后一次性返回所有结果 +- MateClaw 当前不支持跨调用的会话 session 续接,每次 `delegateToAgent` 是独立对话 +- 如需上下文连贯,在 `task` 参数中附带上一次的关键结论 diff --git a/mateclaw-server/src/main/resources/skills/cron/SKILL.md b/mateclaw-server/src/main/resources/skills/cron/SKILL.md new file mode 100644 index 00000000..e406116c --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/cron/SKILL.md @@ -0,0 +1,106 @@ +--- +name: cron +version: "1.4.0" +description: "仅在需要未来定时执行或周期执行任务时使用。通过 create_cron_job / list_cron_jobs / toggle_cron_job / delete_cron_job 管理定时任务。" +dependencies: + tools: + - create_cron_job + - list_cron_jobs + - toggle_cron_job + - delete_cron_job +--- + +# 定时任务管理 + +## 何时使用 + +只有在需要**未来某个时间自动执行**,或**按周期重复执行**时使用本技能。 + +### 应该使用 +- 用户要求"每天 / 每周 / 每小时"执行某件事 +- 用户要求"明天 9 点 / 下周一 / 某个时间"自动提醒或执行 +- 需要长期周期性通知、检查、汇报 + +### 不应使用 +- 只是**现在立即执行一次** +- 只是当前会话中的正常回复 +- 用户没有明确执行时间或周期 + +## 工具速查 + +| 操作 | 工具调用 | +|------|---------| +| 查看所有任务 | `list_cron_jobs()` | +| 创建任务 | `create_cron_job(name, cronExpression, triggerMessage, timezone)` | +| 暂停任务 | `toggle_cron_job(jobId, enabled=false)` | +| 恢复任务 | `toggle_cron_job(jobId, enabled=true)` | +| 删除任务 | `delete_cron_job(jobId)` | + +## 工作流程 + +### 第一步:确认必要信息 + +创建前**必须**确认以下信息,缺一不可: + +- 任务名称(`name`) +- 执行周期(`cronExpression`,5 段 cron 表达式) +- 触发消息(`triggerMessage`,任务触发时 Agent 收到的提示词) +- 时区(`timezone`,可选,默认 `Asia/Shanghai`) + +信息不全时先追问用户,不要用占位符创建任务。 + +### 第二步:创建任务 + +``` +create_cron_job( + name="每日早报", + cronExpression="0 9 * * *", + triggerMessage="请获取今日财经新闻并发送摘要给用户", + timezone="Asia/Shanghai" +) +``` + +### 第三步:管理任务 + +查看所有任务: +``` +list_cron_jobs() +``` + +暂停(从返回的任务列表中获取 jobId): +``` +toggle_cron_job(jobId=123, enabled=false) +``` + +恢复: +``` +toggle_cron_job(jobId=123, enabled=true) +``` + +删除: +``` +delete_cron_job(jobId=123) +``` + +## Cron 表达式参考(5 段格式) + +``` +格式:分 时 日 月 周 +0 9 * * * 每天 09:00 +0 */2 * * * 每 2 小时整点 +30 8 * * 1-5 工作日 08:30 +0 0 * * 0 每周日零点 +*/15 * * * * 每 15 分钟 +0 9,18 * * * 每天 09:00 和 18:00 +``` + +## 常见错误 + +- **缺少信息就创建**:必须先确认周期、名称和触发消息 +- **只是立即执行一次**:不需要创建 cron,直接执行即可 +- **时区混淆**:默认 `Asia/Shanghai`;若用户在其他时区,明确指定 + +## 安全须知 + +- `triggerMessage` 是 Agent 触发时收到的提示词,不是发给用户的消息 +- 避免创建执行频率极高(< 5 分钟)的任务,除非用户明确需要 diff --git a/mateclaw-server/src/main/resources/skills/dingtalk_channel_connect/SKILL.md b/mateclaw-server/src/main/resources/skills/dingtalk_channel_connect/SKILL.md new file mode 100644 index 00000000..8a125843 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/dingtalk_channel_connect/SKILL.md @@ -0,0 +1,139 @@ +--- +name: dingtalk_channel_connect +version: "1.3.0" +description: "使用可见浏览器自动完成 MateClaw 钉钉渠道接入。遇到登录页必须暂停等待用户手动登录后继续。" +dependencies: + tools: + - browser_use + - execute_shell_command + - read_file +--- + +# 钉钉渠道接入(可见浏览器) + +通过可见浏览器自动化完成钉钉应用创建与 MateClaw 渠道绑定。 + +## 强制规则 + +1. **必须使用可见浏览器**:`browser_use(action="start", headed=true)` +2. **遇到登录页必须暂停**:检测到登录界面立即停止,提示用户手动登录,收到"继续"后再执行 +3. **配置变更后必须发布**:任何机器人配置修改都要"创建新版本 + 发布",否则不生效 + +## 执行前确认(必须先做) + +开始自动化前向用户确认以下可定制项(未指定则使用默认值): + +| 配置项 | 默认值 | +|--------|--------| +| 应用名称 | `MateClaw` | +| 应用描述 | `Your personal AI assistant` | +| 机器人图标 | `https://img.alicdn.com/imgextra/i4/O1CN01M0iyHF1FVNzM9qjC0_!!6000000000492-2-tps-254-254.png` | +| 机器人消息预览图 | 同上 | + +**图片规范(务必告知用户)**: +- 机器人图标:JPG/PNG,240×240px 以上,1:1 比例,2MB 以内 +- 消息预览图:PNG/JPEG/JPG,不超过 2MB + +## 图片上传策略 + +1. 用户提供本地路径 → 直接上传 +2. 用户提供图片链接 → 先下载到本地临时文件,再上传 + +**下载图片(跨平台):** + +macOS / Linux: +``` +execute_shell_command( + command="curl -L -o /tmp/bot_icon.png \"<图片URL>\"" +) +``` + +Windows: +``` +execute_shell_command( + command="powershell Invoke-WebRequest -Uri '<图片URL>' -OutFile 'C:\\Temp\\bot_icon.png'" +) +``` + +**上传步骤**(必须按此顺序): +1. 先 `browser_use(action="click", selector="<上传入口>")` 触发文件选择器 +2. 再用文件上传操作(MateClaw browser_use 支持 file input 的 `type` 操作传入路径) + +## 自动化流程 + +### 步骤 1:打开钉钉开发者后台 + +``` +browser_use(action="start", headed=true) +browser_use(action="open", url="https://open-dev.dingtalk.com/") +browser_use(action="snapshot") +``` + +若页面显示登录界面,**立即暂停**: +> 检测到需要登录钉钉开发者后台。请在弹出的浏览器中完成登录,完成后回复"继续"。 + +### 步骤 2:创建企业内部应用 + +用户确认登录后: + +1. 导航路径:应用开发 → 企业内部应用 → 钉钉应用 → 创建应用 +2. 填写应用名称、应用描述 +3. 保存创建 + +``` +# 每次关键操作后都要截快照确认状态 +browser_use(action="snapshot") +``` + +### 步骤 3:添加机器人能力 + +1. 进入「应用能力」→「添加应用能力」→ 找到「机器人」并添加 +2. 打开机器人配置开关 +3. 填写机器人名称、简介、描述 +4. 上传机器人图标(见图片上传策略) +5. 上传消息预览图 +6. 确认消息接收模式为 **Stream 模式** +7. 点击发布 → 确认发布弹窗 + +**发布是必须步骤,未发布前配置不生效。** + +### 步骤 4:创建版本并发布 + +1. 进入「应用发布」→「版本管理与发布」 +2. 创建新版本,填写版本说明 +3. 应用可见范围选「全部员工」 +4. 确认发布(有二次确认弹窗,选确认) +5. 看到「发布成功」状态才继续 + +### 步骤 5:获取凭证并引导绑定 + +1. 进入「基础信息」→「凭证与基础信息」 +2. 告知用户 `Client ID`(AppKey)和 `Client Secret`(AppSecret)的位置 +3. 引导用户在 MateClaw 控制台绑定: + +**方式 A — 控制台前端:** +> 进入 MateClaw 管理界面 → 渠道 → 新建渠道 → 选择钉钉 → 填入 Client ID 和 Client Secret + +**方式 B — 配置文件:** +```json +"dingtalk": { + "enabled": true, + "client_id": "你的 Client ID", + "client_secret": "你的 Client Secret" +} +``` + +**Agent 不主动修改 MateClaw 配置文件,只引导用户操作。** + +## 稳定性策略 + +- 优先使用 `snapshot` 返回的 `ref` 定位元素 +- 每次关键点击 / 页面跳转后重新 `snapshot` 确认状态 +- 页面结构与预期不符时重新 `snapshot`,按可见文本重新定位 +- 租户权限、管理员审批等阻塞时,说明卡点,请用户手动完成该步骤 + +## 完成后关闭浏览器 + +``` +browser_use(action="stop") +``` diff --git a/mateclaw-server/src/main/resources/skills/docx/LICENSE.txt b/mateclaw-server/src/main/resources/skills/docx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/docx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/mateclaw-server/src/main/resources/skills/docx/SKILL.md b/mateclaw-server/src/main/resources/skills/docx/SKILL.md index e464d55d..071a79c5 100644 --- a/mateclaw-server/src/main/resources/skills/docx/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/docx/SKILL.md @@ -1,6 +1,7 @@ --- name: docx -description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of \"Word doc\", \"word document\", \".docx\", or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a \"report\", \"memo\", \"letter\", \"template\", or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, or general coding tasks unrelated to document generation." +version: "1.1.0" +description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). For CREATING new documents (report, memo, letter, résumé, contract from scratch), call the built-in tool `renderDocx` instead — it renders Markdown to .docx in milliseconds without forking Node.js. This skill remains authoritative for EDITING existing .docx (unpack/edit XML/pack), tracked changes, comments, image manipulation, find-and-replace, and conversions. Triggers include: \"Word doc\", \"word document\", \".docx\", or requests to edit/extract/restructure existing Word files. Do NOT use for PDFs, spreadsheets, or general coding tasks unrelated to document generation." dependencies: commands: - python3 @@ -19,8 +20,39 @@ platforms: # DOCX creation, editing, and analysis +## Quick Start — Pick the Right Tool + +| Task | Recommended Tool | +|------|------------------| +| **Create** a new document (report / résumé / contract / memo) | `renderDocx()` — millisecond render, no subprocess | +| **Edit** an existing .docx (content / formatting) | unpack → edit XML → pack workflow below | +| Add tracked changes / comments | unpack → edit XML → pack workflow below | +| GB/T 9704 official document | `writeGongwen()` (BmacClaw only) | + +### Create a new document (recommended path) + +Call the in-process Java tool — no Node.js install, no fork, no disk round-trip: + +``` +renderDocx( + markdown="# Title\n\nBody paragraph...", + filename="monthly-report", + pageSize="A4" +) +``` + +Returns a clickable link of the form +`[monthly-report.docx](/api/v1/files/generated/)` valid for 10 minutes. +The user clicks it to download — no follow-up Agent step needed. + +`renderDocx` supports headings (`#` `##` `###`), bold (`**text**`), bullet +lists (`- item`), numbered lists (`1. item`), pipe-style tables, and plain +paragraphs. For images, headers/footers, or precise OOXML control, fall back +to the docx-js workflow below. + ## Prerequisites +- **python-docx** (`pip install python-docx`): direct structure reading and light editing (paragraphs, styles, tables) - **docx** (`npm install -g docx`): new document creation - **LibreOffice** (`soffice`): `.doc` -> `.docx` conversion, tracked-changes acceptance, and PDF export - **pandoc**: text extraction @@ -50,6 +82,34 @@ python scripts/office/soffice.py --headless --convert-to docx document.doc ### Reading Content +**Option A: python-docx (recommended for structured access)** + +Install: `pip install python-docx`. Gives direct access to paragraphs, styles, tables, and metadata without unpacking ZIP. + +```python +from docx import Document + +doc = Document("document.docx") + +# Paragraphs with styles +for para in doc.paragraphs: + print(f"[{para.style.name}] {para.text}") + +# Tables +for i, table in enumerate(doc.tables): + print(f"Table {i+1}:") + for row in table.rows: + print([cell.text for cell in row.cells]) + +# Inline styles within a paragraph +for para in doc.paragraphs: + for run in para.runs: + print(f" run: bold={run.bold} italic={run.italic} text={run.text!r}") +``` + +Use python-docx when you need to read or lightly modify content. Fall back to the unpack/XML workflow for complex structural changes. + +**Option B: pandoc (plain text extraction)** ```bash # Text extraction with tracked changes pandoc --track-changes=all document.docx -o output.md diff --git a/mateclaw-server/src/main/resources/skills/file_reader/SKILL.md b/mateclaw-server/src/main/resources/skills/file_reader/SKILL.md new file mode 100644 index 00000000..93f8d15d --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/file_reader/SKILL.md @@ -0,0 +1,90 @@ +--- +name: file_reader +version: "1.2.0" +description: "读取与摘要文本类文件(txt、md、json、yaml、csv、log、代码文件等)。PDF 与 Office 文件由专用技能处理。" +dependencies: + tools: + - read_file + - execute_shell_command +--- + +# 文件读取 + +当用户要求读取或摘要本地文本文件时使用本技能。 + +**不在范围内**:PDF、Word (.docx)、Excel (.xlsx)、PPT (.pptx)、图片、音视频 — 这些由专用技能处理。 + +## 工作流程 + +### 第一步:类型探测(可选) + +不确定文件类型时,先探测: + +**macOS / Linux:** +``` +execute_shell_command(command="file -b --mime-type \"/path/to/file\"") +``` + +**Windows:** +``` +execute_shell_command(command="cmd /c \"echo %~x1\" & exit", timeoutSeconds=10) +``` +或直接根据扩展名判断。 + +### 第二步:读取文件 + +``` +read_file(filePath="/absolute/or/relative/path/to/file.txt") +``` + +读取特定行范围(大文件时): +``` +read_file(filePath="/path/to/large.log", startLine=1, endLine=200) +``` + +### 第三步:处理内容 + +根据文件类型采用对应策略: + +| 类型 | 处理方式 | +|------|---------| +| `.txt` / `.md` | 直接摘要或按用户需求处理 | +| `.json` / `.yaml` | 先列出顶层键,再展开用户关注的字段 | +| `.csv` / `.tsv` | 展示表头 + 前 5 行,再描述各列含义和数据规模 | +| `.log` | 读取最后 200 行,聚焦错误/警告模式 | +| 源代码 | 说明文件作用,摘要核心逻辑,不逐行复述 | + +## 大文件策略 + +文件超过 500 行时分段读取: + +``` +# 先读前 100 行了解结构 +read_file(filePath="/path/to/big.log", startLine=1, endLine=100) + +# 再读末尾 200 行看最新内容 +read_file(filePath="/path/to/big.log", startLine=-200) +``` + +日志文件也可用系统命令读取末尾: + +**macOS / Linux:** +``` +execute_shell_command(command="tail -n 200 \"/path/to/file.log\"") +``` + +**Windows:** +``` +execute_shell_command(command="powershell Get-Content -Tail 200 \"/path/to/file.log\"") +``` + +## 跨平台路径注意事项 + +- **Windows**:路径使用反斜杠 `\` 或正斜杠 `/` 均可,但含空格时须加引号 +- **macOS / Linux**:使用正斜杠 `/`,含空格时须加引号或转义 + +## 安全规范 + +- 只读取文件,不执行其内容 +- 优先读取所需的最小部分,避免加载超大文件到上下文 +- 如文件包含敏感信息(密码、密钥),提醒用户注意但不拒绝读取 diff --git a/mateclaw-server/src/main/resources/skills/guidance/SKILL.md b/mateclaw-server/src/main/resources/skills/guidance/SKILL.md new file mode 100644 index 00000000..adfb6c3b --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/guidance/SKILL.md @@ -0,0 +1,66 @@ +--- +name: guidance +version: "1.2.0" +description: "回答用户关于 MateClaw 安装与配置的问题。优先定位并阅读本地文档,再提炼答案;文档不足时访问官网。" +dependencies: + tools: + - readMateClawDoc + - read_file + - search +--- + +# MateClaw 安装与配置问答 + +当用户询问 MateClaw 的安装、初始化、环境配置、依赖要求、常见配置项时使用本技能。 + +**核心原则**:先查文档,再回答;不臆测;回答语言与提问语言一致。 + +## 工作流程 + +### 第一步:查文档目录 + +``` +readMateClawDoc(action="list") +``` + +浏览返回的文件列表,找到与用户问题最相关的文档(如 `zh/quickstart.md`、`en/config.md`)。 + +### 第二步:读取相关文档 + +``` +readMateClawDoc(action="read", path="zh/quickstart.md") +``` + +文档较长时只读相关章节;如多个文档都相关,按优先级依次读取。 + +### 第三步:提炼答案 + +从文档中提取关键信息,组织成可执行答案: +1. 先给直接结论 +2. 再给步骤 / 命令 / 配置示例 +3. 补充必要前置条件和常见坑 + +### 第四步(兜底):搜索官网 + +如本地文档信息不足: +``` +search(query="MateClaw 安装配置 <关键词>", language="zh-CN", count=5) +``` + +参考搜索结果补充回答,并注明信息来自官网搜索。 + +## 文档路径速查 + +| 内容 | 路径 | +|------|------| +| 快速开始(中文) | `zh/quickstart.md` | +| 配置说明(中文) | `zh/config.md` | +| Quick Start (EN) | `en/quickstart.md` | +| Config Reference (EN) | `en/config.md` | + +## 输出质量要求 + +- 不编造不存在的配置项或命令 +- 涉及版本差异时标注"请以当前版本文档为准" +- 涉及路径、命令、配置键时给可复制的原文片段 +- 若信息仍不足,明确告知用户缺少哪类信息(操作系统、安装方式、报错日志等) diff --git a/mateclaw-server/src/main/resources/skills/make_plan/SKILL.md b/mateclaw-server/src/main/resources/skills/make_plan/SKILL.md new file mode 100644 index 00000000..38df78a2 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/make_plan/SKILL.md @@ -0,0 +1,133 @@ +--- +name: make_plan +version: "1.3.0" +description: "当任务需要多步拆解或不确定执行路径时,向更强 Agent 请求一份分步可落地的执行计划,由当前 Agent 自己执行。" +dependencies: + tools: + - listAvailableAgents + - delegateToAgent + - skillFileTool +--- + +# 制定计划 + +本技能的目标:**向更强 Agent 要计划,自己来执行**。不是把任务外包出去,而是获得一份可落地的执行路径。 + +## 何时使用 + +### 应该使用 +- 任务需要多步拆解,步骤之间有依赖关系 +- 不确定执行顺序或关键检查点 +- 涉及多个模块、文件、系统或角色 +- 用户明确要求先给出计划 +- 想在动手前获得更完整、更稳妥的执行路径 + +### 不应使用 +- 任务很简单,一步就能完成 +- 真正缺的是一个小事实,而不是计划 +- 连任务目标都没理解清楚(先理解目标,再要计划) +- 其实是想让对方直接替你执行任务(用 `chat_with_agent`) + +## 工作流程 + +### 第一步:查询可用 Agent + +``` +listAvailableAgents() +``` + +根据描述选择能力最强或最匹配任务领域的 Agent(没有合适的就用 default)。 + +### 第二步:请求计划 + +``` +delegateToAgent( + agentName="strong-agent", + task="[来自 Agent my-agent 的请求] 请为以下任务制定执行计划。你不需要执行任务,只需要输出计划。 + +任务: +<描述要做什么> + +目标: +<最终想达到什么结果> + +约束: +- <限制条件1> +- <限制条件2> + +计划要求: +1. 拆成明确、可执行的步骤 +2. 标明推荐顺序 +3. 指出关键依赖和检查点 +4. 包含验证方式 + +输出格式:请输出 4-8 个编号步骤,每步具体说明。" +) +``` + +**关键原则**: +- 明确说明"只要计划,不要代执行" +- 步骤必须具体,不接受"先分析,再实现"这类泛泛建议 + +### 第三步:保存计划文件(推荐) + +将收到的计划写入工作区,方便后续追踪和恢复: + +``` +skillFileTool( + action="write", + path="plans/{YYYY-MM-DD}-{task-slug}.md", + content="# 计划:{任务标题}\n\n{计划正文}" +) +``` + +文件名规范:`plans/2026-04-23-migrate-database.md` + +好处: +- 执行中断后可恢复进度 +- 用户可以审阅和修改计划 +- 多步任务完成后作为执行记录 + +### 第四步:提炼并执行 + +收到计划后: +1. 提炼出真正可执行的步骤 +2. 按照当前环境做必要微调 +3. **由当前 Agent 自己执行这些步骤** + +**收到计划不等于任务完成,计划是输入,执行才是输出。** + +### 第五步(如需细化):追问 + +如果计划某步骤不够具体,再次调用: + +``` +delegateToAgent( + agentName="strong-agent", + task="[来自 Agent my-agent 的请求] 请基于刚才的计划,细化第 3 步。仍然只需要补充计划细节,不需要代执行。 + +刚才的计划摘要: +<粘贴上次回复的关键内容> + +需要细化的问题: +<具体的疑问>" +) +``` + +## 计划质量标准 + +合格的计划必须满足: +- 有明确步骤,不是泛泛建议 +- 步骤顺序清楚 +- 每步是可执行的具体动作 +- 关键依赖被点明 +- 有必要的验证点 + +若收到的是空泛建议,继续追问细化,直到满足以上标准。 + +## 护栏 + +- 不要把"请帮我规划"说成"请你直接做完" +- 不要请求对方执行代码、命令或变更 +- 拿到计划后仍要结合当前环境判断,不要照单全收 +- 目标不清楚时,先弄清楚目标,再要计划 diff --git a/mateclaw-server/src/main/resources/skills/mateclaw_source_index/SKILL.md b/mateclaw-server/src/main/resources/skills/mateclaw_source_index/SKILL.md new file mode 100644 index 00000000..0c8ff044 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/mateclaw_source_index/SKILL.md @@ -0,0 +1,108 @@ +--- +name: mateclaw_source_index +version: "1.0.0" +description: "将用户问题映射到 MateClaw 文档路径与源码入口,减少盲目搜索。回答'XX 功能在哪里实现'、'怎么修改 YY 逻辑'等源码定位问题。" +dependencies: + tools: + - readMateClawDoc + - read_file +--- + +# MateClaw 源码导航 + +当用户询问"XX 功能在哪里实现"、"Agent 流程入口在哪"、"如何修改 YY 逻辑"等源码定位问题时使用本技能。 + +## 工作流程 + +### 第一步:查文档索引 + +``` +readMateClawDoc(action="list") +``` + +根据用户问题关键词在文件列表中找到最相关文档。 + +### 第二步:读取架构文档 + +``` +readMateClawDoc(action="read", path="zh/architecture.md") +``` + +或直接读 CLAUDE.md(项目根目录,包含最完整的包结构说明): +``` +read_file(filePath="CLAUDE.md") +``` + +### 第三步:返回定位结果 + +回答格式:**文件路径 : 行号范围 + 一句话说明入口作用** + +示例: +> `agent/graph/StateGraphReActAgent.java` — ReAct 循环图的组装入口,连接 ReasoningNode → ActionNode → ObservationNode + +## 核心路径速查表 + +### Agent 运行时 + +| 功能 | 文件路径 | +|------|---------| +| ReAct 图组装 | `agent/graph/StateGraphReActAgent.java` | +| Plan-Execute 图组装 | `agent/graph/plan/StateGraphPlanExecuteAgent.java` | +| 推理节点 | `agent/graph/node/ReasoningNode.java` | +| 动作节点 | `agent/graph/node/ActionNode.java` | +| 观察节点 | `agent/graph/node/ObservationNode.java` | +| 最终答案节点 | `agent/graph/node/FinalAnswerNode.java` | +| Agent 图构建器 | `agent/AgentGraphBuilder.java` | +| 上下文注入 | `agent/context/RuntimeContextInjector.java` | +| Token 估算 / 裁剪 | `agent/context/TokenEstimator.java` | + +### 工具 & 审批 + +| 功能 | 文件路径 | +|------|---------| +| 工具注册中心 | `tool/ToolRegistry.java` | +| MCP 适配器 | `tool/mcp/` | +| 工具守卫规则 | `tool/guard/` | +| 人工审批流程 | `approval/ApprovalWorkflowService.java` | +| 审批 Controller | `approval/ApprovalController.java` | + +### 渠道 & 对话 + +| 功能 | 文件路径 | +|------|---------| +| 渠道适配器接口 | `channel/ChannelAdapter.java` | +| Web SSE 聊天 | `channel/web/ChatController.java` | +| 渠道 Webhook | `channel/ChannelWebhookController.java` | +| 流追踪器 | `channel/web/ChatStreamTracker.java` | + +### 记忆 & Wiki + +| 功能 | 文件路径 | +|------|---------| +| 记忆生命周期协调者 | `memory/MemoryLifecycleMediator.java` | +| Dream 引擎 | `memory/dream/DreamService.java` | +| Dream 报告 Controller | `memory/controller/DreamController.java` | +| Wiki 处理流水线 | `wiki/service/WikiProcessingService.java` | +| Wiki Controller | `wiki/controller/WikiController.java` | + +### 技能运行时 + +| 功能 | 文件路径 | +|------|---------| +| 技能运行时服务 | `skill/runtime/SkillRuntimeService.java` | +| 技能安全扫描 | `skill/security/SkillSecurityService.java` | +| 技能 Controller | `skill/controller/SkillController.java` | + +### 数据库 & 配置 + +| 功能 | 文件路径 | +|------|---------| +| Flyway 迁移(H2) | `resources/db/migration/h2/` | +| Flyway 迁移(MySQL) | `resources/db/migration/mysql/` | +| 种子数据(中文) | `resources/db/data-mysql-zh.sql` | +| 系统设置 | `system/service/SystemSettingService.java` | + +## 注意 + +- 路径均相对于 `mateclaw-server/src/main/java/vip/mate/`(Java 文件)或 `mateclaw-server/src/main/resources/`(资源文件) +- 如找不到精确文件,先用 `readMateClawDoc` 搜索,再用 `read_file` 读取 CLAUDE.md 获取最新架构描述 diff --git a/mateclaw-server/src/main/resources/skills/multi_agent_collaboration/SKILL.md b/mateclaw-server/src/main/resources/skills/multi_agent_collaboration/SKILL.md new file mode 100644 index 00000000..22b24821 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/multi_agent_collaboration/SKILL.md @@ -0,0 +1,104 @@ +--- +name: multi_agent_collaboration +version: "1.4.0" +description: "当任务需要多个 Agent 的专业能力协同完成时,编排多 Agent 并行或串行协作,整合各方结果。" +dependencies: + tools: + - listAvailableAgents + - delegateToAgent + - delegateParallel +--- + +# 多 Agent 协作 + +## 何时使用 + +当任务明显需要多个专业 Agent 共同完成,或用户明确要求多 Agent 协作时使用。 + +### 应该使用 +- 任务可拆分为多个专业子域,每个子域有对应 Agent +- 多个独立子任务可以并行执行(节省时间) +- 需要来自不同 Agent 的结果进行综合分析 +- 用户明确要求"让 A 和 B 一起做" + +### 不应使用 +- 一个 Agent 可以完成,无需分工 +- 只是简单咨询,用 `chat_with_agent` 即可 +- 刚收到某 Agent 的消息,不要立刻回调它(防死循环) + +## 两种协作模式 + +### 模式一:串行(有依赖关系) + +B 的任务需要 A 的结果时使用: + +``` +# 第一阶段:A 完成 +result_a = delegateToAgent( + agentName="research-agent", + task="[来自 Agent coordinator 的请求] 收集最新 AI 大模型基准测试数据,返回原始数据表格。" +) + +# 第二阶段:B 基于 A 的结果处理 +result_b = delegateToAgent( + agentName="data-analyst", + task="[来自 Agent coordinator 的请求] 基于以下数据生成分析报告和可视化建议:\n\n" + result_a +) +``` + +### 模式二:并行(互相独立) + +多个子任务之间没有依赖时使用,最多同时 3 个: + +``` +results = delegateParallel( + tasksJson="[ + {\"agentName\": \"research-agent\", \"task\": \"[来自 Agent coordinator 的请求] 搜索竞品 A 的最新功能更新\"}, + {\"agentName\": \"data-analyst\", \"task\": \"[来自 Agent coordinator 的请求] 分析我们产品上月用户留存数据\"}, + {\"agentName\": \"writer-agent\", \"task\": \"[来自 Agent coordinator 的请求] 起草本次竞品分析报告的大纲\"} + ]" +) +``` + +所有任务完成后一次性返回全部结果,再由当前 Agent 整合。 + +## 完整工作流程 + +### 第一步:查询可用 Agent + +``` +listAvailableAgents() +``` + +根据各 Agent 的描述分配任务。 + +### 第二步:判断串行 or 并行 + +| 判断条件 | 模式 | +|---------|------| +| 子任务 B 依赖子任务 A 的结果 | 串行 | +| 子任务互相独立,可同时进行 | 并行 | +| 混合(部分有依赖) | 先并行无依赖任务,再串行有依赖任务 | + +### 第三步:分配并执行 + +使用对应模式(见上)。 + +### 第四步:整合结果 + +由当前 Agent(编排者)负责整合所有 Agent 的返回结果,形成最终回复。**不要把整合工作再委托给某个子 Agent。** + +## 关键规则 + +- 任务说明中加 `[来自 Agent <名称> 的请求]` 帮助目标 Agent 识别来源 +- 并行任务数量不超过 3 个;超过时按优先级分批 +- 不让两个 Agent 互相调用对方(会形成死循环) +- 整合由编排者负责,不再向下委托 +- 如需上下文连贯,在 `task` 中附带前一阶段的关键结论 + +## 与 chat_with_agent 的区别 + +| 技能 | 场景 | +|------|------| +| `chat_with_agent` | 一对一,咨询或单任务委托 | +| `multi_agent_collaboration` | 一对多,多 Agent 分工、并行或串行编排 | diff --git a/mateclaw-server/src/main/resources/skills/news/SKILL.md b/mateclaw-server/src/main/resources/skills/news/SKILL.md new file mode 100644 index 00000000..e3f56966 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/news/SKILL.md @@ -0,0 +1,116 @@ +--- +name: news +version: "2.0.0" +description: "从互联网查询最新新闻。支持政治、财经、社会、国际、科技、体育、娱乐等分类,自动适配搜索工具与浏览器工具。" +dependencies: + tools: + - search + - browser_use +--- + +# 新闻查询 + +当用户询问"最新新闻"、"今天发生了什么"或某类别新闻时使用本技能。 + +## 工作流程 + +### 方式一:搜索工具(推荐,速度快) + +``` +search( + query="今日财经新闻", + freshness="day", + language="zh-CN", + count=8 +) +``` + +参数说明: +- `freshness`:`day`(24h)/ `week` / `month` / `year` +- `language`:`zh-CN`(中文)/ `en`(英文) +- `count`:返回结果数,1-10,默认 5 + +### 方式二:浏览器直接访问权威来源 + +当搜索结果质量不佳或用户需要更权威来源时: + +``` +browser_use(action="start") +browser_use(action="open", url="https://www.chinanews.com/society/") +browser_use(action="snapshot") +``` + +| 类别 | 来源 | URL | +|------|------|-----| +| 政治 | 人民网 · 党报 | https://cpc.people.com.cn/ | +| 财经 | 中国经济网 | http://www.ce.cn/ | +| 社会 | 中新网 · 社会 | https://www.chinanews.com/society/ | +| 国际 | CGTN | https://www.cgtn.com/ | +| 科技 | 科技日报 | https://www.stdaily.com/ | +| 体育 | 央视体育 | https://sports.cctv.com/ | +| 娱乐 | 新浪娱乐 | https://ent.sina.com.cn/ | + +浏览器用完后关闭: +``` +browser_use(action="stop") +``` + +## 输出格式 + +以要点列表呈现,每条包含: +- 标题(加粗) +- 一两句摘要 +- 来源 + 发布时间 + +示例: +``` +**经济数据:3 月 CPI 同比上涨 0.1%** +国家统计局今日发布数据,环比下降 0.4%,低于市场预期。 +来源:中国经济网 · 2026-04-23 +``` + +## 方式三:持续监控 RSS/Atom(blogwatcher) + +当用户需要**定期追踪**某个来源而非单次查询时,使用 `blogwatcher-cli`。 + +安装:`pip install blogwatcher-cli`(SQLite 后端,无需服务器) + +```bash +# 添加 RSS 源 +blogwatcher add --name "科技日报" --url https://www.stdaily.com/rss.xml + +# 添加 Atom 源(也支持) +blogwatcher add --name "MIT Tech Review" --url https://www.technologyreview.com/feed/ + +# 列出已订阅来源 +blogwatcher list + +# 拉取所有来源的新文章(增量,只返回未见过的) +blogwatcher fetch --all + +# 拉取指定来源 +blogwatcher fetch --name "科技日报" + +# 查看最近 N 条条目 +blogwatcher entries --limit 20 + +# 搜索历史条目 +blogwatcher search "人工智能 大模型" + +# 删除来源 +blogwatcher remove --name "科技日报" +``` + +`blogwatcher fetch` 是**增量**的:只返回自上次 fetch 以来的新条目,不重复推送旧内容。 + +**适用场景**: +- 用户说"每天帮我看看某网站有什么新文章" +- 需要追踪多个来源、避免重复浏览 +- 配合 `cron` 技能设置定时抓取任务 + +## 注意事项 + +- 搜索结果以新鲜度为优先,优先选 `freshness="day"` +- 多个类别时分别搜索,避免混淆 +- 网站无法访问时说明原因并提供备用来源链接 +- 不编造新闻,所有内容来自实际搜索/浏览结果 diff --git a/mateclaw-server/src/main/resources/skills/pdf/LICENSE.txt b/mateclaw-server/src/main/resources/skills/pdf/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/pdf/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/mateclaw-server/src/main/resources/skills/pdf/SKILL.md b/mateclaw-server/src/main/resources/skills/pdf/SKILL.md index a7b38d1f..8679081a 100644 --- a/mateclaw-server/src/main/resources/skills/pdf/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/pdf/SKILL.md @@ -27,25 +27,108 @@ platforms: - **pdftoppm** (poppler-utils): PDF-to-image conversion - **qpdf**: PDF manipulation (merge, split, rotate, decrypt) +## Tool Selection Decision Table + +Choose the right approach before starting: + +| Input | Condition | Recommended Tool | +|-------|-----------|-----------------| +| URL | PDF accessible via URL | `web_extract(url)` — fastest, no download needed | +| Local file | Text-native PDF (generated by software) | `pymupdf` — ~25 MB install, instant extraction | +| Local file | Scanned/image-only PDF (no selectable text) | `marker-pdf` — OCR with layout preservation (~5 GB, needs GPU or CPU) | +| Local file | Form filling or page manipulation | `pypdf` / `pdfplumber` + form scripts | +| Local file | NLP editing or semantic search | `nano-pdf` — sentence-level operations | + +**URL-first rule**: If the user provides a URL, always try URL extraction first before downloading. + ## Overview This guide covers essential PDF processing operations using Python libraries and command-line tools. -## Quick Start +## URL-First Extraction + +If the user provides a URL pointing to a PDF, extract it without downloading: + +``` +web_extract(url="https://example.com/report.pdf") +``` + +Fall back to download + local processing only if `web_extract` returns empty or errors. + +--- + +## Fast Extraction: pymupdf (fitz) + +**Best for**: Text-native PDFs (digital, not scanned). Install: `pip install pymupdf` (~25 MB). ```python -from pypdf import PdfReader, PdfWriter +import fitz # pymupdf -# Read a PDF -reader = PdfReader("document.pdf") -print(f"Pages: {len(reader.pages)}") +doc = fitz.open("document.pdf") +print(f"Pages: {doc.page_count}") -# Extract text -text = "" -for page in reader.pages: - text += page.extract_text() +# Extract all text (fast) +full_text = "\n".join(page.get_text() for page in doc) + +# Extract with layout blocks (tables, columns) +for page in doc: + blocks = page.get_text("blocks") # (x0,y0,x1,y1,text,block_no,block_type) + for block in blocks: + print(block[4]) # text content + +# Extract images +for page in doc: + for img in page.get_images(): + xref = img[0] + base = doc.extract_image(xref) + with open(f"img_{xref}.{base['ext']}", "wb") as f: + f.write(base["image"]) ``` +pymupdf is 5-10× faster than pypdf for text extraction and preserves layout better. + +--- + +## OCR Extraction: marker-pdf + +**Best for**: Scanned PDFs, image-only PDFs, or documents where `pymupdf` returns garbled text. +Install: `pip install marker-pdf` (~5 GB with models). + +```bash +# Single file +marker_single document.pdf output_dir/ --batch_multiplier 2 + +# Batch +marker input_dir/ output_dir/ --workers 4 +``` + +Outputs Markdown with preserved headings, tables, and code blocks. + +**Decision signal**: Run `pymupdf` first. If extracted text has <50% printable characters or looks like garbage, switch to `marker-pdf`. + +--- + +## NLP Editing: nano-pdf + +**Best for**: Semantic search, sentence-level edits, keyword replacement in text-native PDFs. +Install: `pip install nano-pdf`. + +```python +from nano_pdf import NanoPDF + +doc = NanoPDF("document.pdf") + +# Search sentences +results = doc.search("termination clause", top_k=5) +for r in results: + print(r.page, r.text, r.score) + +# Replace text (produces new PDF) +doc.replace("old phrase", "new phrase", output="modified.pdf") +``` + +--- + ## Python Libraries ### pypdf - Basic Operations @@ -268,10 +351,13 @@ with open("encrypted.pdf", "wb") as output: | Task | Best Tool | Command/Code | |------|-----------|--------------| +| URL → text | web_extract | `web_extract(url=...)` | +| Fast text extraction | pymupdf | `fitz.open(...).get_text()` | +| Scanned / OCR | marker-pdf | `marker_single doc.pdf out/` | +| Semantic search/edit | nano-pdf | `NanoPDF(...).search(...)` | | Merge PDFs | pypdf | `writer.add_page(page)` | | Split PDFs | pypdf | One page per file | -| Extract text | pdfplumber | `page.extract_text()` | +| Extract text (layout) | pdfplumber | `page.extract_text()` | | Extract tables | pdfplumber | `page.extract_tables()` | | Create PDFs | reportlab | Canvas or Platypus | | Fill forms | scripts | `fill_fillable_fields.py` | -| OCR scanned PDFs | pytesseract | Convert to image first | diff --git a/mateclaw-server/src/main/resources/skills/pdf/forms.md b/mateclaw-server/src/main/resources/skills/pdf/forms.md new file mode 100644 index 00000000..f8c1dec1 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/pdf/forms.md @@ -0,0 +1,298 @@ +> **Important:** All `scripts/` paths are relative to the skill directory (where SKILL.md is). +> Run with: `cd {this_skill_dir} && python scripts/...` +> Or use the `cwd` parameter of `execute_shell_command`. + +**CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.** + +If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: + `python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions. + +# Fillable fields +If the PDF has fillable form fields: +- Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format: +``` +[ + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page), + "type": ("text", "checkbox", "radio_group", or "choice"), + }, + // Checkboxes have "checked_value" and "unchecked_value" properties: + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "checkbox", + "checked_value": (Set the field to this value to check the checkbox), + "unchecked_value": (Set the field to this value to uncheck the checkbox), + }, + // Radio groups have a "radio_options" list with the possible choices. + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "radio_group", + "radio_options": [ + { + "value": (set the field to this value to select this radio option), + "rect": (bounding box for the radio button for this option) + }, + // Other radio options + ] + }, + // Multiple choice fields have a "choice_options" list with the possible choices: + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "choice", + "choice_options": [ + { + "value": (set the field to this value to select this option), + "text": (display text of the option) + }, + // Other choice options + ], + } +] +``` +- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory): +`python scripts/convert_pdf_to_images.py ` +Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates). +- Create a `field_values.json` file in this format with the values to be entered for each field: +``` +[ + { + "field_id": "last_name", // Must match the field_id from `extract_form_field_info.py` + "description": "The user's last name", + "page": 1, // Must match the "page" value in field_info.json + "value": "Simpson" + }, + { + "field_id": "Checkbox12", + "description": "Checkbox to be checked if the user is 18 or over", + "page": 1, + "value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options". + }, + // more fields +] +``` +- Run the `fill_fillable_fields.py` script from this file's directory to create a filled-in PDF: +`python scripts/fill_fillable_fields.py ` +This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again. + +# Non-fillable fields +If the PDF doesn't have fillable form fields, you'll add text annotations. First try to extract coordinates from the PDF structure (more accurate), then fall back to visual estimation if needed. + +## Step 1: Try Structure Extraction First + +Run this script to extract text labels, lines, and checkboxes with their exact PDF coordinates: +`python scripts/extract_form_structure.py form_structure.json` + +This creates a JSON file containing: +- **labels**: Every text element with exact coordinates (x0, top, x1, bottom in PDF points) +- **lines**: Horizontal lines that define row boundaries +- **checkboxes**: Small square rectangles that are checkboxes (with center coordinates) +- **row_boundaries**: Row top/bottom positions calculated from horizontal lines + +**Check the results**: If `form_structure.json` has meaningful labels (text elements that correspond to form fields), use **Approach A: Structure-Based Coordinates**. If the PDF is scanned/image-based and has few or no labels, use **Approach B: Visual Estimation**. + +--- + +## Approach A: Structure-Based Coordinates (Preferred) + +Use this when `extract_form_structure.py` found text labels in the PDF. + +### A.1: Analyze the Structure + +Read form_structure.json and identify: + +1. **Label groups**: Adjacent text elements that form a single label (e.g., "Last" + "Name") +2. **Row structure**: Labels with similar `top` values are in the same row +3. **Field columns**: Entry areas start after label ends (x0 = label.x1 + gap) +4. **Checkboxes**: Use the checkbox coordinates directly from the structure + +**Coordinate system**: PDF coordinates where y=0 is at TOP of page, y increases downward. + +### A.2: Check for Missing Elements + +The structure extraction may not detect all form elements. Common cases: +- **Circular checkboxes**: Only square rectangles are detected as checkboxes +- **Complex graphics**: Decorative elements or non-standard form controls +- **Faded or light-colored elements**: May not be extracted + +If you see form fields in the PDF images that aren't in form_structure.json, you'll need to use **visual analysis** for those specific fields (see "Hybrid Approach" below). + +### A.3: Create fields.json with PDF Coordinates + +For each field, calculate entry coordinates from the extracted structure: + +**Text fields:** +- entry x0 = label x1 + 5 (small gap after label) +- entry x1 = next label's x0, or row boundary +- entry top = same as label top +- entry bottom = row boundary line below, or label bottom + row_height + +**Checkboxes:** +- Use the checkbox rectangle coordinates directly from form_structure.json +- entry_bounding_box = [checkbox.x0, checkbox.top, checkbox.x1, checkbox.bottom] + +Create fields.json using `pdf_width` and `pdf_height` (signals PDF coordinates): +```json +{ + "pages": [ + {"page_number": 1, "pdf_width": 612, "pdf_height": 792} + ], + "form_fields": [ + { + "page_number": 1, + "description": "Last name entry field", + "field_label": "Last Name", + "label_bounding_box": [43, 63, 87, 73], + "entry_bounding_box": [92, 63, 260, 79], + "entry_text": {"text": "Smith", "font_size": 10} + }, + { + "page_number": 1, + "description": "US Citizen Yes checkbox", + "field_label": "Yes", + "label_bounding_box": [260, 200, 280, 210], + "entry_bounding_box": [285, 197, 292, 205], + "entry_text": {"text": "X"} + } + ] +} +``` + +**Important**: Use `pdf_width`/`pdf_height` and coordinates directly from form_structure.json. + +### A.4: Validate Bounding Boxes + +Before filling, check your bounding boxes for errors: +`python scripts/check_bounding_boxes.py fields.json` + +This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling. + +--- + +## Approach B: Visual Estimation (Fallback) + +Use this when the PDF is scanned/image-based and structure extraction found no usable text labels (e.g., all text shows as "(cid:X)" patterns). + +### B.1: Convert PDF to Images + +`python scripts/convert_pdf_to_images.py ` + +### B.2: Initial Field Identification + +Examine each page image to identify form sections and get **rough estimates** of field locations: +- Form field labels and their approximate positions +- Entry areas (lines, boxes, or blank spaces for text input) +- Checkboxes and their approximate locations + +For each field, note approximate pixel coordinates (they don't need to be precise yet). + +### B.3: Zoom Refinement (CRITICAL for accuracy) + +For each field, crop a region around the estimated position to refine coordinates precisely. + +**Create a zoomed crop using ImageMagick:** +```bash +magick -crop x++ +repage +``` + +Where: +- `, ` = top-left corner of crop region (use your rough estimate minus padding) +- `, ` = size of crop region (field area plus ~50px padding on each side) + +**Example:** To refine a "Name" field estimated around (100, 150): +```bash +magick images_dir/page_1.png -crop 300x80+50+120 +repage crops/name_field.png +``` + +(Note: if the `magick` command isn't available, try `convert` with the same arguments). + +**Examine the cropped image** to determine precise coordinates: +1. Identify the exact pixel where the entry area begins (after the label) +2. Identify where the entry area ends (before next field or edge) +3. Identify the top and bottom of the entry line/box + +**Convert crop coordinates back to full image coordinates:** +- full_x = crop_x + crop_offset_x +- full_y = crop_y + crop_offset_y + +Example: If the crop started at (50, 120) and the entry box starts at (52, 18) within the crop: +- entry_x0 = 52 + 50 = 102 +- entry_top = 18 + 120 = 138 + +**Repeat for each field**, grouping nearby fields into single crops when possible. + +### B.4: Create fields.json with Refined Coordinates + +Create fields.json using `image_width` and `image_height` (signals image coordinates): +```json +{ + "pages": [ + {"page_number": 1, "image_width": 1700, "image_height": 2200} + ], + "form_fields": [ + { + "page_number": 1, + "description": "Last name entry field", + "field_label": "Last Name", + "label_bounding_box": [120, 175, 242, 198], + "entry_bounding_box": [255, 175, 720, 218], + "entry_text": {"text": "Smith", "font_size": 10} + } + ] +} +``` + +**Important**: Use `image_width`/`image_height` and the refined pixel coordinates from the zoom analysis. + +### B.5: Validate Bounding Boxes + +Before filling, check your bounding boxes for errors: +`python scripts/check_bounding_boxes.py fields.json` + +This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling. + +--- + +## Hybrid Approach: Structure + Visual + +Use this when structure extraction works for most fields but misses some elements (e.g., circular checkboxes, unusual form controls). + +1. **Use Approach A** for fields that were detected in form_structure.json +2. **Convert PDF to images** for visual analysis of missing fields +3. **Use zoom refinement** (from Approach B) for the missing fields +4. **Combine coordinates**: For fields from structure extraction, use `pdf_width`/`pdf_height`. For visually-estimated fields, you must convert image coordinates to PDF coordinates: + - pdf_x = image_x * (pdf_width / image_width) + - pdf_y = image_y * (pdf_height / image_height) +5. **Use a single coordinate system** in fields.json - convert all to PDF coordinates with `pdf_width`/`pdf_height` + +--- + +## Step 2: Validate Before Filling + +**Always validate bounding boxes before filling:** +`python scripts/check_bounding_boxes.py fields.json` + +This checks for: +- Intersecting bounding boxes (which would cause overlapping text) +- Entry boxes that are too small for the specified font size + +Fix any reported errors in fields.json before proceeding. + +## Step 3: Fill the Form + +The fill script auto-detects the coordinate system and handles conversion: +`python scripts/fill_pdf_form_with_annotations.py fields.json ` + +## Step 4: Verify Output + +Convert the filled PDF to images and verify text placement: +`python scripts/convert_pdf_to_images.py ` + +If text is mispositioned: +- **Approach A**: Check that you're using PDF coordinates from form_structure.json with `pdf_width`/`pdf_height` +- **Approach B**: Check that image dimensions match and coordinates are accurate pixels +- **Hybrid**: Ensure coordinate conversions are correct for visually-estimated fields diff --git a/mateclaw-server/src/main/resources/skills/pdf/reference.md b/mateclaw-server/src/main/resources/skills/pdf/reference.md new file mode 100644 index 00000000..41400bf4 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/pdf/reference.md @@ -0,0 +1,612 @@ +# PDF Processing Advanced Reference + +This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions. + +## pypdfium2 Library (Apache/BSD License) + +### Overview +pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement. + +### Render PDF to Images +```python +import pypdfium2 as pdfium +from PIL import Image + +# Load PDF +pdf = pdfium.PdfDocument("document.pdf") + +# Render page to image +page = pdf[0] # First page +bitmap = page.render( + scale=2.0, # Higher resolution + rotation=0 # No rotation +) + +# Convert to PIL Image +img = bitmap.to_pil() +img.save("page_1.png", "PNG") + +# Process multiple pages +for i, page in enumerate(pdf): + bitmap = page.render(scale=1.5) + img = bitmap.to_pil() + img.save(f"page_{i+1}.jpg", "JPEG", quality=90) +``` + +### Extract Text with pypdfium2 +```python +import pypdfium2 as pdfium + +pdf = pdfium.PdfDocument("document.pdf") +for i, page in enumerate(pdf): + text = page.get_text() + print(f"Page {i+1} text length: {len(text)} chars") +``` + +## JavaScript Libraries + +### pdf-lib (MIT License) + +pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment. + +#### Load and Manipulate Existing PDF +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function manipulatePDF() { + // Load existing PDF + const existingPdfBytes = fs.readFileSync('input.pdf'); + const pdfDoc = await PDFDocument.load(existingPdfBytes); + + // Get page count + const pageCount = pdfDoc.getPageCount(); + console.log(`Document has ${pageCount} pages`); + + // Add new page + const newPage = pdfDoc.addPage([600, 400]); + newPage.drawText('Added by pdf-lib', { + x: 100, + y: 300, + size: 16 + }); + + // Save modified PDF + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('modified.pdf', pdfBytes); +} +``` + +#### Create Complex PDFs from Scratch +```javascript +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import fs from 'fs'; + +async function createPDF() { + const pdfDoc = await PDFDocument.create(); + + // Add fonts + const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); + const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // Add page + const page = pdfDoc.addPage([595, 842]); // A4 size + const { width, height } = page.getSize(); + + // Add text with styling + page.drawText('Invoice #12345', { + x: 50, + y: height - 50, + size: 18, + font: helveticaBold, + color: rgb(0.2, 0.2, 0.8) + }); + + // Add rectangle (header background) + page.drawRectangle({ + x: 40, + y: height - 100, + width: width - 80, + height: 30, + color: rgb(0.9, 0.9, 0.9) + }); + + // Add table-like content + const items = [ + ['Item', 'Qty', 'Price', 'Total'], + ['Widget', '2', '$50', '$100'], + ['Gadget', '1', '$75', '$75'] + ]; + + let yPos = height - 150; + items.forEach(row => { + let xPos = 50; + row.forEach(cell => { + page.drawText(cell, { + x: xPos, + y: yPos, + size: 12, + font: helveticaFont + }); + xPos += 120; + }); + yPos -= 25; + }); + + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('created.pdf', pdfBytes); +} +``` + +#### Advanced Merge and Split Operations +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function mergePDFs() { + // Create new document + const mergedPdf = await PDFDocument.create(); + + // Load source PDFs + const pdf1Bytes = fs.readFileSync('doc1.pdf'); + const pdf2Bytes = fs.readFileSync('doc2.pdf'); + + const pdf1 = await PDFDocument.load(pdf1Bytes); + const pdf2 = await PDFDocument.load(pdf2Bytes); + + // Copy pages from first PDF + const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices()); + pdf1Pages.forEach(page => mergedPdf.addPage(page)); + + // Copy specific pages from second PDF (pages 0, 2, 4) + const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]); + pdf2Pages.forEach(page => mergedPdf.addPage(page)); + + const mergedPdfBytes = await mergedPdf.save(); + fs.writeFileSync('merged.pdf', mergedPdfBytes); +} +``` + +### pdfjs-dist (Apache License) + +PDF.js is Mozilla's JavaScript library for rendering PDFs in the browser. + +#### Basic PDF Loading and Rendering +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +// Configure worker (important for performance) +pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js'; + +async function renderPDF() { + // Load PDF + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + console.log(`Loaded PDF with ${pdf.numPages} pages`); + + // Get first page + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1.5 }); + + // Render to canvas + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + const renderContext = { + canvasContext: context, + viewport: viewport + }; + + await page.render(renderContext).promise; + document.body.appendChild(canvas); +} +``` + +#### Extract Text with Coordinates +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractText() { + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + let fullText = ''; + + // Extract text from all pages + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + + const pageText = textContent.items + .map(item => item.str) + .join(' '); + + fullText += `\n--- Page ${i} ---\n${pageText}`; + + // Get text with coordinates for advanced processing + const textWithCoords = textContent.items.map(item => ({ + text: item.str, + x: item.transform[4], + y: item.transform[5], + width: item.width, + height: item.height + })); + } + + console.log(fullText); + return fullText; +} +``` + +#### Extract Annotations and Forms +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractAnnotations() { + const loadingTask = pdfjsLib.getDocument('annotated.pdf'); + const pdf = await loadingTask.promise; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const annotations = await page.getAnnotations(); + + annotations.forEach(annotation => { + console.log(`Annotation type: ${annotation.subtype}`); + console.log(`Content: ${annotation.contents}`); + console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`); + }); + } +} +``` + +## Advanced Command-Line Operations + +### poppler-utils Advanced Features + +#### Extract Text with Bounding Box Coordinates +```bash +# Extract text with bounding box coordinates (essential for structured data) +pdftotext -bbox-layout document.pdf output.xml + +# The XML output contains precise coordinates for each text element +``` + +#### Advanced Image Conversion +```bash +# Convert to PNG images with specific resolution +pdftoppm -png -r 300 document.pdf output_prefix + +# Convert specific page range with high resolution +pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages + +# Convert to JPEG with quality setting +pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output +``` + +#### Extract Embedded Images +```bash +# Extract all embedded images with metadata +pdfimages -j -p document.pdf page_images + +# List image info without extracting +pdfimages -list document.pdf + +# Extract images in their original format +pdfimages -all document.pdf images/img +``` + +### qpdf Advanced Features + +#### Complex Page Manipulation +```bash +# Split PDF into groups of pages +qpdf --split-pages=3 input.pdf output_group_%02d.pdf + +# Extract specific pages with complex ranges +qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf + +# Merge specific pages from multiple PDFs +qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf +``` + +#### PDF Optimization and Repair +```bash +# Optimize PDF for web (linearize for streaming) +qpdf --linearize input.pdf optimized.pdf + +# Remove unused objects and compress +qpdf --optimize-level=all input.pdf compressed.pdf + +# Attempt to repair corrupted PDF structure +qpdf --check input.pdf +qpdf --fix-qdf damaged.pdf repaired.pdf + +# Show detailed PDF structure for debugging +qpdf --show-all-pages input.pdf > structure.txt +``` + +#### Advanced Encryption +```bash +# Add password protection with specific permissions +qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf + +# Check encryption status +qpdf --show-encryption encrypted.pdf + +# Remove password protection (requires password) +qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf +``` + +## Advanced Python Techniques + +### pdfplumber Advanced Features + +#### Extract Text with Precise Coordinates +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + page = pdf.pages[0] + + # Extract all text with coordinates + chars = page.chars + for char in chars[:10]: # First 10 characters + print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}") + + # Extract text by bounding box (left, top, right, bottom) + bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text() +``` + +#### Advanced Table Extraction with Custom Settings +```python +import pdfplumber +import pandas as pd + +with pdfplumber.open("complex_table.pdf") as pdf: + page = pdf.pages[0] + + # Extract tables with custom settings for complex layouts + table_settings = { + "vertical_strategy": "lines", + "horizontal_strategy": "lines", + "snap_tolerance": 3, + "intersection_tolerance": 15 + } + tables = page.extract_tables(table_settings) + + # Visual debugging for table extraction + img = page.to_image(resolution=150) + img.save("debug_layout.png") +``` + +### reportlab Advanced Features + +#### Create Professional Reports with Tables +```python +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib import colors + +# Sample data +data = [ + ['Product', 'Q1', 'Q2', 'Q3', 'Q4'], + ['Widgets', '120', '135', '142', '158'], + ['Gadgets', '85', '92', '98', '105'] +] + +# Create PDF with table +doc = SimpleDocTemplate("report.pdf") +elements = [] + +# Add title +styles = getSampleStyleSheet() +title = Paragraph("Quarterly Sales Report", styles['Title']) +elements.append(title) + +# Add table with advanced styling +table = Table(data) +table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.grey), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 14), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.beige), + ('GRID', (0, 0), (-1, -1), 1, colors.black) +])) +elements.append(table) + +doc.build(elements) +``` + +## Complex Workflows + +### Extract Figures/Images from PDF + +#### Method 1: Using pdfimages (fastest) +```bash +# Extract all images with original quality +pdfimages -all document.pdf images/img +``` + +#### Method 2: Using pypdfium2 + Image Processing +```python +import pypdfium2 as pdfium +from PIL import Image +import numpy as np + +def extract_figures(pdf_path, output_dir): + pdf = pdfium.PdfDocument(pdf_path) + + for page_num, page in enumerate(pdf): + # Render high-resolution page + bitmap = page.render(scale=3.0) + img = bitmap.to_pil() + + # Convert to numpy for processing + img_array = np.array(img) + + # Simple figure detection (non-white regions) + mask = np.any(img_array != [255, 255, 255], axis=2) + + # Find contours and extract bounding boxes + # (This is simplified - real implementation would need more sophisticated detection) + + # Save detected figures + # ... implementation depends on specific needs +``` + +### Batch PDF Processing with Error Handling +```python +import os +import glob +from pypdf import PdfReader, PdfWriter +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def batch_process_pdfs(input_dir, operation='merge'): + pdf_files = glob.glob(os.path.join(input_dir, "*.pdf")) + + if operation == 'merge': + writer = PdfWriter() + for pdf_file in pdf_files: + try: + reader = PdfReader(pdf_file) + for page in reader.pages: + writer.add_page(page) + logger.info(f"Processed: {pdf_file}") + except Exception as e: + logger.error(f"Failed to process {pdf_file}: {e}") + continue + + with open("batch_merged.pdf", "wb") as output: + writer.write(output) + + elif operation == 'extract_text': + for pdf_file in pdf_files: + try: + reader = PdfReader(pdf_file) + text = "" + for page in reader.pages: + text += page.extract_text() + + output_file = pdf_file.replace('.pdf', '.txt') + with open(output_file, 'w', encoding='utf-8') as f: + f.write(text) + logger.info(f"Extracted text from: {pdf_file}") + + except Exception as e: + logger.error(f"Failed to extract text from {pdf_file}: {e}") + continue +``` + +### Advanced PDF Cropping +```python +from pypdf import PdfWriter, PdfReader + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +# Crop page (left, bottom, right, top in points) +page = reader.pages[0] +page.mediabox.left = 50 +page.mediabox.bottom = 50 +page.mediabox.right = 550 +page.mediabox.top = 750 + +writer.add_page(page) +with open("cropped.pdf", "wb") as output: + writer.write(output) +``` + +## Performance Optimization Tips + +### 1. For Large PDFs +- Use streaming approaches instead of loading entire PDF in memory +- Use `qpdf --split-pages` for splitting large files +- Process pages individually with pypdfium2 + +### 2. For Text Extraction +- `pdftotext -bbox-layout` is fastest for plain text extraction +- Use pdfplumber for structured data and tables +- Avoid `pypdf.extract_text()` for very large documents + +### 3. For Image Extraction +- `pdfimages` is much faster than rendering pages +- Use low resolution for previews, high resolution for final output + +### 4. For Form Filling +- pdf-lib maintains form structure better than most alternatives +- Pre-validate form fields before processing + +### 5. Memory Management +```python +# Process PDFs in chunks +def process_large_pdf(pdf_path, chunk_size=10): + reader = PdfReader(pdf_path) + total_pages = len(reader.pages) + + for start_idx in range(0, total_pages, chunk_size): + end_idx = min(start_idx + chunk_size, total_pages) + writer = PdfWriter() + + for i in range(start_idx, end_idx): + writer.add_page(reader.pages[i]) + + # Process chunk + with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output: + writer.write(output) +``` + +## Troubleshooting Common Issues + +### Encrypted PDFs +```python +# Handle password-protected PDFs +from pypdf import PdfReader + +try: + reader = PdfReader("encrypted.pdf") + if reader.is_encrypted: + reader.decrypt("password") +except Exception as e: + print(f"Failed to decrypt: {e}") +``` + +### Corrupted PDFs +```bash +# Use qpdf to repair +qpdf --check corrupted.pdf +qpdf --replace-input corrupted.pdf +``` + +### Text Extraction Issues +```python +# Fallback to OCR for scanned PDFs +import pytesseract +from pdf2image import convert_from_path + +def extract_text_with_ocr(pdf_path): + images = convert_from_path(pdf_path) + text = "" + for i, image in enumerate(images): + text += pytesseract.image_to_string(image) + return text +``` + +## License Information + +- **pypdf**: BSD License +- **pdfplumber**: MIT License +- **pypdfium2**: Apache/BSD License +- **reportlab**: BSD License +- **poppler-utils**: GPL-2 License +- **qpdf**: Apache License +- **pdf-lib**: MIT License +- **pdfjs-dist**: Apache License \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/skills/pptx/LICENSE.txt b/mateclaw-server/src/main/resources/skills/pptx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/pptx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/mateclaw-server/src/main/resources/skills/pptx/SKILL.md b/mateclaw-server/src/main/resources/skills/pptx/SKILL.md index cfe2ad05..0a8a6329 100644 --- a/mateclaw-server/src/main/resources/skills/pptx/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/pptx/SKILL.md @@ -7,6 +7,7 @@ dependencies: tools: - skillScriptTool - skillFileTool + - delegateToAgent platforms: - macos - linux @@ -199,6 +200,34 @@ pdftoppm -jpeg -r 150 output.pdf slide Look for: overlapping elements, text overflow, low-contrast text, uneven gaps, insufficient margins. +### Subagent Visual QA (Fresh Eyes) + +For high-stakes presentations, delegate a visual inspection to a separate agent that has NOT seen the creation process. A fresh pair of eyes catches issues the author missed. + +``` +delegateToAgent( + agentName="strong-agent", + task="[Visual QA Request] Inspect the attached presentation slides as a fresh reviewer. +You have no context about how these were made — treat it as if seeing them for the first time. + +Slides location: + +Check for: +1. Any slide where text is cut off or overflows the frame +2. Low contrast (e.g., light text on light background) +3. Repeated layouts — more than 2 slides with identical structure +4. Text-only slides with no visual element +5. Accent lines under slide titles (hallmark of AI-generated slides) +6. Any leftover placeholder text (XXXX, lorem, [insert here]) +7. Font size below 14pt in body text + +For each issue, state: slide number, issue type, what you see. +If everything looks clean, say so explicitly." +) +``` + +Act on the subagent's findings before declaring the presentation complete. + ### Verification Loop 1. Generate slides -> Convert to images -> Inspect @@ -206,6 +235,7 @@ Look for: overlapping elements, text overflow, low-contrast text, uneven gaps, i 3. Fix issues 4. Re-verify affected slides 5. Repeat until clean +6. (High-stakes) Run subagent visual QA for a fresh-eyes check --- diff --git a/mateclaw-server/src/main/resources/skills/pptx/editing.md b/mateclaw-server/src/main/resources/skills/pptx/editing.md new file mode 100644 index 00000000..60a18b68 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/pptx/editing.md @@ -0,0 +1,209 @@ +> **Important:** All `scripts/` paths are relative to the skill directory (where SKILL.md is). +> Run with: `cd {this_skill_dir} && python scripts/...` +> Or use the `cwd` parameter of `execute_shell_command`. + +# Editing Presentations + +## Template-Based Workflow + +When using an existing presentation as a template: + +1. **Analyze existing slides**: + ```bash + python scripts/thumbnail.py template.pptx + python -m markitdown template.pptx + ``` + Review `thumbnails.jpg` to see layouts, and markitdown output to see placeholder text. + +2. **Plan slide mapping**: For each content section, choose a template slide. + + ⚠️ **USE VARIED LAYOUTS** — monotonous presentations are a common failure mode. Don't default to basic title + bullet slides. Actively seek out: + - Multi-column layouts (2-column, 3-column) + - Image + text combinations + - Full-bleed images with text overlay + - Quote or callout slides + - Section dividers + - Stat/number callouts + - Icon grids or icon + text rows + + **Avoid:** Repeating the same text-heavy layout for every slide. + + Match content type to layout style (e.g., key points → bullet slide, team info → multi-column, testimonials → quote slide). + +3. **Unpack**: `python scripts/office/unpack.py template.pptx unpacked/` + +4. **Build presentation** (do this yourself, not with subagents): + - Delete unwanted slides (remove from ``) + - Duplicate slides you want to reuse (`add_slide.py`) + - Reorder slides in `` + - **Complete all structural changes before step 5** + +5. **Edit content**: Update text in each `slide{N}.xml`. + **Use subagents here if available** — slides are separate XML files, so subagents can edit in parallel. + +6. **Clean**: `python scripts/clean.py unpacked/` + +7. **Pack**: `python scripts/office/pack.py unpacked/ output.pptx --original template.pptx` + +--- + +## Scripts + +| Script | Purpose | +|--------|---------| +| `unpack.py` | Extract and pretty-print PPTX | +| `add_slide.py` | Duplicate slide or create from layout | +| `clean.py` | Remove orphaned files | +| `pack.py` | Repack with validation | +| `thumbnail.py` | Create visual grid of slides | + +### unpack.py + +```bash +python scripts/office/unpack.py input.pptx unpacked/ +``` + +Extracts PPTX, pretty-prints XML, escapes smart quotes. + +### add_slide.py + +```bash +python scripts/add_slide.py unpacked/ slide2.xml # Duplicate slide +python scripts/add_slide.py unpacked/ slideLayout2.xml # From layout +``` + +Prints `` to add to `` at desired position. + +### clean.py + +```bash +python scripts/clean.py unpacked/ +``` + +Removes slides not in ``, unreferenced media, orphaned rels. + +### pack.py + +```bash +python scripts/office/pack.py unpacked/ output.pptx --original input.pptx +``` + +Validates, repairs, condenses XML, re-encodes smart quotes. + +### thumbnail.py + +```bash +python scripts/thumbnail.py input.pptx [output_prefix] [--cols N] +``` + +Creates `thumbnails.jpg` with slide filenames as labels. Default 3 columns, max 12 per grid. + +**Use for template analysis only** (choosing layouts). For visual QA, use `soffice` + `pdftoppm` to create full-resolution individual slide images—see SKILL.md. + +--- + +## Slide Operations + +Slide order is in `ppt/presentation.xml` → ``. + +**Reorder**: Rearrange `` elements. + +**Delete**: Remove ``, then run `clean.py`. + +**Add**: Use `add_slide.py`. Never manually copy slide files—the script handles notes references, Content_Types.xml, and relationship IDs that manual copying misses. + +--- + +## Editing Content + +**Subagents:** If available, use them here (after completing step 4). Each slide is a separate XML file, so subagents can edit in parallel. In your prompt to subagents, include: +- The slide file path(s) to edit +- **"Use the Edit tool for all changes"** +- The formatting rules and common pitfalls below + +For each slide: +1. Read the slide's XML +2. Identify ALL placeholder content—text, images, charts, icons, captions +3. Replace each placeholder with final content + +**Use the Edit tool, not sed or Python scripts.** The Edit tool forces specificity about what to replace and where, yielding better reliability. + +### Formatting Rules + +- **Bold all headers, subheadings, and inline labels**: Use `b="1"` on ``. This includes: + - Slide titles + - Section headers within a slide + - Inline labels like (e.g.: "Status:", "Description:") at the start of a line +- **Never use unicode bullets (•)**: Use proper list formatting with `` or `` +- **Bullet consistency**: Let bullets inherit from the layout. Only specify `` or ``. + +--- + +## Common Pitfalls + +### Template Adaptation + +When source content has fewer items than the template: +- **Remove excess elements entirely** (images, shapes, text boxes), don't just clear text +- Check for orphaned visuals after clearing text content +- Run visual QA to catch mismatched counts + +When replacing text with different length content: +- **Shorter replacements**: Usually safe +- **Longer replacements**: May overflow or wrap unexpectedly +- Test with visual QA after text changes +- Consider truncating or splitting content to fit the template's design constraints + +**Template slots ≠ Source items**: If template has 4 team members but source has 3 users, delete the 4th member's entire group (image + text boxes), not just the text. + +### Multi-Item Content + +If source has multiple items (numbered lists, multiple sections), create separate `` elements for each — **never concatenate into one string**. + +**❌ WRONG** — all items in one paragraph: +```xml + + Step 1: Do the first thing. Step 2: Do the second thing. + +``` + +**✅ CORRECT** — separate paragraphs with bold headers: +```xml + + + Step 1 + + + + Do the first thing. + + + + Step 2 + + +``` + +Copy `` from the original paragraph to preserve line spacing. Use `b="1"` on headers. + +### Smart Quotes + +Handled automatically by unpack/pack. But the Edit tool converts smart quotes to ASCII. + +**When adding new text with quotes, use XML entities:** + +```xml +the “Agreement” +``` + +| Character | Name | Unicode | XML Entity | +|-----------|------|---------|------------| +| `“` | Left double quote | U+201C | `“` | +| `”` | Right double quote | U+201D | `”` | +| `‘` | Left single quote | U+2018 | `‘` | +| `’` | Right single quote | U+2019 | `’` | + +### Other + +- **Whitespace**: Use `xml:space="preserve"` on `` with leading/trailing spaces +- **XML parsing**: Use `defusedxml.minidom`, not `xml.etree.ElementTree` (corrupts namespaces) diff --git a/mateclaw-server/src/main/resources/skills/pptx/pptxgenjs.md b/mateclaw-server/src/main/resources/skills/pptx/pptxgenjs.md new file mode 100644 index 00000000..6bfed908 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/pptx/pptxgenjs.md @@ -0,0 +1,420 @@ +# PptxGenJS Tutorial + +## Setup & Basic Structure + +```javascript +const pptxgen = require("pptxgenjs"); + +let pres = new pptxgen(); +pres.layout = 'LAYOUT_16x9'; // or 'LAYOUT_16x10', 'LAYOUT_4x3', 'LAYOUT_WIDE' +pres.author = 'Your Name'; +pres.title = 'Presentation Title'; + +let slide = pres.addSlide(); +slide.addText("Hello World!", { x: 0.5, y: 0.5, fontSize: 36, color: "363636" }); + +pres.writeFile({ fileName: "Presentation.pptx" }); +``` + +## Layout Dimensions + +Slide dimensions (coordinates in inches): +- `LAYOUT_16x9`: 10" × 5.625" (default) +- `LAYOUT_16x10`: 10" × 6.25" +- `LAYOUT_4x3`: 10" × 7.5" +- `LAYOUT_WIDE`: 13.3" × 7.5" + +--- + +## Text & Formatting + +```javascript +// Basic text +slide.addText("Simple Text", { + x: 1, y: 1, w: 8, h: 2, fontSize: 24, fontFace: "Arial", + color: "363636", bold: true, align: "center", valign: "middle" +}); + +// Character spacing (use charSpacing, not letterSpacing which is silently ignored) +slide.addText("SPACED TEXT", { x: 1, y: 1, w: 8, h: 1, charSpacing: 6 }); + +// Rich text arrays +slide.addText([ + { text: "Bold ", options: { bold: true } }, + { text: "Italic ", options: { italic: true } } +], { x: 1, y: 3, w: 8, h: 1 }); + +// Multi-line text (requires breakLine: true) +slide.addText([ + { text: "Line 1", options: { breakLine: true } }, + { text: "Line 2", options: { breakLine: true } }, + { text: "Line 3" } // Last item doesn't need breakLine +], { x: 0.5, y: 0.5, w: 8, h: 2 }); + +// Text box margin (internal padding) +slide.addText("Title", { + x: 0.5, y: 0.3, w: 9, h: 0.6, + margin: 0 // Use 0 when aligning text with other elements like shapes or icons +}); +``` + +**Tip:** Text boxes have internal margin by default. Set `margin: 0` when you need text to align precisely with shapes, lines, or icons at the same x-position. + +--- + +## Lists & Bullets + +```javascript +// ✅ CORRECT: Multiple bullets +slide.addText([ + { text: "First item", options: { bullet: true, breakLine: true } }, + { text: "Second item", options: { bullet: true, breakLine: true } }, + { text: "Third item", options: { bullet: true } } +], { x: 0.5, y: 0.5, w: 8, h: 3 }); + +// ❌ WRONG: Never use unicode bullets +slide.addText("• First item", { ... }); // Creates double bullets + +// Sub-items and numbered lists +{ text: "Sub-item", options: { bullet: true, indentLevel: 1 } } +{ text: "First", options: { bullet: { type: "number" }, breakLine: true } } +``` + +--- + +## Shapes + +```javascript +slide.addShape(pres.shapes.RECTANGLE, { + x: 0.5, y: 0.8, w: 1.5, h: 3.0, + fill: { color: "FF0000" }, line: { color: "000000", width: 2 } +}); + +slide.addShape(pres.shapes.OVAL, { x: 4, y: 1, w: 2, h: 2, fill: { color: "0000FF" } }); + +slide.addShape(pres.shapes.LINE, { + x: 1, y: 3, w: 5, h: 0, line: { color: "FF0000", width: 3, dashType: "dash" } +}); + +// With transparency +slide.addShape(pres.shapes.RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "0088CC", transparency: 50 } +}); + +// Rounded rectangle (rectRadius only works with ROUNDED_RECTANGLE, not RECTANGLE) +// ⚠️ Don't pair with rectangular accent overlays — they won't cover rounded corners. Use RECTANGLE instead. +slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "FFFFFF" }, rectRadius: 0.1 +}); + +// With shadow +slide.addShape(pres.shapes.RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "FFFFFF" }, + shadow: { type: "outer", color: "000000", blur: 6, offset: 2, angle: 135, opacity: 0.15 } +}); +``` + +Shadow options: + +| Property | Type | Range | Notes | +|----------|------|-------|-------| +| `type` | string | `"outer"`, `"inner"` | | +| `color` | string | 6-char hex (e.g. `"000000"`) | No `#` prefix, no 8-char hex — see Common Pitfalls | +| `blur` | number | 0-100 pt | | +| `offset` | number | 0-200 pt | **Must be non-negative** — negative values corrupt the file | +| `angle` | number | 0-359 degrees | Direction the shadow falls (135 = bottom-right, 270 = upward) | +| `opacity` | number | 0.0-1.0 | Use this for transparency, never encode in color string | + +To cast a shadow upward (e.g. on a footer bar), use `angle: 270` with a positive offset — do **not** use a negative offset. + +**Note**: Gradient fills are not natively supported. Use a gradient image as a background instead. + +--- + +## Images + +### Image Sources + +```javascript +// From file path +slide.addImage({ path: "images/chart.png", x: 1, y: 1, w: 5, h: 3 }); + +// From URL +slide.addImage({ path: "https://example.com/image.jpg", x: 1, y: 1, w: 5, h: 3 }); + +// From base64 (faster, no file I/O) +slide.addImage({ data: "image/png;base64,iVBORw0KGgo...", x: 1, y: 1, w: 5, h: 3 }); +``` + +### Image Options + +```javascript +slide.addImage({ + path: "image.png", + x: 1, y: 1, w: 5, h: 3, + rotate: 45, // 0-359 degrees + rounding: true, // Circular crop + transparency: 50, // 0-100 + flipH: true, // Horizontal flip + flipV: false, // Vertical flip + altText: "Description", // Accessibility + hyperlink: { url: "https://example.com" } +}); +``` + +### Image Sizing Modes + +```javascript +// Contain - fit inside, preserve ratio +{ sizing: { type: 'contain', w: 4, h: 3 } } + +// Cover - fill area, preserve ratio (may crop) +{ sizing: { type: 'cover', w: 4, h: 3 } } + +// Crop - cut specific portion +{ sizing: { type: 'crop', x: 0.5, y: 0.5, w: 2, h: 2 } } +``` + +### Calculate Dimensions (preserve aspect ratio) + +```javascript +const origWidth = 1978, origHeight = 923, maxHeight = 3.0; +const calcWidth = maxHeight * (origWidth / origHeight); +const centerX = (10 - calcWidth) / 2; + +slide.addImage({ path: "image.png", x: centerX, y: 1.2, w: calcWidth, h: maxHeight }); +``` + +### Supported Formats + +- **Standard**: PNG, JPG, GIF (animated GIFs work in Microsoft 365) +- **SVG**: Works in modern PowerPoint/Microsoft 365 + +--- + +## Icons + +Use react-icons to generate SVG icons, then rasterize to PNG for universal compatibility. + +### Setup + +```javascript +const React = require("react"); +const ReactDOMServer = require("react-dom/server"); +const sharp = require("sharp"); +const { FaCheckCircle, FaChartLine } = require("react-icons/fa"); + +function renderIconSvg(IconComponent, color = "#000000", size = 256) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(IconComponent, { color, size: String(size) }) + ); +} + +async function iconToBase64Png(IconComponent, color, size = 256) { + const svg = renderIconSvg(IconComponent, color, size); + const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); + return "image/png;base64," + pngBuffer.toString("base64"); +} +``` + +### Add Icon to Slide + +```javascript +const iconData = await iconToBase64Png(FaCheckCircle, "#4472C4", 256); + +slide.addImage({ + data: iconData, + x: 1, y: 1, w: 0.5, h: 0.5 // Size in inches +}); +``` + +**Note**: Use size 256 or higher for crisp icons. The size parameter controls the rasterization resolution, not the display size on the slide (which is set by `w` and `h` in inches). + +### Icon Libraries + +Install: `npm install -g react-icons react react-dom sharp` + +Popular icon sets in react-icons: +- `react-icons/fa` - Font Awesome +- `react-icons/md` - Material Design +- `react-icons/hi` - Heroicons +- `react-icons/bi` - Bootstrap Icons + +--- + +## Slide Backgrounds + +```javascript +// Solid color +slide.background = { color: "F1F1F1" }; + +// Color with transparency +slide.background = { color: "FF3399", transparency: 50 }; + +// Image from URL +slide.background = { path: "https://example.com/bg.jpg" }; + +// Image from base64 +slide.background = { data: "image/png;base64,iVBORw0KGgo..." }; +``` + +--- + +## Tables + +```javascript +slide.addTable([ + ["Header 1", "Header 2"], + ["Cell 1", "Cell 2"] +], { + x: 1, y: 1, w: 8, h: 2, + border: { pt: 1, color: "999999" }, fill: { color: "F1F1F1" } +}); + +// Advanced with merged cells +let tableData = [ + [{ text: "Header", options: { fill: { color: "6699CC" }, color: "FFFFFF", bold: true } }, "Cell"], + [{ text: "Merged", options: { colspan: 2 } }] +]; +slide.addTable(tableData, { x: 1, y: 3.5, w: 8, colW: [4, 4] }); +``` + +--- + +## Charts + +```javascript +// Bar chart +slide.addChart(pres.charts.BAR, [{ + name: "Sales", labels: ["Q1", "Q2", "Q3", "Q4"], values: [4500, 5500, 6200, 7100] +}], { + x: 0.5, y: 0.6, w: 6, h: 3, barDir: 'col', + showTitle: true, title: 'Quarterly Sales' +}); + +// Line chart +slide.addChart(pres.charts.LINE, [{ + name: "Temp", labels: ["Jan", "Feb", "Mar"], values: [32, 35, 42] +}], { x: 0.5, y: 4, w: 6, h: 3, lineSize: 3, lineSmooth: true }); + +// Pie chart +slide.addChart(pres.charts.PIE, [{ + name: "Share", labels: ["A", "B", "Other"], values: [35, 45, 20] +}], { x: 7, y: 1, w: 5, h: 4, showPercent: true }); +``` + +### Better-Looking Charts + +Default charts look dated. Apply these options for a modern, clean appearance: + +```javascript +slide.addChart(pres.charts.BAR, chartData, { + x: 0.5, y: 1, w: 9, h: 4, barDir: "col", + + // Custom colors (match your presentation palette) + chartColors: ["0D9488", "14B8A6", "5EEAD4"], + + // Clean background + chartArea: { fill: { color: "FFFFFF" }, roundedCorners: true }, + + // Muted axis labels + catAxisLabelColor: "64748B", + valAxisLabelColor: "64748B", + + // Subtle grid (value axis only) + valGridLine: { color: "E2E8F0", size: 0.5 }, + catGridLine: { style: "none" }, + + // Data labels on bars + showValue: true, + dataLabelPosition: "outEnd", + dataLabelColor: "1E293B", + + // Hide legend for single series + showLegend: false, +}); +``` + +**Key styling options:** +- `chartColors: [...]` - hex colors for series/segments +- `chartArea: { fill, border, roundedCorners }` - chart background +- `catGridLine/valGridLine: { color, style, size }` - grid lines (`style: "none"` to hide) +- `lineSmooth: true` - curved lines (line charts) +- `legendPos: "r"` - legend position: "b", "t", "l", "r", "tr" + +--- + +## Slide Masters + +```javascript +pres.defineSlideMaster({ + title: 'TITLE_SLIDE', background: { color: '283A5E' }, + objects: [{ + placeholder: { options: { name: 'title', type: 'title', x: 1, y: 2, w: 8, h: 2 } } + }] +}); + +let titleSlide = pres.addSlide({ masterName: "TITLE_SLIDE" }); +titleSlide.addText("My Title", { placeholder: "title" }); +``` + +--- + +## Common Pitfalls + +⚠️ These issues cause file corruption, visual bugs, or broken output. Avoid them. + +1. **NEVER use "#" with hex colors** - causes file corruption + ```javascript + color: "FF0000" // ✅ CORRECT + color: "#FF0000" // ❌ WRONG + ``` + +2. **NEVER encode opacity in hex color strings** - 8-char colors (e.g., `"00000020"`) corrupt the file. Use the `opacity` property instead. + ```javascript + shadow: { type: "outer", blur: 6, offset: 2, color: "00000020" } // ❌ CORRUPTS FILE + shadow: { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.12 } // ✅ CORRECT + ``` + +3. **Use `bullet: true`** - NEVER unicode symbols like "•" (creates double bullets) + +4. **Use `breakLine: true`** between array items or text runs together + +5. **Avoid `lineSpacing` with bullets** - causes excessive gaps; use `paraSpaceAfter` instead + +6. **Each presentation needs fresh instance** - don't reuse `pptxgen()` objects + +7. **NEVER reuse option objects across calls** - PptxGenJS mutates objects in-place (e.g. converting shadow values to EMU). Sharing one object between multiple calls corrupts the second shape. + ```javascript + const shadow = { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }; + slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); // ❌ second call gets already-converted values + slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); + + const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }); + slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); // ✅ fresh object each time + slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); + ``` + +8. **Don't use `ROUNDED_RECTANGLE` with accent borders** - rectangular overlay bars won't cover rounded corners. Use `RECTANGLE` instead. + ```javascript + // ❌ WRONG: Accent bar doesn't cover rounded corners + slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } }); + slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } }); + + // ✅ CORRECT: Use RECTANGLE for clean alignment + slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } }); + slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } }); + ``` + +--- + +## Quick Reference + +- **Shapes**: RECTANGLE, OVAL, LINE, ROUNDED_RECTANGLE +- **Charts**: BAR, LINE, PIE, DOUGHNUT, SCATTER, BUBBLE, RADAR +- **Layouts**: LAYOUT_16x9 (10"×5.625"), LAYOUT_16x10, LAYOUT_4x3, LAYOUT_WIDE +- **Alignment**: "left", "center", "right" +- **Chart data labels**: "outEnd", "inEnd", "center" diff --git a/mateclaw-server/src/main/resources/skills/xlsx/LICENSE.txt b/mateclaw-server/src/main/resources/skills/xlsx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/mateclaw-server/src/main/resources/skills/xlsx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 564ceb64..c891c7a8 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -1,6 +1,6 @@ { "name": "mateclaw-ui", - "version": "1.1.0", + "version": "1.1.137", "private": true, "type": "module", "description": "MateClaw - Personal AI Assistant Web Console", @@ -18,8 +18,10 @@ "echarts": "^6.0.0", "element-plus": "^2.9.1", "highlight.js": "^11.11.1", + "katex": "^0.16.45", "marked": "^15.0.6", "marked-highlight": "^2.2.3", + "mermaid": "^11.14.0", "pinia": "^3.0.1", "vue": "^3.5.13", "vue-i18n": "9.14.4", @@ -27,6 +29,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", + "@types/katex": "^0.16.8", "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.7.0", "autoprefixer": "^10.4.20", diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index 028d18d9..594d7f60 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -29,12 +29,18 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + katex: + specifier: ^0.16.45 + version: 0.16.45 marked: specifier: ^15.0.6 version: 15.0.12 marked-highlight: specifier: ^2.2.3 version: 2.2.3(marked@15.0.12) + mermaid: + specifier: ^11.14.0 + version: 11.14.0 pinia: specifier: ^3.0.1 version: 3.0.4(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)) @@ -51,6 +57,9 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@types/katex': + specifier: ^0.16.8 + version: 0.16.8 '@vitejs/plugin-vue': specifier: ^6.0.5 version: 6.0.5(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3)) @@ -81,6 +90,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -98,6 +110,24 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/cst-dts-gen@12.0.0': + resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} + + '@chevrotain/gast@12.0.0': + resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} + + '@chevrotain/regexp-to-ast@12.0.0': + resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} + + '@chevrotain/types@12.0.0': + resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} + + '@chevrotain/utils@12.0.0': + resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@ctrl/tinycolor@4.2.0': resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} @@ -326,6 +356,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.1': + resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} + '@intlify/core-base@9.14.4': resolution: {integrity: sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==} engines: {node: '>= 16'} @@ -354,6 +390,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} @@ -391,79 +430,66 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -536,28 +562,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -592,12 +614,111 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} @@ -610,6 +731,9 @@ packages: '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-vue@6.0.5': resolution: {integrity: sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -766,6 +890,15 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chevrotain-allstar@0.4.1: + resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} + peerDependencies: + chevrotain: ^12.0.0 + + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -777,13 +910,30 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -796,6 +946,162 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.2: + resolution: {integrity: sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -811,6 +1117,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1017,6 +1326,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1040,6 +1352,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1052,6 +1368,13 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1084,9 +1407,26 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@4.2.2: + resolution: {integrity: sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1126,28 +1466,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1198,6 +1534,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1205,6 +1546,9 @@ packages: memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1219,6 +1563,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1254,6 +1601,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1261,6 +1611,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1269,6 +1622,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -1288,6 +1644,15 @@ packages: typescript: optional: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -1318,11 +1683,23 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1348,6 +1725,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -1363,10 +1743,18 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + tslib@2.3.0: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} @@ -1383,6 +1771,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1395,6 +1786,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1435,6 +1830,23 @@ packages: yaml: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -1494,6 +1906,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -1507,6 +1924,23 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/cst-dts-gen@12.0.0': + dependencies: + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 + + '@chevrotain/gast@12.0.0': + dependencies: + '@chevrotain/types': 12.0.0 + + '@chevrotain/regexp-to-ast@12.0.0': {} + + '@chevrotain/types@12.0.0': {} + + '@chevrotain/utils@12.0.0': {} + '@ctrl/tinycolor@4.2.0': {} '@element-plus/icons-vue@2.3.2(vue@3.5.31(typescript@5.7.3))': @@ -1659,6 +2093,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.1': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.2 + '@intlify/core-base@9.14.4': dependencies: '@intlify/message-compiler': 9.14.4 @@ -1690,6 +2132,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.1.0': + dependencies: + langium: 4.2.2 + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/rollup-android-arm-eabi@4.60.1': @@ -1837,10 +2283,131 @@ snapshots: tailwindcss: 4.2.2 vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.24 @@ -1852,6 +2419,11 @@ snapshots: '@types/web-bluetooth@0.0.20': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-vue@6.0.5(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 @@ -2053,6 +2625,19 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chevrotain-allstar@0.4.1(chevrotain@12.0.0): + dependencies: + chevrotain: 12.0.0 + lodash-es: 4.18.1 + + chevrotain@12.0.0: + dependencies: + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2063,12 +2648,26 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@7.2.0: {} + + commander@8.3.0: {} + concat-map@0.0.1: {} + confbox@0.1.8: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2079,6 +2678,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.2): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.2 + + cytoscape-fcose@2.2.0(cytoscape@3.33.2): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.2 + + cytoscape@3.33.2: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + dayjs@1.11.20: {} debug@4.4.3: @@ -2087,6 +2870,10 @@ snapshots: deep-is@0.1.4: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} detect-libc@2.1.2: {} @@ -2354,6 +3141,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2370,6 +3159,10 @@ snapshots: hookable@5.5.3: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -2379,6 +3172,10 @@ snapshots: imurmurhash@0.1.4: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2401,10 +3198,29 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + katex@0.16.45: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + langium@4.2.2: + dependencies: + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.1(chevrotain@12.0.0) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2485,10 +3301,36 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} memoize-one@6.0.0: {} + mermaid@11.14.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.1 + '@mermaid-js/parser': 1.1.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.2 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.2) + cytoscape-fcose: 2.2.0(cytoscape@3.33.2) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.3.3 + katex: 0.16.45 + khroma: 2.1.0 + lodash-es: 4.18.1 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.2.0 + uuid: 11.1.0 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -2501,6 +3343,13 @@ snapshots: mitt@3.0.1: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -2534,16 +3383,22 @@ snapshots: dependencies: p-limit: 3.1.0 + package-manager-detector@1.6.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + pathe@2.0.3: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -2557,6 +3412,19 @@ snapshots: optionalDependencies: typescript: 5.7.3 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -2580,6 +3448,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@3.0.3: {} + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 @@ -2611,6 +3481,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + semver@7.7.4: {} shebang-command@2.0.0: @@ -2625,6 +3506,8 @@ snapshots: strip-json-comments@3.1.1: {} + stylis@4.4.0: {} + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -2637,11 +3520,15 @@ snapshots: tapable@2.3.2: {} + tinyexec@1.1.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + ts-dedent@2.2.0: {} + tslib@2.3.0: {} type-check@0.4.0: @@ -2652,6 +3539,8 @@ snapshots: typescript@5.7.3: {} + ufo@1.6.3: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -2664,6 +3553,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.5 @@ -2677,6 +3568,21 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + vscode-uri@3.1.0: {} vue-component-type-helpers@3.2.6: {} diff --git a/mateclaw-ui/public/icons/providers/siliconcloud.svg b/mateclaw-ui/public/icons/providers/siliconcloud.svg new file mode 100644 index 00000000..6b5f6d80 --- /dev/null +++ b/mateclaw-ui/public/icons/providers/siliconcloud.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f2f83169..bb8e6a9d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -30,7 +30,9 @@ http.interceptors.response.use( // 后端统一响应格式 R: { code: number, msg: string, data: T } if (data && typeof data === 'object' && 'code' in data) { if (data.code === 200) return data - if (data.code === 401 || data.code === 403) { + // 401 = authentication failure → log out + // 403 = authorization failure (e.g. workspace permission denied) → keep session, surface error to caller + if (data.code === 401) { handleAuthFailure() return Promise.reject(new Error(data.msg || 'Unauthorized')) } @@ -39,7 +41,7 @@ http.interceptors.response.use( return data }, (err) => { - if (err.response?.status === 401 || err.response?.status === 403) { + if (err.response?.status === 401) { handleAuthFailure() } return Promise.reject(err.response?.data?.msg || err.message) @@ -121,8 +123,6 @@ export const chatApi = { }, stop: (conversationId: string) => http.post<{ stopped: boolean }>(`/chat/${conversationId}/stop`), - approve: (conversationId: string, data: { pendingId: string; decision: string }) => - http.post(`/chat/${conversationId}/approve`, data), getPendingApprovals: (conversationId: string) => http.get(`/chat/${conversationId}/pending-approvals`), } @@ -144,7 +144,20 @@ export const conversationApi = { // ==================== Skill ==================== export const skillApi = { - list: () => http.get('/skills'), + /** RFC-042 §2.1 — paginated skill listing with search/type/enabled/scanStatus filters */ + page: (params: { + page?: number + size?: number + keyword?: string + skillType?: string + enabled?: boolean + /** 'PASSED' / 'FAILED' — filters by security_scan_status (RFC-042 §2.3.5) */ + scanStatus?: string + } = {}) => http.get('/skills', { params }), + /** Tab count aggregate — returns { all, builtin, mcp, dynamic } */ + counts: () => http.get('/skills/counts'), + /** RFC-042 §2.3.4 — manually rescan a single skill's security */ + rescan: (id: string | number) => http.post(`/skills/${id}/rescan`), listEnabled: () => http.get('/skills/enabled'), get: (id: string | number) => http.get(`/skills/${id}`), create: (data: any) => http.post('/skills', data), @@ -215,10 +228,24 @@ export const channelApi = { toggle: (id: string | number, enabled: boolean) => http.put(`/channels/${id}/toggle?enabled=${enabled}`), status: () => http.get('/channels/status'), + /** Real-time per-channel health (true transport state, not DB enabled flag). */ + health: (id: string | number) => http.get(`/channels/${id}/health`), + /** Batch health for all channels in current workspace. */ + healthAll: () => http.get('/channels/health'), // 微信 iLink Bot QR 码登录 weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'), weixinQrcodeStatus: (qrcode: string) => http.get(`/channels/webhook/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`), + // Feishu one-click app registration (oapi-sdk 2.6+ scene/registration) + feishuRegisterBegin: (domain: string) => + http.post(`/channels/webhook/feishu/register/begin?domain=${encodeURIComponent(domain)}`), + feishuRegisterStatus: (sessionId: string) => + http.get(`/channels/webhook/feishu/register/status?session=${encodeURIComponent(sessionId)}`), + // DingTalk one-click app registration (OAuth Device Flow) + dingtalkRegisterBegin: () => + http.post('/channels/webhook/dingtalk/register/begin'), + dingtalkRegisterStatus: (sessionId: string) => + http.get(`/channels/webhook/dingtalk/register/status?session=${encodeURIComponent(sessionId)}`), } // ==================== MCP Server ==================== @@ -272,6 +299,14 @@ export const modelApi = { testModel: (providerId: string, modelId: string) => http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`), + // ==================== RFC-074: enabled / catalog ==================== + /** Full provider catalog including enabled=false rows; powers the Add Provider drawer. */ + catalog: () => http.get('/models/catalog'), + /** Opt a provider into the dropdown; backend triggers re-probe via ModelConfigChangedEvent. */ + enableProvider: (providerId: string) => http.post(`/models/${providerId}/enable`), + /** Hide a provider; if it owned the current default model, backend auto-promotes a replacement. */ + disableProvider: (providerId: string) => http.post(`/models/${providerId}/disable`), + // ==================== Embedding Model (RFC Embedding UI) ==================== listByType: (modelType: 'chat' | 'embedding') => http.get('/models/by-type', { params: { modelType } }), @@ -282,6 +317,33 @@ export const modelApi = { http.post('/models/embedding/default', { modelId }), } +// ==================== Provider Pool (RFC-009 Phase 4) ==================== +export interface ProviderPoolEntry { + providerId: string + providerName: string + inPool: boolean + removalSource: string | null + removalMessage: string | null + removedAtMs: number | null + inCooldown: boolean + cooldownRemainingMs: number + consecutiveFailures: number +} + +export interface ReprobeResult { + providerId: string + success: boolean + latencyMs: number + errorMessage: string | null + inPool: boolean +} + +export const providerPoolApi = { + snapshot: () => http.get('/llm/provider-pool'), + reprobe: (providerId: string) => + http.post(`/llm/provider-pool/${encodeURIComponent(providerId)}/reprobe`), +} + // ==================== OAuth ==================== export const oauthApi = { authorize: () => http.get('/oauth/openai/authorize'), @@ -290,6 +352,14 @@ export const oauthApi = { revoke: () => http.delete('/oauth/openai/revoke'), } +// RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code +// install — no in-app authorize/revoke flow yet (PR-4). Until then the UI +// can only check status + force a re-detect from disk. +export const claudeCodeOAuthApi = { + status: () => http.get('/oauth/anthropic/status'), + reload: () => http.post('/oauth/anthropic/reload'), +} + // ==================== Setup ==================== export const setupApi = { onboardingStatus: () => http.get('/setup/onboarding-status'), @@ -382,17 +452,25 @@ export const wikiApi = { listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`), addRawText: (kbId: number, data: { title: string; content: string }) => http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data), - uploadRaw: (kbId: number, formData: FormData) => + uploadRaw: (kbId: number, formData: FormData, onProgress?: (pct: number) => void) => http.post(`/wiki/knowledge-bases/${kbId}/raw/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: onProgress + ? (e) => { if (e.total) onProgress(Math.round((e.loaded / e.total) * 100)) } + : undefined, }), deleteRaw: (kbId: number, rawId: number) => http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`), reprocessRaw: (kbId: number, rawId: number) => http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`), + downloadRaw: (kbId: number, rawId: number) => + http.get(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, { + responseType: 'blob', + }), // Wiki Pages - listPages: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/pages`), + listPages: (kbId: number, rawId?: number) => + http.get(`/wiki/knowledge-bases/${kbId}/pages`, rawId != null ? { params: { rawId } } : undefined), getPage: (kbId: number, slug: string) => http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`), updatePage: (kbId: number, slug: string, content: string) => @@ -404,9 +482,41 @@ export const wikiApi = { getBacklinks: (kbId: number, slug: string) => http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`), + // RFC-051 PR-7: archived pages + listArchivedPages: (kbId: number) => + http.get(`/wiki/knowledge-bases/${kbId}/pages/archived`), + archivePage: (kbId: number, slug: string) => + http.post(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/archive`), + unarchivePage: (kbId: number, slug: string) => + http.post(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/unarchive`), + // Processing processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`), getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`), + + // RFC-029: Relations + getRelatedPages: (kbId: number, slug: string, topK = 5) => + http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/related`, { params: { topK } }), + explainRelation: (kbId: number, slugA: string, slugB: string) => + http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slugA)}/relation/${encodeURIComponent(slugB)}`), + getPageCitations: (kbId: number, pageId: number) => + http.get(`/wiki/kb/${kbId}/pages/${pageId}/citations`), + + // RFC-030: Jobs + getWikiJobs: (kbId: number, rawId: number) => + http.get(`/wiki/kb/${kbId}/jobs`, { params: { rawId } }), + getKBStats: (kbId: number) => + http.get(`/wiki/kb/${kbId}/stats`), + + // RFC-031: Enrichment & Repair + enrichPage: (kbId: number, slug: string) => + http.post(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/enrich`), + repairPage: (kbId: number, slug: string) => + http.post(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/repair`), + + // RFC-032: Search preview + searchPreview: (kbId: number, data: { query: string; mode?: string; topK?: number }) => + http.post(`/wiki/kb/${kbId}/search-preview`, data), } // ==================== Workspace (Team) ==================== @@ -433,6 +543,11 @@ export const agentBindingApi = { unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`), listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`), setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames), + // RFC-009 PR-3: per-agent provider preference order. Empty list = use global chain order. + listProviderPreferences: (agentId: string | number) => + http.get(`/agents/${agentId}/provider-preferences`), + setProviderPreferences: (agentId: string | number, providerIds: string[]) => + http.put(`/agents/${agentId}/provider-preferences`, providerIds), } // ==================== Dashboard ==================== diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index 1a8a40eb..d79a2f9e 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -86,13 +86,15 @@ --mc-input-border: #DDD5CC; --mc-input-text: #1C1410; - /* code blocks */ - --mc-code-bg: #1E1814; - --mc-code-header-bg: #2A221C; - --mc-code-header-border: rgba(255, 255, 255, 0.06); - --mc-code-lang-color: #A09080; - --mc-code-copy-color: #8A7A6A; - --mc-code-text: #abb2bf; + /* code blocks — light mode: warm beige bg, dark text */ + --mc-code-bg: #FAF6F1; + --mc-code-header-bg: #EFE8DE; + --mc-code-header-border: rgba(28, 20, 16, 0.08); + --mc-code-lang-color: #6B5344; + --mc-code-copy-color: #6B5344; + --mc-code-copy-hover-bg: rgba(28, 20, 16, 0.06); + --mc-code-copy-hover-color: #1C1410; + --mc-code-text: #1C1410; --mc-code-shadow: rgba(0, 0, 0, 0.08); /* inline code */ @@ -117,6 +119,11 @@ --mc-attachment-color: #7B3F1E; --mc-thinking-bg: #F5F0EB; --mc-thinking-text: #5A4030; + --mc-mermaid-bg: #FAFAF8; + --mc-mermaid-border: #DDD5CC; + /* Code block gutter: now theme-aware along with --mc-code-bg. + Light mode = darker gutter on light bg; dark fork below flips it. */ + --mc-code-line-number: rgba(28, 20, 16, 0.38); --mc-thinking-hover: rgba(217, 119, 87, 0.1); --mc-thinking-icon-bg: rgba(217, 119, 87, 0.12); --mc-thinking-border: rgba(217, 119, 87, 0.2); @@ -201,12 +208,14 @@ html.dark { --mc-input-border: #3D3028; --mc-input-text: #F0EAE4; - /* code blocks */ + /* code blocks — dark mode: deep brown bg, soft text */ --mc-code-bg: #161210; --mc-code-header-bg: #1E1814; --mc-code-header-border: rgba(255, 255, 255, 0.06); --mc-code-lang-color: #8A7060; - --mc-code-copy-color: #7A6050; + --mc-code-copy-color: #8A7060; + --mc-code-copy-hover-bg: rgba(255, 255, 255, 0.08); + --mc-code-copy-hover-color: #F0EAE4; --mc-code-text: #abb2bf; --mc-code-shadow: rgba(0, 0, 0, 0.2); @@ -232,6 +241,9 @@ html.dark { --mc-attachment-color: #F0C4A0; --mc-thinking-bg: #231C17; --mc-thinking-text: #C4A898; + --mc-mermaid-bg: #1A1410; + --mc-mermaid-border: #3D3028; + --mc-code-line-number: rgba(138, 112, 96, 0.95); --mc-thinking-hover: rgba(224, 136, 96, 0.1); --mc-thinking-icon-bg: rgba(224, 136, 96, 0.12); --mc-thinking-border: rgba(224, 136, 96, 0.2); @@ -365,46 +377,151 @@ html.dark body::before { line-height: 1.7; } -/* code-block container (from useMarkdownRenderer) */ +/* code-block container (from useMarkdownRenderer) + --------------------------------------------------------------- + These rules live globally so they apply consistently to every + markdown-body context (chat bubbles, agent context preview, etc.) + and don't depend on the Vue scope hash. The ! markers on padding + and height defeat the global `.markdown-body pre` rule and any + stray

browser defaults that previously inflated the + header to 400+px tall. */ .markdown-body .code-block { margin: 14px 0; border-radius: 12px; overflow: hidden; background: var(--mc-code-bg); + border: 1px solid var(--mc-code-header-border); } .markdown-body .code-block__header { - display: flex; + display: flex !important; align-items: center; justify-content: space-between; - padding: 8px 16px; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 0 14px !important; + margin: 0 !important; + /* Hard cap — earlier versions (auto / min-height: 0) were being inflated + by the SVG inside the copy button rendering at its UA-default 300×150 + when DOMPurify normalised attributes. Fixed height + overflow:hidden + defeats that regardless of child sizing. */ + height: 38px !important; + min-height: 38px !important; + max-height: 38px !important; + line-height: 1 !important; + font-size: 12px !important; + background: var(--mc-code-header-bg); + border-bottom: 1px solid var(--mc-code-header-border); + box-sizing: border-box; + overflow: hidden; +} +/* Defence in depth: pin the icon to its declared 14×14 even if the inline + width/height attrs get normalised away. Without this the SVG can default + to 300×150, which is what was inflating the header. */ +.markdown-body .code-block__header svg { + width: 14px !important; + height: 14px !important; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; +} +.markdown-body .code-block__header > * { + flex-shrink: 0; + min-width: 0; } .markdown-body .code-block__lang { font-size: 12px; - color: #94a3b8; + color: var(--mc-code-lang-color); font-weight: 500; + letter-spacing: 0.02em; +} +.markdown-body .code-block__lines { + /* Hidden by default — only shown for collapsed details (rule below). */ + display: none; } .markdown-body .code-block__copy { - display: flex; + display: inline-flex; align-items: center; gap: 4px; - padding: 4px 8px; + padding: 3px 8px; background: transparent; border: none; border-radius: 6px; - color: #94a3b8; + color: var(--mc-code-copy-color); font-size: 12px; cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .markdown-body .code-block__copy:hover { - background: rgba(255, 255, 255, 0.1); - color: #e2e8f0; + background: var(--mc-code-copy-hover-bg); + color: var(--mc-code-copy-hover-color); } +/* Kill the GLOBAL .markdown-body pre padding (16px) and margin (14px) — those + are what created the visible "tall header" gap between summary and code. */ .markdown-body .code-block pre { + margin: 0 !important; + padding: 0 !important; + border-radius: 0 !important; + background: transparent !important; +} +.markdown-body .code-block pre code, +.markdown-body .code-block pre code.hljs { + background: transparent !important; + padding: 0 !important; + color: var(--mc-code-text); + display: block; + line-height: 1.55; +} +/* Line-numbered gutter via CSS counter */ +.markdown-body .code-block ol.hljs-lines { + counter-reset: ln; + padding: 12px 0; margin: 0; - border-radius: 0; + list-style: none; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} +.markdown-body .code-block ol.hljs-lines > li { + counter-increment: ln; + display: block; + padding: 0 16px 0 4em; + position: relative; + white-space: pre; + min-height: 1.55em; + line-height: 1.55; +} +.markdown-body .code-block ol.hljs-lines > li::before { + content: counter(ln); + position: absolute; + left: 0; + width: 3em; + text-align: right; + color: var(--mc-code-line-number); + user-select: none; + font-size: 0.85em; + font-variant-numeric: tabular-nums; +} +/*
/ reset — strip browser-default marker spacing that + pushed the header sky-high. */ +.markdown-body details.code-block--collapsible > summary { + display: block; + list-style: none; + cursor: pointer; +} +.markdown-body details.code-block--collapsible > summary::-webkit-details-marker, +.markdown-body details.code-block--collapsible > summary::marker { + display: none; + content: ''; +} +/* Show the line-count badge only when the block is collapsed. */ +.markdown-body details.code-block--collapsible:not([open]) .code-block__lines { + display: inline-block; + font-size: 11px; + color: var(--mc-code-lang-color); + margin: 0 12px; + padding: 2px 8px; + border-radius: 4px; + background: var(--mc-code-copy-hover-bg); + user-select: none; +} +.markdown-body details.code-block--collapsible:not([open]) > pre { + display: none; } /* ECharts chart block */ @@ -580,23 +697,43 @@ html.dark .mc-surface-card { } } -/* highlight.js token colors (One Dark inspired — works on dark bg) */ -.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c678dd; } -.hljs-string, .hljs-attr { color: #98c379; } -.hljs-number, .hljs-literal { color: #d19a66; } -.hljs-comment, .hljs-quote { color: #5c6370; font-style: italic; } -.hljs-function .hljs-title, .hljs-title.function_ { color: #61afef; } -.hljs-type, .hljs-title.class_ { color: #e5c07b; } -.hljs-variable, .hljs-template-variable { color: #e06c75; } -.hljs-tag { color: #e06c75; } -.hljs-name { color: #e06c75; } -.hljs-attribute { color: #d19a66; } -.hljs-symbol, .hljs-bullet { color: #56b6c2; } -.hljs-meta { color: #61afef; } -.hljs-params { color: #abb2bf; } -.hljs-section { color: #e06c75; font-weight: bold; } -.hljs-addition { color: #98c379; background: rgba(152, 195, 121, 0.1); } -.hljs-deletion { color: #e06c75; background: rgba(224, 108, 117, 0.1); } +/* highlight.js token colors — Atom One Light (works on the warm beige + light-mode code bg). Dark mode swaps to One Dark in the html.dark block + below so colours stay legible against the deep brown #161210. */ +.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #a626a4; } +.hljs-string, .hljs-attr { color: #50a14f; } +.hljs-number { color: #c18401; } +.hljs-comment, .hljs-quote { color: #a0a1a7; font-style: italic; } +.hljs-function .hljs-title, .hljs-title.function_ { color: #4078f2; } +.hljs-type, .hljs-title.class_ { color: #c18401; } +.hljs-variable, .hljs-template-variable { color: #e45649; } +.hljs-tag { color: #e45649; } +.hljs-name { color: #e45649; } +.hljs-attribute { color: #c18401; } +.hljs-symbol, .hljs-bullet { color: #0184bc; } +.hljs-meta { color: #4078f2; } +.hljs-params { color: #383a42; } +.hljs-section { color: #e45649; font-weight: bold; } +.hljs-addition { color: #50a14f; background: rgba(80, 161, 79, 0.1); } +.hljs-deletion { color: #e45649; background: rgba(228, 86, 73, 0.1); } + +/* Dark mode: One Dark token palette */ +html.dark .hljs-keyword, html.dark .hljs-selector-tag, html.dark .hljs-built_in, html.dark .hljs-literal { color: #c678dd; } +html.dark .hljs-string, html.dark .hljs-attr { color: #98c379; } +html.dark .hljs-number { color: #d19a66; } +html.dark .hljs-comment, html.dark .hljs-quote { color: #5c6370; font-style: italic; } +html.dark .hljs-function .hljs-title, html.dark .hljs-title.function_ { color: #61afef; } +html.dark .hljs-type, html.dark .hljs-title.class_ { color: #e5c07b; } +html.dark .hljs-variable, html.dark .hljs-template-variable { color: #e06c75; } +html.dark .hljs-tag { color: #e06c75; } +html.dark .hljs-name { color: #e06c75; } +html.dark .hljs-attribute { color: #d19a66; } +html.dark .hljs-symbol, html.dark .hljs-bullet { color: #56b6c2; } +html.dark .hljs-meta { color: #61afef; } +html.dark .hljs-params { color: #abb2bf; } +html.dark .hljs-section { color: #e06c75; font-weight: bold; } +html.dark .hljs-addition { color: #98c379; background: rgba(152, 195, 121, 0.1); } +html.dark .hljs-deletion { color: #e06c75; background: rgba(224, 108, 117, 0.1); } .markdown-body p { margin: 8px 0; } .markdown-body ul, .markdown-body ol { padding-left: 1.5rem; margin: 8px 0; } diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue new file mode 100644 index 00000000..e8cfd17c --- /dev/null +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -0,0 +1,903 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index c4515808..38c20d58 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -64,7 +64,7 @@ {{ t('chat.approvalAllow') }} - {{ pendingApproval.toolName }} + {{ getToolLabel(pendingApproval.toolName) }} {{ t('chat.approvalExecute') }}
@@ -136,14 +136,16 @@ - + @@ -203,6 +205,7 @@ import { ref, computed, nextTick, watch } from 'vue' import { useI18n } from 'vue-i18n' import { CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue' +import { useToolLabel } from '@/composables/useToolLabel' import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types' interface Props { @@ -238,6 +241,11 @@ interface Props { enableTalkMode?: boolean /** 深度思考开关状态 */ thinkingEnabled?: boolean + /** + * RFC-049 PR-1-UI: 当前 runtime 模型是否支持 reasoning_effort。false 时按钮灰掉, + * 不响应点击,tooltip 提示当前模型不支持深度思考。默认 true 以保持向后兼容。 + */ + thinkingSupported?: boolean } const props = withDefaults(defineProps(), { @@ -256,6 +264,7 @@ const props = withDefaults(defineProps(), { queueSize: 0, enableTalkMode: false, thinkingEnabled: false, + thinkingSupported: true, }) const emit = defineEmits<{ @@ -272,6 +281,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const { getToolLabel } = useToolLabel() // 内部状态 const containerRef = ref(null) @@ -311,8 +321,15 @@ const handleSubmit = () => { } } - // 运行中且输入为空时,停止生成 + // 运行中且输入为空时,停止生成 —— 但当用户刚刚追加了一条 queued 消息时, + // 第二次点击发送/按 Enter 通常是误操作(双击 / 输入法回车 / 连击)。这种情况下 + // 触发 stop 会把用户预期会跑的当前 turn + queued 一起杀掉,前端给出"任务直接结束" + // 的错觉。检测到 sending 状态的 queued 消息时静默吞掉这次空提交,让用户必须明确 + // 点 cancel-queued 或专用 stop 按钮才能终止。 if (props.loading && !canSend.value) { + if (props.queuedMessage && (props.queuedMessage.status === 'queued' || props.queuedMessage.status === 'sending')) { + return + } emit('stop') return } @@ -596,6 +613,11 @@ defineExpose({ border-radius: 50%; background: var(--el-color-primary, #409eff); } +/* RFC-049 PR-1-UI: model doesn't support reasoning_effort — stronger grayed state */ +.thinking-btn.unsupported { + opacity: 0.35; + cursor: not-allowed; +} .thinking-btn:hover:not(:disabled) { color: var(--el-color-primary, #409eff); background: var(--el-color-primary-light-9, rgba(64, 158, 255, 0.08)); diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index ca0bb37a..10b3419e 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -89,7 +89,7 @@ + +
+ -
-
+
{{ skill.icon || getSkillIcon(skill.skillType) }}
-

{{ skill.name }}

+

{{ resolveSkillName(skill) }}

+ +
{{ skill.name }}
{{ getSkillTypeLabel(skill.skillType) }} @@ -74,12 +92,19 @@ 🤖 AI - - - 🛡️ Scan Failed - + + - ✓ Scanned + ✓ {{ t('skills.security.scanned') }} @@ -102,6 +127,52 @@
{{ getRuntimeError(skill) }}
+ +
+
+ + {{ t('skills.security.findingsTitle') }} + + · {{ formatScanTime(skill.securityScanTime) }} + + + +
+
    +
  • +
    + [{{ f.severity || 'INFO' }}] + {{ f.ruleId || f.category || '—' }} + + {{ f.filePath }}:{{ f.lineNumber }} + +
    +
    {{ f.title }}
    +
    {{ f.description }}
    +
    + {{ t('skills.security.fix') }}: {{ f.remediation }} +
    +
  • +
+
+ {{ t('skills.security.noPersistedFindings') }} +
+
+
{{ tag }}
@@ -132,6 +203,21 @@

{{ t('skills.empty') }}

{{ t('skills.emptyDesc') }}

+ + +
@@ -156,6 +242,15 @@
+ +
+ + +
+
+ + +
- - - -
+ +
+ + +
+
+
+
{{ t('wiki.configPanel.ingestMode') }}
+
+ {{ ingestMode === 'lazy' + ? t('wiki.configPanel.ingestModeLazyHint') + : t('wiki.configPanel.ingestModeEagerHint') }} +
+
+ +
+
+ + +
- - -
- - + +
+
+ + + +
+
{{ t('wiki.configPanel.modelStrategy') }}
+
+ + + + +
+
+
+ {{ activeStepCount }} / {{ stepKeys.length }} +
+ + + +
+ + +
+
+ + + +
+
处理规则
+
AI 消化原始材料时遵循的质量、格式和语言规则
+ +
+ {{ line }} + +{{ totalLines - 4 }} 行 +
+
点击配置 →
+
+ + + +
+
+ + +
+
+ + + +
+
{{ t('wiki.configPanel.searchPreview') }}
+
{{ t('wiki.configPanel.searchPreviewPlaceholder') }}
+
+ + + +
+
+ + + + + + +
diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfigModels.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfigModels.vue new file mode 100644 index 00000000..40890958 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfigModels.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfigRules.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfigRules.vue new file mode 100644 index 00000000..c00325af --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfigRules.vue @@ -0,0 +1,473 @@ + diff --git a/mateclaw-ui/src/views/Wiki/components/WikiSearchPreview.vue b/mateclaw-ui/src/views/Wiki/components/WikiSearchPreview.vue new file mode 100644 index 00000000..029e8b8e --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiSearchPreview.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index f553e1c0..12590cb5 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -17,140 +17,284 @@
- -
-