mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(content-studio): 公众号凭据设置+截图工具+平台规范; fix: 封面按文件名自愈
This commit is contained in:
parent
f6fb7f2556
commit
85bee7a041
@ -34,6 +34,14 @@ public class SystemSettingsDTO {
|
||||
private String serperApiKeyMasked;
|
||||
private String tavilyApiKeyMasked;
|
||||
|
||||
// ===== WeChat Official Account (公众号) publish credentials =====
|
||||
/** 公众号 AppID (plain — not sensitive). */
|
||||
private String weixinoaAppId;
|
||||
/** 公众号 AppSecret — write-only from the client; never echoed in plaintext. */
|
||||
private String weixinoaAppSecret;
|
||||
/** Masked AppSecret for display. */
|
||||
private String weixinoaAppSecretMasked;
|
||||
|
||||
// ===== 视频生成配置 =====
|
||||
/** 是否启用视频生成能力 */
|
||||
private Boolean videoEnabled;
|
||||
|
||||
@ -30,6 +30,10 @@ public class SystemSettingService {
|
||||
private static final String SERPER_BASE_URL_KEY = "serperBaseUrl";
|
||||
private static final String TAVILY_API_KEY_KEY = "tavilyApiKey";
|
||||
private static final String TAVILY_BASE_URL_KEY = "tavilyBaseUrl";
|
||||
|
||||
// WeChat Official Account (公众号) publish credentials — read by GzhPublishTool.
|
||||
private static final String WEIXINOA_APP_ID_KEY = "weixinoa.app_id";
|
||||
private static final String WEIXINOA_APP_SECRET_KEY = "weixinoa.app_secret";
|
||||
private static final String DUCKDUCKGO_ENABLED_KEY = "duckduckgoEnabled";
|
||||
private static final String SEARXNG_BASE_URL_KEY = "searxngBaseUrl";
|
||||
|
||||
@ -139,6 +143,10 @@ public class SystemSettingService {
|
||||
dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, "")));
|
||||
dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, "")));
|
||||
|
||||
// 公众号发布凭证(AppSecret 脱敏回显,AppID 明文)
|
||||
dto.setWeixinoaAppId(getValue(WEIXINOA_APP_ID_KEY, ""));
|
||||
dto.setWeixinoaAppSecretMasked(maskApiKey(getValue(WEIXINOA_APP_SECRET_KEY, "")));
|
||||
|
||||
// 视频生成配置
|
||||
dto.setVideoEnabled(Boolean.parseBoolean(getValue(VIDEO_ENABLED_KEY, "false")));
|
||||
dto.setVideoProvider(getValue(VIDEO_PROVIDER_KEY, "auto"));
|
||||
@ -295,6 +303,14 @@ public class SystemSettingService {
|
||||
if (dto.getTavilyBaseUrl() != null) {
|
||||
saveValue(TAVILY_BASE_URL_KEY, dto.getTavilyBaseUrl(), "Tavily 接口地址");
|
||||
}
|
||||
|
||||
// 公众号发布凭证(AppSecret 仅在非空时保存,避免脱敏回显覆盖为空)
|
||||
if (dto.getWeixinoaAppId() != null) {
|
||||
saveValue(WEIXINOA_APP_ID_KEY, dto.getWeixinoaAppId().trim(), "公众号 AppID");
|
||||
}
|
||||
if (dto.getWeixinoaAppSecret() != null && !dto.getWeixinoaAppSecret().isBlank()) {
|
||||
saveValue(WEIXINOA_APP_SECRET_KEY, dto.getWeixinoaAppSecret().trim(), "公众号 AppSecret");
|
||||
}
|
||||
// Keyless provider 配置
|
||||
if (dto.getDuckduckgoEnabled() != null) {
|
||||
saveValue(DUCKDUCKGO_ENABLED_KEY, String.valueOf(dto.getDuckduckgoEnabled()), "DuckDuckGo 免 Key 搜索(零配置兜底)");
|
||||
|
||||
@ -101,8 +101,14 @@ public class GzhPackageTool {
|
||||
|
||||
// 1. Markdown -> HTML fragment, then inline every style (公众号 drops <style>/class).
|
||||
String innerHtml = markdownToInlineHtml(markdown);
|
||||
String coverTag = (coverImageUrl != null && !coverImageUrl.isBlank())
|
||||
? "<img src=\"" + escapeAttr(coverImageUrl.trim()) + "\" alt=\"cover\" "
|
||||
|
||||
// Resolve the cover to real image bytes + a servable URL up front, so the
|
||||
// preview never embeds a broken <img>: a reference that doesn't resolve to
|
||||
// an actual image is dropped (and flagged) rather than rendered. This also
|
||||
// self-heals a reference that points at the file's name instead of its id.
|
||||
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
|
||||
String coverTag = (cover != null)
|
||||
? "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
|
||||
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />"
|
||||
: "";
|
||||
String meta = (author != null && !author.isBlank())
|
||||
@ -130,7 +136,7 @@ public class GzhPackageTool {
|
||||
String zipUrl;
|
||||
String coverNote;
|
||||
try {
|
||||
byte[] coverBytes = resolveCover(coverImageUrl);
|
||||
byte[] coverBytes = cover != null ? cover.bytes() : null;
|
||||
// For the offline bundle, point the cover at the local file.
|
||||
String bundleContainer = coverBytes != null
|
||||
? container.replaceFirst("<img src=\"[^\"]*\"", "<img src=\"cover.png\"")
|
||||
@ -150,6 +156,13 @@ public class GzhPackageTool {
|
||||
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("✅ 公众号图文已打包完成。\n\n");
|
||||
if (coverImageUrl != null && !coverImageUrl.isBlank() && cover == null) {
|
||||
// Requested a cover but it didn't resolve to an image — say so instead
|
||||
// of silently shipping a broken image tag.
|
||||
out.append("⚠️ 提供的封面引用无法解析为图片,已跳过封面(未嵌坏图):")
|
||||
.append(coverImageUrl.trim())
|
||||
.append("\n 请改用 image_generate 返回的完整 URL(/api/v1/files/generated/<id>)再打包一次。\n\n");
|
||||
}
|
||||
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
|
||||
if (zipUrl != null) {
|
||||
out.append("📦 素材下载(article.html + article.md + 封面,").append(coverNote).append("):")
|
||||
@ -157,7 +170,7 @@ public class GzhPackageTool {
|
||||
}
|
||||
out.append("\n可将下面的内联样式 HTML 直接粘贴进公众号编辑器(如需直接进草稿箱,用 gzh_publish):\n");
|
||||
out.append("```html\n").append(container).append("\n```");
|
||||
log.info("[GzhPackage] packaged '{}' ({} md chars, cover={})", title, markdown.length(), coverImageUrl != null);
|
||||
log.info("[GzhPackage] packaged '{}' ({} md chars, coverResolved={})", title, markdown.length(), cover != null);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
@ -222,30 +235,84 @@ public class GzhPackageTool {
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
/** Resolve the cover reference to bytes: generated-file id, http(s) URL, else null. */
|
||||
/** A cover resolved to real image bytes (for the bundle) and a servable URL (for the preview). */
|
||||
private record ResolvedCover(byte[] bytes, String url) { }
|
||||
|
||||
/**
|
||||
* Resolve the cover reference to real image bytes plus a servable URL, or null
|
||||
* if it can't be made into an image. Three paths, in order:
|
||||
* <ol>
|
||||
* <li>an explicit generated-file id in the URL ({@code .../generated/<uuid>}),
|
||||
* accepted only if it maps to a live {@code image/*} entry;</li>
|
||||
* <li><b>self-heal</b>: the ref points at the file's logical <em>name</em>
|
||||
* ({@code cover_xyz.png}) rather than its id — recover the real id by
|
||||
* filename so a name-based reference still yields a working cover;</li>
|
||||
* <li>an external {@code http(s)} image — downloaded for the bundle, its URL
|
||||
* kept for the preview.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Nullable
|
||||
private byte[] resolveCover(@Nullable String ref) {
|
||||
private ResolvedCover resolveCover(@Nullable String ref, @Nullable ToolContext ctx) {
|
||||
if (ref == null || ref.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String r = ref.trim();
|
||||
try {
|
||||
// 1. Explicit generated-file id — trust only a live image entry.
|
||||
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r);
|
||||
if (m.find()) {
|
||||
Optional<GeneratedFileCache.Entry> e = cache.get(m.group(1));
|
||||
return e.map(GeneratedFileCache.Entry::bytes).orElse(null);
|
||||
if (e.isPresent() && isImage(e.get()) && hasBytes(e.get())) {
|
||||
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx));
|
||||
}
|
||||
}
|
||||
// 2. Self-heal a name-based reference (the id pattern can't parse
|
||||
// underscores/dots, so `cover_xyz.png` never matches step 1).
|
||||
String name = lastSegment(r);
|
||||
if (name != null && !name.isBlank()) {
|
||||
Optional<String> healed = cache.findIdByFilename(name, "image/");
|
||||
if (healed.isPresent()) {
|
||||
Optional<GeneratedFileCache.Entry> e = cache.get(healed.get());
|
||||
if (e.isPresent() && hasBytes(e.get())) {
|
||||
log.info("[GzhPackage] cover ref '{}' healed to generated id {} by filename", r, healed.get());
|
||||
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(healed.get(), ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. External http(s) image — download for the bundle, keep the URL.
|
||||
if (r.startsWith("http://") || r.startsWith("https://")) {
|
||||
UrlSafetyChecker.check(r);
|
||||
byte[] b = HttpUtil.downloadBytes(r);
|
||||
return (b != null && b.length > 0) ? b : null;
|
||||
if (b != null && b.length > 0) {
|
||||
return new ResolvedCover(b, r);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[GzhPackage] cover resolve failed: {}", e.getMessage());
|
||||
log.warn("[GzhPackage] cover resolve failed for '{}': {}", r, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isImage(GeneratedFileCache.Entry e) {
|
||||
return e.mimeType() != null && e.mimeType().startsWith("image/");
|
||||
}
|
||||
|
||||
private static boolean hasBytes(GeneratedFileCache.Entry e) {
|
||||
return e.bytes() != null && e.bytes().length > 0;
|
||||
}
|
||||
|
||||
/** Last path segment of a URL, minus any {@code ?query} / {@code #fragment}. */
|
||||
@Nullable
|
||||
private static String lastSegment(String url) {
|
||||
String s = url;
|
||||
int cut = s.indexOf('?');
|
||||
if (cut >= 0) s = s.substring(0, cut);
|
||||
cut = s.indexOf('#');
|
||||
if (cut >= 0) s = s.substring(0, cut);
|
||||
int slash = s.lastIndexOf('/');
|
||||
return slash >= 0 ? s.substring(slash + 1) : s;
|
||||
}
|
||||
|
||||
private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) {
|
||||
String id = cache.put(bytes, name, mime);
|
||||
return cache.downloadUrl(id, ctx);
|
||||
|
||||
@ -0,0 +1,180 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
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.options.ScreenshotType;
|
||||
import com.microsoft.playwright.options.WaitUntilState;
|
||||
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.beans.factory.annotation.Value;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.tool.browser.BrowserLauncher;
|
||||
import vip.mate.tool.document.FilenameSanitizer;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
import vip.mate.tool.document.GeneratedFileLink;
|
||||
|
||||
/**
|
||||
* Built-in tool: capture a screenshot of a MateClaw admin-console page and
|
||||
* return an embeddable image URL.
|
||||
*
|
||||
* <p>Purpose: let content skills illustrate a how-to article with <b>real</b>
|
||||
* product screenshots. The console lives behind JWT auth, so the tool mints a
|
||||
* short-lived token for the calling user, injects it into {@code localStorage}
|
||||
* before the SPA boots (Playwright init script), navigates to the requested
|
||||
* <b>same-origin relative path</b>, and screenshots the rendered page. The PNG
|
||||
* is stashed in {@link GeneratedFileCache}; the returned {@code /api/v1/files/
|
||||
* generated/<id>} URL can be embedded directly as {@code } in a
|
||||
* gzh_package Markdown body.
|
||||
*
|
||||
* <p>Security: only relative in-app paths ({@code /chat}, {@code /channels}, …)
|
||||
* are allowed — no scheme/host, so the tool cannot be aimed at arbitrary hosts
|
||||
* (no SSRF). The injected token is a normal user token scoped to whoever is
|
||||
* driving the conversation.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ScreenshotTool {
|
||||
|
||||
private static final String PNG_MIME = "image/png";
|
||||
private static final int DEFAULT_WIDTH = 1440;
|
||||
private static final int DEFAULT_HEIGHT = 900;
|
||||
private static final int NAV_TIMEOUT_MS = 20_000;
|
||||
private static final int DEFAULT_SETTLE_MS = 2500;
|
||||
private static final int MAX_SETTLE_MS = 8000;
|
||||
|
||||
private final GeneratedFileCache cache;
|
||||
private final AuthService authService;
|
||||
|
||||
@Value("${server.port:18088}")
|
||||
private int serverPort;
|
||||
|
||||
@Tool(name = "capture_screenshot", description = """
|
||||
Capture a screenshot of a MateClaw admin-console page and return an
|
||||
embeddable image URL. Use this to put REAL product screenshots into a
|
||||
how-to / tutorial article (e.g. steps of using 内容工作室).
|
||||
|
||||
`path` must be a relative in-app path starting with '/', e.g. '/chat',
|
||||
'/channels', '/agents', '/skills'. External URLs are rejected.
|
||||
|
||||
The returned URL is `/api/v1/files/generated/<id>` (image/png). Embed it
|
||||
in a gzh_package Markdown body as `` so the packaged article
|
||||
shows the real screenshot instead of a 【截图】placeholder.
|
||||
""")
|
||||
public String capture_screenshot(
|
||||
@ToolParam(description = "Relative in-app path, e.g. /chat, /channels, /agents, /skills")
|
||||
String path,
|
||||
@ToolParam(description = "Capture the full scrollable page (default false = just the viewport)", required = false)
|
||||
Boolean fullPage,
|
||||
@ToolParam(description = "Output filename without extension, e.g. 'step1-console'", required = false)
|
||||
String filename,
|
||||
@ToolParam(description = "Extra settle wait in ms after load for the SPA to render (default 2500, max 8000)", required = false)
|
||||
Integer waitMs,
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
if (path == null || path.isBlank()) {
|
||||
return "Error: path is required (a relative in-app path like /chat).";
|
||||
}
|
||||
String p = path.trim();
|
||||
if (p.contains("://") || p.startsWith("//") || !p.startsWith("/")) {
|
||||
return "Error: only relative in-app paths are allowed (must start with '/', no scheme/host). Got: " + path;
|
||||
}
|
||||
|
||||
String token = mintToken();
|
||||
if (token == null || token.isBlank()) {
|
||||
return "Error: could not mint an auth token to render the console (no resolvable user).";
|
||||
}
|
||||
|
||||
String url = "http://127.0.0.1:" + serverPort + p;
|
||||
int settle = waitMs == null ? DEFAULT_SETTLE_MS : Math.min(Math.max(waitMs, 0), MAX_SETTLE_MS);
|
||||
boolean full = fullPage != null && fullPage;
|
||||
String displayName = FilenameSanitizer.sanitize(filename, "screenshot", ".png") + ".png";
|
||||
|
||||
byte[] png;
|
||||
try {
|
||||
png = render(url, token, full, settle);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Screenshot] capture failed for {}: {}", p, e.getMessage());
|
||||
String hint = e.getMessage() != null && e.getMessage().contains("Executable doesn't exist")
|
||||
? " Hint: install the bundled browser (Playwright chromium)."
|
||||
: "";
|
||||
return "Error: screenshot failed — " + e.getMessage() + hint;
|
||||
}
|
||||
|
||||
log.info("[Screenshot] captured {} ({} bytes, fullPage={})", p, png.length, full);
|
||||
return GeneratedFileLink.resultZh(png, displayName, PNG_MIME, cache, "截图", ctx);
|
||||
}
|
||||
|
||||
private byte[] render(String url, String token, boolean fullPage, int settleMs) {
|
||||
try (Playwright pw = Playwright.create()) {
|
||||
BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions()
|
||||
.setHeadless(true)
|
||||
.setArgs(BrowserLauncher.chromiumLaunchArgs());
|
||||
Browser browser = pw.chromium().launch(opts);
|
||||
try {
|
||||
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
|
||||
.setViewportSize(DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
||||
.setDeviceScaleFactor(2.0)
|
||||
.setLocale("zh-CN"));
|
||||
// Seed the JWT before any app script runs so the SPA boots
|
||||
// authenticated. Playwright evaluates the init script as-is, so it
|
||||
// must be raw statements — a "() => {...}" arrow would only be
|
||||
// defined, never called, leaving localStorage untouched (and the
|
||||
// SPA would render the login page instead).
|
||||
context.addInitScript("try { window.localStorage.setItem('token', '"
|
||||
+ token + "'); } catch (e) {}");
|
||||
try {
|
||||
Page page = context.newPage();
|
||||
page.navigate(url, new Page.NavigateOptions()
|
||||
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED)
|
||||
.setTimeout(NAV_TIMEOUT_MS));
|
||||
// The console holds an SSE stream, so 'networkidle' can never
|
||||
// settle — use a fixed render delay instead.
|
||||
page.waitForTimeout(settleMs);
|
||||
return page.screenshot(new Page.ScreenshotOptions()
|
||||
.setFullPage(fullPage)
|
||||
.setType(ScreenshotType.PNG));
|
||||
} finally {
|
||||
try { context.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
} finally {
|
||||
try { browser.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint a token for the current user, falling back to the default admin. */
|
||||
@Nullable
|
||||
private String mintToken() {
|
||||
String username = currentUsername();
|
||||
String token = username != null ? authService.renewToken(username) : null;
|
||||
if (token == null) {
|
||||
token = authService.renewToken("admin");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String currentUsername() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated() && auth.getName() != null
|
||||
&& !"anonymousUser".equals(auth.getName())) {
|
||||
return auth.getName();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Async agent thread may have no security context — fall back to admin.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@ -235,6 +236,71 @@ public class GeneratedFileCache {
|
||||
return Optional.of(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort lookup of a live entry's id by its logical filename, optionally
|
||||
* constrained to a mime-type prefix (e.g. {@code "image/"}). Scans the
|
||||
* in-memory cache first — which covers the common case of a reference to a
|
||||
* file generated earlier in the same run — then persisted metadata on disk as
|
||||
* a durable fallback. Returns the first live match, or empty.
|
||||
*
|
||||
* <p>This exists to self-heal references that point at a file's logical
|
||||
* <em>name</em> ({@code cover_xyz.png}) rather than its issued id: such a
|
||||
* reference cannot be resolved by {@link #GENERATED_URL_PATTERN} (the id
|
||||
* capture group stops at the first {@code _} or {@code .}), so a name-based
|
||||
* fallback recovers the real, servable id instead of yielding a broken link.
|
||||
*/
|
||||
public Optional<String> findIdByFilename(String filename, @Nullable String mimePrefix) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String target = filename.trim();
|
||||
// 1. In-memory — the fresh-same-run case, and cheap.
|
||||
synchronized (entries) {
|
||||
for (Map.Entry<String, Entry> e : entries.entrySet()) {
|
||||
Entry v = e.getValue();
|
||||
if (!v.expired() && target.equalsIgnoreCase(v.filename())
|
||||
&& (mimePrefix == null
|
||||
|| (v.mimeType() != null && v.mimeType().startsWith(mimePrefix)))) {
|
||||
return Optional.of(e.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Disk metas — durable fallback (survives memory eviction / restart).
|
||||
if (!Files.isDirectory(storageDir)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Path> metas;
|
||||
try (Stream<Path> files = Files.list(storageDir)) {
|
||||
metas = files.filter(p -> p.getFileName().toString().endsWith(META_SUFFIX)).toList();
|
||||
} catch (IOException e) {
|
||||
log.debug("findIdByFilename disk scan failed: {}", e.toString());
|
||||
return Optional.empty();
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
for (Path metaPath : metas) {
|
||||
try {
|
||||
String[] parts = Files.readString(metaPath).split("\t", 3);
|
||||
if (Long.parseLong(parts[0].trim()) <= now) {
|
||||
continue;
|
||||
}
|
||||
String mime = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null;
|
||||
if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) {
|
||||
continue;
|
||||
}
|
||||
String fn = parts.length > 2 && !parts[2].isEmpty()
|
||||
? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8)
|
||||
: null;
|
||||
if (fn != null && target.equalsIgnoreCase(fn)) {
|
||||
String name = metaPath.getFileName().toString();
|
||||
return Optional.of(name.substring(0, name.length() - META_SUFFIX.length()));
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// Skip unreadable / malformed meta.
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private void persist(String id, Entry entry) {
|
||||
if (entry.bytes() == null) {
|
||||
return;
|
||||
|
||||
@ -1945,3 +1945,8 @@ VALUES (1000100021, 'Weekly 公众号 Draft', '0 9 * * 1', 'Asia/Shanghai', 1000
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as  in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
@ -1870,3 +1870,8 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as  in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
@ -1867,3 +1867,8 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(Markdown)打包成在线预览(渲染 HTML)+ 素材下载 zip(article.html/article.md/封面)。服务端生成内联样式 HTML,避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
@ -1986,3 +1986,8 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expressio
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', 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);
|
||||
|
||||
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as  in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', 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);
|
||||
|
||||
@ -1983,3 +1983,8 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expressio
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(Markdown)打包成在线预览(渲染 HTML)+ 素材下载 zip(article.html/article.md/封面)。服务端生成内联样式 HTML,避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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);
|
||||
|
||||
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', 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);
|
||||
|
||||
@ -1946,3 +1946,8 @@ VALUES (1000100021, '每周公众号入草稿箱', '0 9 * * 1', 'Asia/Shanghai',
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(Markdown)打包成在线预览(渲染 HTML)+ 素材下载 zip(article.html/article.md/封面)。服务端生成内联样式 HTML,避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
-- V168: Content Studio scenario seed (公众号 / 小红书 图文创作).
|
||||
-- Delivers to EXISTING databases the rows fresh installs get from db/data-*.sql:
|
||||
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package),
|
||||
-- the 内容工作室 (Content Studio) agent, and two disabled cron templates.
|
||||
-- DatabaseBootstrapRunner skips seeding once a database is initialized, so these
|
||||
-- would otherwise never reach upgraders. Idempotent upsert on id; content is the
|
||||
-- default (zh-CN) locale — a fresh install re-runs the locale seed afterwards.
|
||||
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
|
||||
-- capture_screenshot), the 内容工作室 (Content Studio) agent, and two disabled
|
||||
-- cron templates. DatabaseBootstrapRunner skips seeding once a database is
|
||||
-- initialized, so these would otherwise never reach upgraders. Idempotent upsert
|
||||
-- on id; content is the default (zh-CN) locale — a fresh install re-runs the
|
||||
-- locale seed afterwards.
|
||||
|
||||
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
@ -23,6 +24,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
|
||||
KEY (id)
|
||||
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(Markdown)打包成在线预览(渲染 HTML)+ 素材下载 zip(article.html/article.md/封面)。服务端生成内联样式 HTML,避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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 (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
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 (1000000640, '内容工作室', '端到端创作公众号与小红书图文:选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
-- V168: Content Studio scenario seed (公众号 / 小红书 图文创作).
|
||||
-- Delivers to EXISTING databases the rows fresh installs get from db/data-*.sql:
|
||||
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package),
|
||||
-- the 内容工作室 (Content Studio) agent, and two disabled cron templates.
|
||||
-- DatabaseBootstrapRunner skips seeding once a database is initialized, so these
|
||||
-- would otherwise never reach upgraders. Idempotent upsert on id; content is the
|
||||
-- default (zh-CN) locale — a fresh install re-runs the locale seed afterwards.
|
||||
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
|
||||
-- capture_screenshot), the 内容工作室 (Content Studio) agent, and two disabled
|
||||
-- cron templates. DatabaseBootstrapRunner skips seeding once a database is
|
||||
-- initialized, so these would otherwise never reach upgraders. Idempotent upsert
|
||||
-- on id; content is the default (zh-CN) locale — a fresh install re-runs the
|
||||
-- locale seed afterwards.
|
||||
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
@ -23,6 +24,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
|
||||
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(Markdown)打包成在线预览(渲染 HTML)+ 素材下载 zip(article.html/article.md/封面)。服务端生成内联样式 HTML,避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文:选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
-- V168: Content Studio scenario seed (公众号 / 小红书 图文创作).
|
||||
-- Delivers to EXISTING databases the rows fresh installs get from db/data-*.sql:
|
||||
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package),
|
||||
-- the 内容工作室 (Content Studio) agent, and two disabled cron templates.
|
||||
-- DatabaseBootstrapRunner skips seeding once a database is initialized, so these
|
||||
-- would otherwise never reach upgraders. Idempotent upsert on id; content is the
|
||||
-- default (zh-CN) locale — a fresh install re-runs the locale seed afterwards.
|
||||
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
|
||||
-- capture_screenshot), the 内容工作室 (Content Studio) agent, and two disabled
|
||||
-- cron templates. DatabaseBootstrapRunner skips seeding once a database is
|
||||
-- initialized, so these would otherwise never reach upgraders. Idempotent upsert
|
||||
-- on id; content is the default (zh-CN) locale — a fresh install re-runs the
|
||||
-- locale seed afterwards.
|
||||
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
@ -23,6 +24,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
|
||||
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(Markdown)打包成在线预览(渲染 HTML)+ 素材下载 zip(article.html/article.md/封面)。服务端生成内联样式 HTML,避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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);
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', 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);
|
||||
|
||||
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文:选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gzh_article
|
||||
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端:选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
tags:
|
||||
- 公众号
|
||||
- 图文
|
||||
@ -18,6 +18,8 @@ platforms:
|
||||
|
||||
把一个选题做成可直接粘进公众号编辑器的图文推文。7 个阶段,每步都接到平台真实工具上。
|
||||
|
||||
> 📌 **动笔前务必先读 `references/gzh_platform_rules.md`** —— 那是微信公众号的真实平台规矩(封面尺寸、标题/摘要、编辑器排版、诱导分享/关注红线、群发频次与时机、原创机制),懂了这些才叫"会做公众号",而不只是"会写字"。下面 SOP 的成文/配图/自查各步都以它为准。
|
||||
|
||||
## 开工前:读取共享人设记忆
|
||||
|
||||
先用 `recall_structured` 取回并全程遵守:
|
||||
@ -57,11 +59,16 @@ platforms:
|
||||
|
||||
全程遵守 `writing_style_gzh` + `content_persona`。
|
||||
|
||||
**同时定好标题和摘要**(打开率的命门,见 `references/gzh_platform_rules.md`):
|
||||
- **标题**:≤30 字为佳(后台上限 64),带具体信息 / 数字 / 悬念 / 情绪;**与正文一致**,不做标题党、不用"震惊体"、别满屏感叹号(会被限流)。给 2–3 个候选让用户挑。
|
||||
- **摘要(digest)**:**手写 ≤120 字**,补一句标题没说完的钩子(不填会被系统乱截正文前 54 字)。交付时作为 `gzh_publish` 的 `digest` 传入。
|
||||
|
||||
### 4. 配图
|
||||
|
||||
用 `image_generate`(`action=generate`):
|
||||
|
||||
- **封面头图**:`aspectRatio=landscape`。prompt 里写清主题、风格、留白、中文标题可读。
|
||||
- **封面头图**:`aspectRatio=landscape`(公众号头图是 **2.35:1**,约 900×383 / 1080×460)。prompt 写清主题、风格、留白;**封面文字要大而少**(缩略图很小,一行主标题足矣)。
|
||||
- **分享/朋友圈封面**:如需单独出,用 `aspectRatio=square`(**1:1**,≥500×500)。
|
||||
- **关键小节配图**:按需为 2–3 个重点小节各生成一张,风格与封面统一。
|
||||
|
||||
`image_generate` 只认 `landscape` / `portrait` / `square` 三种比例,其它比例映射到最近的一个(如 3:4 → portrait)。
|
||||
@ -70,9 +77,13 @@ platforms:
|
||||
|
||||
`load_skill deai_humanize`,然后对全文跑它的"打分→改写→复检"循环,`platform=gzh`,目标 `score ≤ 55`。
|
||||
|
||||
### 6. 违禁词自查
|
||||
### 6. 违禁词 + 平台规则自查
|
||||
|
||||
对照 `banned_words` 和 `references/compliance_checklist.md`(广告法极限词、虚假宣传、敏感词、侵权风险),命中就**标注并给出替换建议**,不要静默通过。
|
||||
对照 `banned_words` 和 `references/compliance_checklist.md`,命中就**标注并给出替换建议**,不要静默通过。两类都要扫:
|
||||
- **广告法**:极限词(最/第一/唯一/国家级/100%)、虚假功效、承诺收益、敏感内容、侵权。
|
||||
- **微信平台红线**(比广告法更容易封号,见 `references/gzh_platform_rules.md` 第 5 节):**诱导分享**(集赞/助力/分享解锁)、**诱导关注**(关注才能看全文)、**违规外链/二维码**、标题党。引导互动只用话术、不用利诱。
|
||||
|
||||
**产品教程/操作类文章配真实截图**:写"如何用 MateClaw 做 XX"这类教程时,用 `capture_screenshot(path)` 截真实后台界面(`path` 为站内相对路径,如 `/chat`、`/channels`、`/agents`、`/skills`),把返回的图片 URL 以 `` **直接嵌进对应步骤的 Markdown**,替代【截图】占位;再整体交给 `gzh_package`。这样成品里是真实产品截图,不用手动补图。
|
||||
|
||||
### 7. 打包交付(gzh_package —— 在线预览 + 素材下载)
|
||||
|
||||
@ -107,4 +118,5 @@ gzh_package(title="<标题>", markdown="<正文 Markdown,含小标题/列表/
|
||||
- `references/gzh_layout_minimal.html` — 极简编辑风模板。
|
||||
- `references/gzh_layout_business.html` — 商务专业风模板。
|
||||
- `references/gzh_structure.md` — 文章结构 + 钩子 / 金句方法论。
|
||||
- `references/compliance_checklist.md` — 合规自查清单(广告法禁用词、敏感词、侵权、极限词)。
|
||||
- `references/gzh_platform_rules.md` — **微信公众号平台规范与实操**(封面尺寸、标题/摘要规则、编辑器排版、诱导红线、群发频次与时机、原创机制、敏感行业)。写公众号务读。
|
||||
- `references/compliance_checklist.md` — 合规自查清单(广告法禁用词 + 微信诱导红线)。
|
||||
|
||||
@ -33,6 +33,15 @@
|
||||
- **文字**:本文为原创;参考文章只用于找角度并在文末标注来源,**不逐段搬运洗稿**。
|
||||
- **商标 / 品牌**:提及第三方品牌时客观陈述,不贬损、不假冒关联。
|
||||
|
||||
## 5. 微信平台规则(诱导 / 外链红线,比广告法更容易封号)
|
||||
|
||||
这类属于**微信运营规范**,不是广告法——违规轻则限流删文,重则扣原创分 / 封号。详见 `gzh_platform_rules.md` 第 5 节。发文前重点扫:
|
||||
|
||||
- **诱导分享**:`分享到朋友圈领取` / `集赞送` / `助力` / `分享解锁全文` / `不转不是XX` → 一律删除。
|
||||
- **诱导关注**:`关注才能看全文` / `关注领资料` 式的强制/利诱 → 删除(正常"欢迎关注"可留)。
|
||||
- **违规外链 / 二维码**:正文直接放淘宝、外部下载等外链 → 删除(合规外链走文末"阅读原文")。
|
||||
- **引导互动**:只允许话术(`欢迎点赞、在看、转发`),不允许利诱(`转发抽奖`)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
自查后给用户一份小结:
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
# 微信公众号平台规范与实操要点
|
||||
|
||||
写公众号图文时,除了"写得好",还要"合平台规矩、贴移动端阅读、能被打开"。以下是真实运营里绕不开的硬约束和经验,成文/配图/排版/交付时逐条对照。
|
||||
|
||||
## 1. 封面图规范(尺寸是硬要求)
|
||||
|
||||
- **头条大图 / 顶部封面**:比例 **2.35 : 1**,建议 **900×383** 或 **1080×460** px。这是文章顶部那张大图。
|
||||
- **分享小图 / 朋友圈封面 / 次条图**:比例 **1 : 1**(正方形),建议 **≥ 500×500** px。这是分享到聊天、朋友圈时显示的缩略图。
|
||||
- 封面上的**文字要大、要少**——缩略图在手机上很小,一行主标题 + 一句副标题足矣,别塞满字。
|
||||
- 用 `image_generate` 出头图时 `aspectRatio=landscape`(近似 2.35:1);`gzh_package` 会把封面放进文章头部。
|
||||
|
||||
## 2. 标题(打开率的命门)
|
||||
|
||||
- 后台标题上限 **64 字**,但**强烈建议精炼**(15–30 字最佳)。订阅号在"订阅号消息"里是折叠列表,**读者先看到的往往只有标题**。
|
||||
- 好标题带**具体信息 / 数字 / 悬念 / 利益点 / 情绪**,标题和正文**必须一致**——标题党、夸大、"震惊体""速看体"、满屏感叹号会被平台**限流**。
|
||||
- 反例:`震惊!!!99% 的人都不知道的秘密`。正例:`我用这 3 个方法,把每天的碎片时间省出了 2 小时`。
|
||||
|
||||
## 3. 摘要(digest,别浪费)
|
||||
|
||||
- 摘要上限 **120 字**;**不填会自动截取正文前 ~54 字**(常常截得很尴尬)。
|
||||
- 摘要显示在**分享卡片、部分历史入口**。**手动写摘要**,用它补一句标题没说完的钩子,别让系统乱截。
|
||||
- `gzh_publish` 的 `digest` 参数就是它;不传会自动从正文派生。
|
||||
|
||||
## 4. 正文排版(微信编辑器习惯)
|
||||
|
||||
- **字号**:正文 15px(最常用)或 16px;小标题 16–17px 加粗。
|
||||
- **行间距** 1.75–2.0;**段间距**要留白。公众号习惯**首行不缩进**,靠段间距分段。
|
||||
- **字色**:正文用 `#3f3f3f` / `#333`(不用纯黑 `#000`,太硬);重点色**不超过 2–3 种**(品牌色 + 黄底标重点足够)。
|
||||
- **图片**宽度自适应(100%);图注用小字、灰色、居中。
|
||||
- **每段 3–4 行以内,多用短句**——手机一屏能读完一个意群。首屏(首图 + 前 3–4 行)决定读者跳不跳出。
|
||||
- `gzh_package` 已内置这套内联样式;手写 HTML 时对齐这些数值。
|
||||
|
||||
## 5. 诱导红线(微信平台规则,**比广告法更容易封号**)
|
||||
|
||||
这是真实运营最常踩的坑,和《广告法》是两回事——属于**微信运营规范**,违规会**限流、删文、扣原创分、封号**:
|
||||
|
||||
- **诱导分享**(严禁):用利益或胁迫诱导转发,如"**分享到朋友圈截图领取**""**集赞 XX 个送**""**为 XX 助力**""**分享后解锁全文**""**不转不是 XX**"。
|
||||
- **诱导关注**(严禁):**强制/利诱**才能看全文或领资料,如"**关注公众号才能看后续**""**关注领取完整版**"。**正常引导关注可以**("觉得有用就关注一下"),**强制/利诱不行**。
|
||||
- **外链 / 二维码跳转**:正文**不能直接放外部网址**跳到淘宝、外部下载、非白名单站点;诱导"长按识别二维码加个人微信/关注他人号"批量导流有风险。合规外链走文末"阅读原文"(白名单或公众号文章链接)。
|
||||
- **标题党 / 内容不符**:标题夸大、与正文不符 → 限流。
|
||||
- **抄袭 / 洗稿**:被原创方投诉 → 删文 + 扣信用分。
|
||||
|
||||
> 引导互动要用**话术**而非**利诱**:`觉得有帮助,欢迎点赞、在看、转发,也欢迎关注` ✅;`转发集赞 20 个抽奖` ❌。
|
||||
|
||||
## 6. 发布频次与时机
|
||||
|
||||
- **群发频次**:订阅号**每天 1 次**(一次可含多条图文合并推送);认证服务号**每月 4 次**。
|
||||
- **高打开时段**(视受众调整):早 **7:00–9:00**(通勤)、午 **12:00–13:30**、晚 **20:00–22:30**(黄金)、睡前 **22:30–23:30**。上班族偏早晚通勤,学生/宝妈偏午间和晚间。
|
||||
- **固定节奏**(如每周二四六早 8 点)有助于养成读者打开习惯。
|
||||
- 定时推送可用「每周公众号入草稿箱」这类 Cron 模板生成,人工核对后发。
|
||||
|
||||
## 7. 原创与互动机制
|
||||
|
||||
- **声明原创**(账号需已开通原创功能):可获**原创标、留言、赞赏、被转载保留出处**。原创内容平台推荐权重更高。
|
||||
- 文末**引导互动**:点赞 / 在看 / 留言 / 转发 / 关注——用真诚话术。**主动引导留言并精选**能提升互动权重。
|
||||
- **搜一搜 / SEO**:标题、正文前 100 字合理布局关键词,有助于被"搜一搜"收录,带来长尾流量。
|
||||
|
||||
## 8. 敏感行业额外合规
|
||||
|
||||
- **医疗 / 保健 / 药品 / 保健品**:不得宣称疗效、治愈、抗癌等(见 `compliance_checklist.md`),且多需资质。
|
||||
- **金融 / 投资 / 理财**:不得承诺收益、保本、稳赚。
|
||||
- **教育 / 培训**:不得承诺"包过""保分""名校保录"。
|
||||
- 这些行业发文前务必让用户确认资质与措辞。
|
||||
|
||||
## 交付前自检(把上面浓缩成一句话清单)
|
||||
|
||||
- [ ] 封面 2.35:1(头图)/ 1:1(分享图),文字大而少
|
||||
- [ ] 标题 ≤30 字、不标题党、与正文一致
|
||||
- [ ] 手动写了 ≤120 字摘要
|
||||
- [ ] 段落 3–4 行、短句、重点色 ≤3 种
|
||||
- [ ] 无诱导分享 / 诱导关注 / 违规外链
|
||||
- [ ] 引导互动用话术不用利诱
|
||||
- [ ] 敏感行业措辞合规
|
||||
@ -0,0 +1,99 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Reproduce and pin the fix for the broken-cover bug: when the model references
|
||||
* the cover by its logical filename ({@code cover_xyz.png}) instead of the
|
||||
* issued id, the URL id-pattern can't parse it, so the old tool embedded the raw
|
||||
* (non-serving) reference and the preview showed a broken image. The packager
|
||||
* must now (a) self-heal a name-based reference to the real generated image, and
|
||||
* (b) when a cover genuinely can't be resolved, drop it and warn rather than ship
|
||||
* a broken {@code <img>}.
|
||||
*/
|
||||
class GzhPackageCoverHealingTest {
|
||||
|
||||
private GeneratedFileCache cache;
|
||||
private GzhPackageTool tool;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path tempDir) {
|
||||
cache = new GeneratedFileCache(tempDir);
|
||||
tool = new GzhPackageTool(cache);
|
||||
}
|
||||
|
||||
private static final String BODY = "## 小节一\n\n正文一段。\n\n## 小节二\n\n又一段。";
|
||||
|
||||
@Test
|
||||
@DisplayName("name-based cover reference self-heals to the real generated image")
|
||||
void nameBasedReferenceHeals() {
|
||||
String id = cache.put("PNGBYTES".getBytes(), "cover_cat_7pits.png", "image/png");
|
||||
// The model echoed the cover by filename — the id pattern stops at the '_'.
|
||||
String out = tool.gzh_package(
|
||||
"养猫第一年烧掉3万块",
|
||||
BODY,
|
||||
"/api/v1/files/generated/cover_cat_7pits.png",
|
||||
"内容工作室",
|
||||
null);
|
||||
|
||||
assertTrue(out.contains("/api/v1/files/generated/" + id),
|
||||
"cover should be healed to the real generated id; got:\n" + out);
|
||||
assertFalse(out.contains("generated/cover_cat_7pits.png"),
|
||||
"the broken name-based reference must not be embedded");
|
||||
assertTrue(out.contains("<img "), "a cover image must be present");
|
||||
assertFalse(out.contains("⚠️"), "a healed cover must not warn");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unresolvable cover → dropped with a warning, never a broken <img>")
|
||||
void unresolvableCoverDroppedAndWarned() {
|
||||
String out = tool.gzh_package(
|
||||
"标题",
|
||||
BODY,
|
||||
"/api/v1/files/generated/deadbeef-0000-0000-0000-000000000000",
|
||||
"内容工作室",
|
||||
null);
|
||||
|
||||
assertTrue(out.contains("⚠️"), "an unresolved cover must be flagged; got:\n" + out);
|
||||
assertFalse(out.contains("<img "), "no broken cover image may be embedded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a correct generated-id image reference is embedded as-is, no warning")
|
||||
void correctIdReferenceEmbedded() {
|
||||
String id = cache.put("PNGBYTES".getBytes(), "gzh-cover.png", "image/png");
|
||||
String out = tool.gzh_package(
|
||||
"标题",
|
||||
BODY,
|
||||
"/api/v1/files/generated/" + id,
|
||||
"内容工作室",
|
||||
null);
|
||||
|
||||
assertTrue(out.contains("/api/v1/files/generated/" + id), "the cover id must be embedded");
|
||||
assertTrue(out.contains("<img "), "a cover image must be present");
|
||||
assertFalse(out.contains("⚠️"), "a resolved cover must not warn");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-image generated file referenced as cover is not embedded")
|
||||
void nonImageReferenceNotEmbedded() {
|
||||
String id = cache.put("%PDF".getBytes(), "handout.pdf", "application/pdf");
|
||||
String out = tool.gzh_package(
|
||||
"标题",
|
||||
BODY,
|
||||
"/api/v1/files/generated/" + id,
|
||||
"内容工作室",
|
||||
null);
|
||||
|
||||
assertTrue(out.contains("⚠️"), "a non-image cover must be flagged");
|
||||
assertFalse(out.contains("<img "), "a non-image must not be embedded as a cover");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package vip.mate.tool.document;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin {@link GeneratedFileCache#findIdByFilename}: recover a file's issued id
|
||||
* from its logical filename, so a reference that points at the name instead of
|
||||
* the id (which {@code GENERATED_URL_PATTERN} cannot parse) can still be
|
||||
* resolved. Covers the in-memory hit, the disk fallback, the mime-prefix
|
||||
* filter, case-insensitivity, and the misses.
|
||||
*/
|
||||
class GeneratedFileCacheFindByFilenameTest {
|
||||
|
||||
private Path dir;
|
||||
private GeneratedFileCache cache;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path tempDir) {
|
||||
dir = tempDir;
|
||||
cache = new GeneratedFileCache(tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-memory: filename + image mime → the issued id")
|
||||
void memoryHit() {
|
||||
String id = cache.put("PNG".getBytes(), "cover_cat_7pits.png", "image/png");
|
||||
assertEquals(Optional.of(id), cache.findIdByFilename("cover_cat_7pits.png", "image/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filename match is case-insensitive")
|
||||
void caseInsensitive() {
|
||||
String id = cache.put("PNG".getBytes(), "Cover_Cat.PNG", "image/png");
|
||||
assertEquals(Optional.of(id), cache.findIdByFilename("cover_cat.png", "image/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mime prefix excludes a non-image with the same name")
|
||||
void mimePrefixExcludesNonImage() {
|
||||
cache.put("PDF".getBytes(), "cover.pdf", "application/pdf");
|
||||
assertTrue(cache.findIdByFilename("cover.pdf", "image/").isEmpty());
|
||||
// Without the constraint it is found.
|
||||
assertTrue(cache.findIdByFilename("cover.pdf", null).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disk fallback: a fresh cache with empty memory finds it via persisted meta")
|
||||
void diskFallback() {
|
||||
String id = cache.put("PNG".getBytes(), "gzh-cover.png", "image/png");
|
||||
// A brand-new instance over the same dir has nothing in memory yet.
|
||||
GeneratedFileCache reopened = new GeneratedFileCache(dir);
|
||||
assertEquals(Optional.of(id), reopened.findIdByFilename("gzh-cover.png", "image/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown filename and null/blank input → empty")
|
||||
void misses() {
|
||||
cache.put("PNG".getBytes(), "cover.png", "image/png");
|
||||
assertTrue(cache.findIdByFilename("nope.png", "image/").isEmpty());
|
||||
assertTrue(cache.findIdByFilename(null, "image/").isEmpty());
|
||||
assertTrue(cache.findIdByFilename(" ", "image/").isEmpty());
|
||||
}
|
||||
}
|
||||
@ -1013,6 +1013,8 @@ export default {
|
||||
searchProvider: 'Search Provider',
|
||||
searchFallbackEnabled: 'Fallback on Failure',
|
||||
serperApiKey: 'Serper API Key',
|
||||
weixinoaAppId: 'Official Account AppID',
|
||||
weixinoaAppSecret: 'Official Account AppSecret',
|
||||
serperBaseUrl: 'Serper Base URL',
|
||||
tavilyApiKey: 'Tavily API Key',
|
||||
tavilyBaseUrl: 'Tavily Base URL',
|
||||
@ -1062,6 +1064,8 @@ export default {
|
||||
searchProvider: 'Primary search provider used when the search tool is invoked.',
|
||||
searchFallbackEnabled: 'Automatically try the other provider when the primary one fails.',
|
||||
serperApiKey: 'API key for Google Serper search, get it from serper.dev.',
|
||||
weixinoaAppId: 'AppID from the Official Account admin (Development → Basic Config).',
|
||||
weixinoaAppSecret: 'AppSecret (write-only, never echoed). Once set, gzh_publish can push articles into the draft box. Note: the calling server\'s public IP must be whitelisted in the Official Account admin, and a verified account is required; localhost usually cannot reach the WeChat API.',
|
||||
serperBaseUrl: 'Usually no need to change unless using a custom proxy.',
|
||||
tavilyApiKey: 'API key for Tavily search, get it from tavily.com.',
|
||||
tavilyBaseUrl: 'Usually no need to change unless using a custom proxy.',
|
||||
@ -1116,6 +1120,8 @@ export default {
|
||||
minimaxApiKey: 'Get from minimaxi.com. Hailuo video with free quota, excellent for Chinese scenes.',
|
||||
minimaxRegion: 'Choose the API host. Mainland China accounts must use the CN endpoint; others use Global.',
|
||||
},
|
||||
weixinoaTitle: 'Official Account Publishing',
|
||||
weixinoaDesc: 'Configure the WeChat Official Account AppID / AppSecret so Content Studio can push articles into the draft box via gzh_publish (requires a verified account + the server IP whitelisted).',
|
||||
searchTitle: 'Search Service',
|
||||
searchDesc: 'Configure the built-in search tool provider and API credentials',
|
||||
searchCatalogError: 'Failed to load the search provider catalog — showing built-in provider configuration only; plugin providers and status info are unavailable.',
|
||||
|
||||
@ -876,6 +876,8 @@ export default {
|
||||
searchFallbackEnabled: '失败回退',
|
||||
serperApiKey: 'Serper API Key',
|
||||
serperBaseUrl: 'Serper 接口地址',
|
||||
weixinoaAppId: '公众号 AppID',
|
||||
weixinoaAppSecret: '公众号 AppSecret',
|
||||
tavilyApiKey: 'Tavily API Key',
|
||||
tavilyBaseUrl: 'Tavily 接口地址',
|
||||
duckduckgoEnabled: 'DuckDuckGo(免 Key)',
|
||||
@ -931,6 +933,8 @@ export default {
|
||||
searchFallbackEnabled: '主提供商调用失败时,自动回退到另一个提供商。',
|
||||
serperApiKey: '用于 Google Serper 搜索服务,从 serper.dev 获取。',
|
||||
serperBaseUrl: '通常无需修改,除非使用自定义代理地址。',
|
||||
weixinoaAppId: '公众号后台「开发 → 基本配置」里的 AppID。',
|
||||
weixinoaAppSecret: 'AppSecret(保密,仅输入不回显)。配好后 gzh_publish 可将图文推进草稿箱。注意:调用微信接口的服务器公网 IP 需加入公众号后台的 IP 白名单,且需认证服务号/订阅号;localhost 通常无法直连微信接口。',
|
||||
tavilyApiKey: '用于 Tavily 搜索服务,从 tavily.com 获取。',
|
||||
tavilyBaseUrl: '通常无需修改,除非使用自定义代理地址。',
|
||||
duckduckgoEnabled: '免费搜索兜底,无需 API Key。默认开启,作为零配置下的搜索降级方案。',
|
||||
@ -990,6 +994,8 @@ export default {
|
||||
minimaxApiKey: '从 minimaxi.com 获取。海螺 Hailuo 视频,有免费额度,中文场景优秀。',
|
||||
minimaxRegion: '选择 API 入口域名。中国账号用 CN,海外账号用 Global。',
|
||||
},
|
||||
weixinoaTitle: '公众号发布',
|
||||
weixinoaDesc: '配置微信公众号 AppID / AppSecret,内容工作室即可用 gzh_publish 把图文推进公众号草稿箱(需认证号 + 服务器公网 IP 加白名单)。',
|
||||
searchTitle: '搜索服务',
|
||||
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
|
||||
searchCatalogError: '无法加载搜索提供商目录,仅显示内置提供商配置;插件提供商与状态信息暂不可用。',
|
||||
|
||||
@ -818,6 +818,9 @@ export interface SystemSettings {
|
||||
tavilyBaseUrl: string
|
||||
serperApiKeyMasked?: string
|
||||
tavilyApiKeyMasked?: string
|
||||
weixinoaAppId?: string
|
||||
weixinoaAppSecret?: string
|
||||
weixinoaAppSecretMasked?: string
|
||||
// Keyless 搜索 provider
|
||||
duckduckgoEnabled: boolean
|
||||
searxngBaseUrl: string
|
||||
|
||||
@ -229,6 +229,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section system-section">
|
||||
<h2 class="section-title">{{ t('settings.weixinoaTitle') }}</h2>
|
||||
<p class="section-desc">{{ t('settings.weixinoaDesc') }}</p>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item setting-item-vertical">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.fields.weixinoaAppId') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.hints.weixinoaAppId') }}</div>
|
||||
</div>
|
||||
<div class="setting-control setting-control-full">
|
||||
<input
|
||||
v-model="settings.weixinoaAppId"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="wx1234567890abcdef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item setting-item-vertical">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.fields.weixinoaAppSecret') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.hints.weixinoaAppSecret') }}</div>
|
||||
</div>
|
||||
<div class="setting-control setting-control-full">
|
||||
<input
|
||||
v-model="weixinoaAppSecretInput"
|
||||
type="password"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
:placeholder="settings.weixinoaAppSecretMasked || t('settings.model.apiKeyInput')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<button class="btn-secondary" @click="loadSettings">{{ t('common.reset') }}</button>
|
||||
<button class="btn-primary" @click="onSaveSettings">{{ t('settings.actions.saveSystem') }}</button>
|
||||
@ -254,6 +290,7 @@ const savedTip = ref('')
|
||||
// API Key 独立管理,不回显明文
|
||||
const serperApiKeyInput = ref('')
|
||||
const tavilyApiKeyInput = ref('')
|
||||
const weixinoaAppSecretInput = ref('')
|
||||
|
||||
const providerCatalog = ref<SearchProviderCatalog>({ providers: [], resolvedId: null, resolvedSource: null })
|
||||
const expandedProviderId = ref<string | null>(null)
|
||||
@ -299,6 +336,7 @@ const settings = reactive<SystemSettings>({
|
||||
tavilyBaseUrl: 'https://api.tavily.com/search',
|
||||
duckduckgoEnabled: true,
|
||||
searxngBaseUrl: '',
|
||||
weixinoaAppId: '',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@ -313,6 +351,7 @@ async function loadSettings() {
|
||||
// 清空 API Key 输入框(不回显明文)
|
||||
serperApiKeyInput.value = ''
|
||||
tavilyApiKeyInput.value = ''
|
||||
weixinoaAppSecretInput.value = ''
|
||||
}
|
||||
|
||||
async function onSaveSettings() {
|
||||
@ -324,6 +363,9 @@ async function onSaveSettings() {
|
||||
if (tavilyApiKeyInput.value) {
|
||||
payload.tavilyApiKey = tavilyApiKeyInput.value
|
||||
}
|
||||
if (weixinoaAppSecretInput.value) {
|
||||
payload.weixinoaAppSecret = weixinoaAppSecretInput.value
|
||||
}
|
||||
await settingsApi.update(payload)
|
||||
await applyLocale(settings.language)
|
||||
// 重新加载以获取最新脱敏值
|
||||
|
||||
Loading…
Reference in New Issue
Block a user