feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)

Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Executable → Regular
+51
View File
@@ -47,6 +47,7 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.2",
"imapflow": "^2.9.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
@@ -460,6 +461,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.4", "https://registry.npmjs.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@prisma/client": ["@prisma/client@6.19.2", "https://registry.npmjs.com/@prisma/client/-/client-6.19.2.tgz", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg=="],
"@prisma/config": ["@prisma/config@6.19.2", "https://registry.npmjs.com/@prisma/config/-/config-6.19.2.tgz", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="],
@@ -776,6 +779,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmjs.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.29", "", { "dependencies": { "libbase64": "1.4.1", "libmime": "5.6.3", "libqp": "3.0.1" } }, "sha512-CCUNycmUjrV5M0wNf23waWAt9eOHczhjLbUQS7k0FNJvR0py9VSqHFt+tSl/D0+XI73VLG0U+PwYtq9R0zsm9Q=="],
"acorn": ["acorn@8.15.0", "https://registry.npmjs.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -812,6 +817,8 @@
"async-function": ["async-function@1.0.0", "https://registry.npmjs.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmjs.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.11.1", "https://registry.npmjs.com/axe-core/-/axe-core-4.11.1.tgz", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
@@ -994,6 +1001,8 @@
"empathic": ["empathic@2.0.0", "https://registry.npmjs.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"encoding-japanese": ["encoding-japanese@2.4.0", "", {}, "sha512-PE0KjlsDy9JSsoU4C2Q8NXb1kC4c+bZRac9ZvHXUE4q1Ht63gScKMdEy9jst9+Fi3M4eDkqzkEhBZvix4blP2A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmjs.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-abstract": ["es-abstract@1.24.1", "https://registry.npmjs.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@@ -1170,10 +1179,14 @@
"html-url-attributes": ["html-url-attributes@3.0.1", "https://registry.npmjs.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"ieee754": ["ieee754@1.2.1", "https://registry.npmjs.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "https://registry.npmjs.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imapflow": ["imapflow@2.9.1", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.29", "encoding-japanese": "2.4.0", "iconv-lite": "0.7.3", "libbase64": "1.4.1", "libmime": "5.6.3", "libqp": "3.0.1", "pino": "10.3.1", "socks": "2.8.10" } }, "sha512-hOq2/+T+WnQzRy7D40gYlio25nG+c9bhtrxowokOQM57SwZFqRbqAtxFQcIpx82lhYBPQEEVVjmkYpd9Fplj7g=="],
"immer": ["immer@10.2.0", "https://registry.npmjs.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "https://registry.npmjs.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -1192,6 +1205,8 @@
"intl-messageformat": ["intl-messageformat@10.7.18", "https://registry.npmjs.com/intl-messageformat/-/intl-messageformat-10.7.18.tgz", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
"ip-address": ["ip-address@10.7.5", "", {}, "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmjs.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmjs.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
@@ -1298,6 +1313,12 @@
"lib0": ["lib0@0.2.117", "https://registry.npmjs.com/lib0/-/lib0-0.2.117.tgz", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"libbase64": ["libbase64@1.4.1", "", {}, "sha512-Nyh3H90B3gRtrcxIe56Ayy7635J0jmh6AbxbQgQae8wrSPQMH5wFqJChiMTB13yKG512L3YspbA5DzumhFTITA=="],
"libmime": ["libmime@5.6.3", "", { "dependencies": { "encoding-japanese": "2.4.0", "iconv-lite": "0.7.3", "libbase64": "1.4.1", "libqp": "3.0.1" } }, "sha512-cuTUW/Jybvh5O4/U9p61gyutHOjDtZyGQYuY99+bfnaFwqcDf2Nb+R6hMHY6DDen2H9wLKQSMpofWUgn2TI5JQ=="],
"libqp": ["libqp@3.0.1", "", {}, "sha512-7QZxpPX25pga1WiMueduWxmrHMVBFrZPH6ZUaozGYa/H98JH6NVBSET3qVw+4NxzoraIW7rJ81YxSU9IalvCpg=="],
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmjs.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmjs.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -1516,6 +1537,8 @@
"oidc-token-hash": ["oidc-token-hash@5.2.0", "https://registry.npmjs.com/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"openid-client": ["openid-client@5.7.1", "https://registry.npmjs.com/openid-client/-/openid-client-5.7.1.tgz", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
"optionator": ["optionator@0.9.4", "https://registry.npmjs.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -1546,6 +1569,12 @@
"picomatch": ["picomatch@4.0.3", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pkg-types": ["pkg-types@2.3.0", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.0.tgz", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"po-parser": ["po-parser@2.1.1", "https://registry.npmjs.com/po-parser/-/po-parser-2.1.1.tgz", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="],
@@ -1566,6 +1595,8 @@
"prismjs": ["prismjs@1.30.0", "https://registry.npmjs.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"process-warning": ["process-warning@5.1.1", "", {}, "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg=="],
"prop-types": ["prop-types@15.8.1", "https://registry.npmjs.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"property-information": ["property-information@7.1.0", "https://registry.npmjs.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
@@ -1576,6 +1607,8 @@
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmjs.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"rc9": ["rc9@2.1.2", "https://registry.npmjs.com/rc9/-/rc9-2.1.2.tgz", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"react": ["react@19.2.3", "https://registry.npmjs.com/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
@@ -1610,6 +1643,8 @@
"readdirp": ["readdirp@4.1.2", "https://registry.npmjs.com/readdirp/-/readdirp-4.1.2.tgz", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recharts": ["recharts@2.15.4", "https://registry.npmjs.com/recharts/-/recharts-2.15.4.tgz", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "https://registry.npmjs.com/recharts-scale/-/recharts-scale-0.4.5.tgz", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
@@ -1642,6 +1677,10 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmjs.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmjs.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"screenfull": ["screenfull@5.2.0", "https://registry.npmjs.com/screenfull/-/screenfull-5.2.0.tgz", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="],
@@ -1668,12 +1707,20 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmjs.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"sonner": ["sonner@2.0.7", "https://registry.npmjs.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "https://registry.npmjs.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stable-hash": ["stable-hash@0.0.5", "https://registry.npmjs.com/stable-hash/-/stable-hash-0.0.5.tgz", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"static-browser-server": ["static-browser-server@1.0.3", "https://registry.npmjs.com/static-browser-server/-/static-browser-server-1.0.3.tgz", { "dependencies": { "@open-draft/deferred-promise": "^2.1.0", "dotenv": "^16.0.3", "mime-db": "^1.52.0", "outvariant": "^1.3.0" } }, "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA=="],
@@ -1722,6 +1769,8 @@
"tapable": ["tapable@2.3.0", "https://registry.npmjs.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmjs.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.0.2", "https://registry.npmjs.com/tinyexec/-/tinyexec-1.0.2.tgz", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -1942,6 +1991,8 @@
"refractor/prismjs": ["prismjs@1.27.0", "https://registry.npmjs.com/prismjs/-/prismjs-1.27.0.tgz", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.
+1
View File
@@ -55,6 +55,7 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.2",
"imapflow": "^2.9.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 z-ai-web-dev-sdk Skills
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+580
View File
@@ -0,0 +1,580 @@
---
name: ASR
description: Implement speech-to-text (ASR/automatic speech recognition) capabilities using the z-ai-web-dev-sdk. Use this skill when the user needs to transcribe audio files, convert speech to text, build voice input features, or process audio recordings. Supports base64 encoded audio files and returns accurate text transcriptions.
license: MIT
---
# ASR (Speech to Text) Skill
This skill guides the implementation of speech-to-text (ASR) functionality using the z-ai-web-dev-sdk package, enabling accurate transcription of spoken audio into text.
## Skills Path
**Skill Location**: `{project_path}/skills/ASR`
this skill is located at above path in your project.
**Reference Scripts**: Example test scripts are available in the `{Skill Location}/scripts/` directory for quick testing and reference. See `{Skill Location}/scripts/asr.ts` for a working example.
## Overview
Speech-to-Text (ASR - Automatic Speech Recognition) allows you to build applications that convert spoken language in audio files into written text, enabling voice-controlled interfaces, transcription services, and audio content analysis.
**IMPORTANT**: z-ai-web-dev-sdk MUST be used in backend code only. Never use it in client-side code.
## Prerequisites
The z-ai-web-dev-sdk package is already installed. Import it as shown in the examples below.
## CLI Usage (For Simple Tasks)
For simple audio transcription tasks, you can use the z-ai CLI instead of writing code. This is ideal for quick transcriptions, testing audio files, or batch processing.
### Basic Transcription from File
```bash
# Transcribe an audio file
z-ai asr --file ./audio.wav
# Save transcription to JSON file
z-ai asr -f ./recording.mp3 -o transcript.json
# Transcribe and view output
z-ai asr --file ./interview.wav --output result.json
```
### Transcription from Base64
```bash
# Transcribe from base64 encoded audio
z-ai asr --base64 "UklGRiQAAABXQVZFZm10..." -o result.json
# Using short option
z-ai asr -b "base64_encoded_audio_data" -o transcript.json
```
### Streaming Output
```bash
# Stream transcription results
z-ai asr -f ./audio.wav --stream
```
### CLI Parameters
- `--file, -f <path>`: **Required** (if not using --base64) - Audio file path
- `--base64, -b <base64>`: **Required** (if not using --file) - Base64 encoded audio
- `--output, -o <path>`: Optional - Output file path (JSON format)
- `--stream`: Optional - Stream the transcription output
### Supported Audio Formats
The ASR service supports various audio formats including:
- WAV (.wav)
- MP3 (.mp3)
- Other common audio formats
### When to Use CLI vs SDK
**Use CLI for:**
- Quick audio file transcriptions
- Testing audio recognition accuracy
- Simple batch processing scripts
- One-off transcription tasks
**Use SDK for:**
- Real-time audio transcription in applications
- Integration with recording systems
- Custom audio processing workflows
- Production applications with streaming audio
## Basic ASR Implementation
### Simple Audio Transcription
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function transcribeAudio(audioFilePath) {
const zai = await ZAI.create();
// Read audio file and convert to base64
const audioFile = fs.readFileSync(audioFilePath);
const base64Audio = audioFile.toString('base64');
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
return response.text;
}
// Usage
const transcription = await transcribeAudio('./audio.wav');
console.log('Transcription:', transcription);
```
### Transcribe Multiple Audio Files
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function transcribeBatch(audioFilePaths) {
const zai = await ZAI.create();
const results = [];
for (const filePath of audioFilePaths) {
try {
const audioFile = fs.readFileSync(filePath);
const base64Audio = audioFile.toString('base64');
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
results.push({
file: filePath,
success: true,
transcription: response.text
});
} catch (error) {
results.push({
file: filePath,
success: false,
error: error.message
});
}
}
return results;
}
// Usage
const files = ['./interview1.wav', './interview2.wav', './interview3.wav'];
const transcriptions = await transcribeBatch(files);
transcriptions.forEach(result => {
if (result.success) {
console.log(`${result.file}: ${result.transcription}`);
} else {
console.error(`${result.file}: Error - ${result.error}`);
}
});
```
## Advanced Use Cases
### Audio File Processing with Metadata
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
import path from 'path';
async function transcribeWithMetadata(audioFilePath) {
const zai = await ZAI.create();
// Get file metadata
const stats = fs.statSync(audioFilePath);
const audioFile = fs.readFileSync(audioFilePath);
const base64Audio = audioFile.toString('base64');
const startTime = Date.now();
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
const endTime = Date.now();
return {
filename: path.basename(audioFilePath),
filepath: audioFilePath,
fileSize: stats.size,
transcription: response.text,
wordCount: response.text.split(/\s+/).length,
processingTime: endTime - startTime,
timestamp: new Date().toISOString()
};
}
// Usage
const result = await transcribeWithMetadata('./meeting_recording.wav');
console.log('Transcription Details:', JSON.stringify(result, null, 2));
```
### Real-time Audio Processing Service
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
class ASRService {
constructor() {
this.zai = null;
this.transcriptionCache = new Map();
}
async initialize() {
this.zai = await ZAI.create();
}
generateCacheKey(audioBuffer) {
const crypto = require('crypto');
return crypto.createHash('md5').update(audioBuffer).digest('hex');
}
async transcribe(audioFilePath, useCache = true) {
const audioBuffer = fs.readFileSync(audioFilePath);
const cacheKey = this.generateCacheKey(audioBuffer);
// Check cache
if (useCache && this.transcriptionCache.has(cacheKey)) {
return {
transcription: this.transcriptionCache.get(cacheKey),
cached: true
};
}
// Transcribe audio
const base64Audio = audioBuffer.toString('base64');
const response = await this.zai.audio.asr.create({
file_base64: base64Audio
});
// Cache result
if (useCache) {
this.transcriptionCache.set(cacheKey, response.text);
}
return {
transcription: response.text,
cached: false
};
}
clearCache() {
this.transcriptionCache.clear();
}
getCacheSize() {
return this.transcriptionCache.size;
}
}
// Usage
const asrService = new ASRService();
await asrService.initialize();
const result1 = await asrService.transcribe('./audio.wav');
console.log('First call (not cached):', result1);
const result2 = await asrService.transcribe('./audio.wav');
console.log('Second call (cached):', result2);
```
### Directory Transcription
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
import path from 'path';
async function transcribeDirectory(directoryPath, outputJsonPath) {
const zai = await ZAI.create();
// Get all audio files
const files = fs.readdirSync(directoryPath);
const audioFiles = files.filter(file =>
/\.(wav|mp3|m4a|flac|ogg)$/i.test(file)
);
const results = {
directory: directoryPath,
totalFiles: audioFiles.length,
processedAt: new Date().toISOString(),
transcriptions: []
};
for (const filename of audioFiles) {
const filePath = path.join(directoryPath, filename);
try {
const audioFile = fs.readFileSync(filePath);
const base64Audio = audioFile.toString('base64');
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
results.transcriptions.push({
filename: filename,
success: true,
text: response.text,
wordCount: response.text.split(/\s+/).length
});
console.log(`✓ Transcribed: ${filename}`);
} catch (error) {
results.transcriptions.push({
filename: filename,
success: false,
error: error.message
});
console.error(`✗ Failed: ${filename} - ${error.message}`);
}
}
// Save results to JSON
fs.writeFileSync(
outputJsonPath,
JSON.stringify(results, null, 2)
);
return results;
}
// Usage
const results = await transcribeDirectory(
'./audio-recordings',
'./transcriptions.json'
);
console.log(`\nProcessed ${results.totalFiles} files`);
console.log(`Successful: ${results.transcriptions.filter(t => t.success).length}`);
console.log(`Failed: ${results.transcriptions.filter(t => !t.success).length}`);
```
## Best Practices
### 1. Audio Format Handling
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function transcribeAnyFormat(audioFilePath) {
// Supported formats: WAV, MP3, M4A, FLAC, OGG, etc.
const validExtensions = ['.wav', '.mp3', '.m4a', '.flac', '.ogg'];
const ext = audioFilePath.toLowerCase().substring(audioFilePath.lastIndexOf('.'));
if (!validExtensions.includes(ext)) {
throw new Error(`Unsupported audio format: ${ext}`);
}
const zai = await ZAI.create();
const audioFile = fs.readFileSync(audioFilePath);
const base64Audio = audioFile.toString('base64');
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
return response.text;
}
```
### 2. Error Handling
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function safeTranscribe(audioFilePath) {
try {
// Validate file exists
if (!fs.existsSync(audioFilePath)) {
throw new Error(`File not found: ${audioFilePath}`);
}
// Check file size (e.g., limit to 100MB)
const stats = fs.statSync(audioFilePath);
const fileSizeMB = stats.size / (1024 * 1024);
if (fileSizeMB > 100) {
throw new Error(`File too large: ${fileSizeMB.toFixed(2)}MB (max 100MB)`);
}
// Transcribe
const zai = await ZAI.create();
const audioFile = fs.readFileSync(audioFilePath);
const base64Audio = audioFile.toString('base64');
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
if (!response.text || response.text.trim().length === 0) {
throw new Error('Empty transcription result');
}
return {
success: true,
transcription: response.text,
filePath: audioFilePath,
fileSize: stats.size
};
} catch (error) {
console.error('Transcription error:', error);
return {
success: false,
error: error.message,
filePath: audioFilePath
};
}
}
```
### 3. Post-Processing Transcriptions
```javascript
function cleanTranscription(text) {
// Remove excessive whitespace
text = text.replace(/\s+/g, ' ').trim();
// Capitalize first letter of sentences
text = text.replace(/(^\w|[.!?]\s+\w)/g, match => match.toUpperCase());
// Remove filler words (optional)
const fillers = ['um', 'uh', 'ah', 'like', 'you know'];
const fillerPattern = new RegExp(`\\b(${fillers.join('|')})\\b`, 'gi');
text = text.replace(fillerPattern, '').replace(/\s+/g, ' ');
return text;
}
async function transcribeAndClean(audioFilePath) {
const zai = await ZAI.create();
const audioFile = fs.readFileSync(audioFilePath);
const base64Audio = audioFile.toString('base64');
const response = await zai.audio.asr.create({
file_base64: base64Audio
});
return {
raw: response.text,
cleaned: cleanTranscription(response.text)
};
}
```
## Common Use Cases
1. **Meeting Transcription**: Convert recorded meetings into searchable text
2. **Interview Processing**: Transcribe interviews for analysis and documentation
3. **Podcast Transcription**: Create text versions of podcast episodes
4. **Voice Notes**: Convert voice memos to text for easier reference
5. **Call Center Analytics**: Analyze customer service calls
6. **Accessibility**: Provide text alternatives for audio content
7. **Voice Commands**: Enable voice-controlled applications
8. **Language Learning**: Transcribe pronunciation practice
## Integration Examples
### Express.js API Endpoint
```javascript
import express from 'express';
import multer from 'multer';
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
const app = express();
const upload = multer({ dest: 'uploads/' });
let zaiInstance;
async function initZAI() {
zaiInstance = await ZAI.create();
}
app.post('/api/transcribe', upload.single('audio'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const audioFile = fs.readFileSync(req.file.path);
const base64Audio = audioFile.toString('base64');
const response = await zaiInstance.audio.asr.create({
file_base64: base64Audio
});
// Clean up uploaded file
fs.unlinkSync(req.file.path);
res.json({
success: true,
transcription: response.text,
wordCount: response.text.split(/\s+/).length
});
} catch (error) {
// Clean up on error
if (req.file && fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
res.status(500).json({
success: false,
error: error.message
});
}
});
initZAI().then(() => {
app.listen(3000, () => {
console.log('ASR API running on port 3000');
});
});
```
## Troubleshooting
**Issue**: "SDK must be used in backend"
- **Solution**: Ensure z-ai-web-dev-sdk is only imported in server-side code
**Issue**: Empty or incorrect transcription
- **Solution**: Verify audio quality and format. Check if audio contains clear speech
**Issue**: Large file processing fails
- **Solution**: Consider splitting large audio files into smaller segments
**Issue**: Slow transcription speed
- **Solution**: Implement caching for repeated transcriptions, optimize file sizes
**Issue**: Memory errors with large files
- **Solution**: Process files in chunks or increase Node.js memory limit
## Performance Tips
1. **Reuse SDK Instance**: Create once, use multiple times
2. **Implement Caching**: Cache transcriptions for duplicate files
3. **Batch Processing**: Process multiple files efficiently with proper queuing
4. **Audio Optimization**: Compress audio files before processing when possible
5. **Async Operations**: Use Promise.all for parallel processing when appropriate
## Audio Quality Guidelines
For best transcription results:
- **Sample Rate**: 16kHz or higher
- **Format**: WAV, MP3, or M4A recommended
- **Noise Level**: Minimize background noise
- **Speech Clarity**: Clear pronunciation and normal speaking pace
- **File Size**: Under 100MB recommended for individual files
## Remember
- Always use z-ai-web-dev-sdk in backend code only
- The SDK is already installed - import as shown in examples
- Audio files must be converted to base64 before processing
- Implement proper error handling for production applications
- Consider audio quality for best transcription accuracy
- Clean up temporary files after processing
- Cache results for frequently transcribed files
+27
View File
@@ -0,0 +1,27 @@
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
import path from 'path';
async function main(inputFile: string) {
if (!fs.existsSync(inputFile)) {
console.error(`Audio file not found: ${inputFile}`);
return;
}
try {
const zai = await ZAI.create();
const audioBuffer = fs.readFileSync(inputFile);
const file_base64 = audioBuffer.toString('base64');
const result = await zai.audio.asr.create({ file_base64 });
console.log('Transcription result:');
console.log(result.text ?? JSON.stringify(result, null, 2));
} catch (err: any) {
console.error('ASR failed:', err?.message || err);
}
}
main('./output.wav');
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 z-ai-web-dev-sdk Skills
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+856
View File
@@ -0,0 +1,856 @@
---
name: LLM
description: Implement large language model (LLM) chat completions using the z-ai-web-dev-sdk. Use this skill when the user needs to build conversational AI applications, chatbots, AI assistants, or any text generation features. Supports multi-turn conversations, system prompts, and context management.
license: MIT
---
# LLM (Large Language Model) Skill
This skill guides the implementation of chat completions functionality using the z-ai-web-dev-sdk package, enabling powerful conversational AI and text generation capabilities.
## Skills Path
**Skill Location**: `{project_path}/skills/llm`
this skill is located at above path in your project.
**Reference Scripts**: Example test scripts are available in the `{Skill Location}/scripts/` directory for quick testing and reference. See `{Skill Location}/scripts/chat.ts` for a working example.
## Overview
The LLM skill allows you to build applications that leverage large language models for natural language understanding and generation, including chatbots, AI assistants, content generation, and more.
**IMPORTANT**: z-ai-web-dev-sdk MUST be used in backend code only. Never use it in client-side code.
## Prerequisites
The z-ai-web-dev-sdk package is already installed. Import it as shown in the examples below.
## CLI Usage (For Simple Tasks)
For simple, one-off chat completions, you can use the z-ai CLI instead of writing code. This is ideal for quick tests, simple queries, or automation scripts.
### Basic Chat
```bash
# Simple question
z-ai chat --prompt "What is the capital of France?"
# Save response to file
z-ai chat -p "Explain quantum computing" -o response.json
# Stream the response
z-ai chat -p "Write a short poem" --stream
```
### With System Prompt
```bash
# Custom system prompt for specific behavior
z-ai chat \
--prompt "Review this code: function add(a,b) { return a+b; }" \
--system "You are an expert code reviewer" \
-o review.json
```
### With Thinking (Chain of Thought)
```bash
# Enable thinking for complex reasoning
z-ai chat \
--prompt "Solve this math problem: If a train travels 120km in 2 hours, what's its speed?" \
--thinking \
-o solution.json
```
### CLI Parameters
- `--prompt, -p <text>`: **Required** - User message content
- `--system, -s <text>`: Optional - System prompt for custom behavior
- `--thinking, -t`: Optional - Enable chain-of-thought reasoning (default: disabled)
- `--output, -o <path>`: Optional - Output file path (JSON format)
- `--stream`: Optional - Stream the response in real-time
### When to Use CLI vs SDK
**Use CLI for:**
- Quick one-off questions
- Simple automation scripts
- Testing prompts
- Single-turn conversations
**Use SDK for:**
- Multi-turn conversations with context
- Custom conversation management
- Integration with web applications
- Complex chat workflows
- Production applications
## Basic Chat Completions
### Simple Question and Answer
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function askQuestion(question) {
const zai = await ZAI.create();
const completion = await zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: 'You are a helpful assistant.'
},
{
role: 'user',
content: question
}
],
thinking: { type: 'disabled' }
});
const response = completion.choices[0]?.message?.content;
return response;
}
// Usage
const answer = await askQuestion('What is the capital of France?');
console.log('Answer:', answer);
```
### Custom System Prompt
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function customAssistant(systemPrompt, userMessage) {
const zai = await ZAI.create();
const completion = await zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: systemPrompt
},
{
role: 'user',
content: userMessage
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
// Usage - Code reviewer
const codeReview = await customAssistant(
'You are an expert code reviewer. Analyze code for bugs, performance issues, and best practices.',
'Review this function: function add(a, b) { return a + b; }'
);
// Usage - Creative writer
const story = await customAssistant(
'You are a creative fiction writer who writes engaging short stories.',
'Write a short story about a robot learning to paint.'
);
console.log(codeReview);
console.log(story);
```
## Multi-turn Conversations
### Conversation History Management
```javascript
import ZAI from 'z-ai-web-dev-sdk';
class ConversationManager {
constructor(systemPrompt = 'You are a helpful assistant.') {
this.messages = [
{
role: 'assistant',
content: systemPrompt
}
];
this.zai = null;
}
async initialize() {
this.zai = await ZAI.create();
}
async sendMessage(userMessage) {
// Add user message to history
this.messages.push({
role: 'user',
content: userMessage
});
// Get completion
const completion = await this.zai.chat.completions.create({
messages: this.messages,
thinking: { type: 'disabled' }
});
const assistantResponse = completion.choices[0]?.message?.content;
// Add assistant response to history
this.messages.push({
role: 'assistant',
content: assistantResponse
});
return assistantResponse;
}
getHistory() {
return this.messages;
}
clearHistory(systemPrompt = 'You are a helpful assistant.') {
this.messages = [
{
role: 'assistant',
content: systemPrompt
}
];
}
getMessageCount() {
// Subtract 1 for system message
return this.messages.length - 1;
}
}
// Usage
const conversation = new ConversationManager();
await conversation.initialize();
const response1 = await conversation.sendMessage('Hi, my name is John.');
console.log('AI:', response1);
const response2 = await conversation.sendMessage('What is my name?');
console.log('AI:', response2); // Should remember the name is John
console.log('Total messages:', conversation.getMessageCount());
```
### Context-Aware Conversations
```javascript
import ZAI from 'z-ai-web-dev-sdk';
class ContextualChat {
constructor() {
this.messages = [];
this.zai = null;
}
async initialize() {
this.zai = await ZAI.create();
}
async startConversation(role, context) {
// Set up system prompt with context
const systemPrompt = `You are ${role}. Context: ${context}`;
this.messages = [
{
role: 'assistant',
content: systemPrompt
}
];
}
async chat(userMessage) {
this.messages.push({
role: 'user',
content: userMessage
});
const completion = await this.zai.chat.completions.create({
messages: this.messages,
thinking: { type: 'disabled' }
});
const response = completion.choices[0]?.message?.content;
this.messages.push({
role: 'assistant',
content: response
});
return response;
}
}
// Usage - Customer support scenario
const support = new ContextualChat();
await support.initialize();
await support.startConversation(
'a customer support agent for TechCorp',
'The user has ordered product #12345 which is delayed due to shipping issues.'
);
const reply1 = await support.chat('Where is my order?');
console.log('Support:', reply1);
const reply2 = await support.chat('Can I get a refund?');
console.log('Support:', reply2);
```
## Advanced Use Cases
### Content Generation
```javascript
import ZAI from 'z-ai-web-dev-sdk';
class ContentGenerator {
constructor() {
this.zai = null;
}
async initialize() {
this.zai = await ZAI.create();
}
async generateBlogPost(topic, tone = 'professional') {
const completion = await this.zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: `You are a professional content writer. Write in a ${tone} tone.`
},
{
role: 'user',
content: `Write a blog post about: ${topic}. Include an introduction, main points, and conclusion.`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
async generateProductDescription(productName, features) {
const completion = await this.zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: 'You are an expert at writing compelling product descriptions for e-commerce.'
},
{
role: 'user',
content: `Write a product description for "${productName}". Key features: ${features.join(', ')}.`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
async generateEmailResponse(originalEmail, intent) {
const completion = await this.zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: 'You are a professional email writer. Write clear, concise, and polite emails.'
},
{
role: 'user',
content: `Original email: "${originalEmail}"\n\nWrite a ${intent} response.`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
}
// Usage
const generator = new ContentGenerator();
await generator.initialize();
const blogPost = await generator.generateBlogPost(
'The Future of Artificial Intelligence',
'informative'
);
console.log('Blog Post:', blogPost);
const productDesc = await generator.generateProductDescription(
'Smart Watch Pro',
['Heart rate monitoring', 'GPS tracking', 'Waterproof', '7-day battery life']
);
console.log('Product Description:', productDesc);
```
### Data Analysis and Summarization
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function analyzeData(data, analysisType) {
const zai = await ZAI.create();
const prompts = {
summarize: 'You are a data analyst. Summarize the key insights from the data.',
trend: 'You are a data analyst. Identify trends and patterns in the data.',
recommendation: 'You are a business analyst. Provide actionable recommendations based on the data.'
};
const completion = await zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: prompts[analysisType] || prompts.summarize
},
{
role: 'user',
content: `Analyze this data:\n\n${JSON.stringify(data, null, 2)}`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
// Usage
const salesData = {
Q1: { revenue: 100000, customers: 250 },
Q2: { revenue: 120000, customers: 280 },
Q3: { revenue: 150000, customers: 320 },
Q4: { revenue: 180000, customers: 380 }
};
const summary = await analyzeData(salesData, 'summarize');
const trends = await analyzeData(salesData, 'trend');
const recommendations = await analyzeData(salesData, 'recommendation');
console.log('Summary:', summary);
console.log('Trends:', trends);
console.log('Recommendations:', recommendations);
```
### Code Generation and Debugging
```javascript
import ZAI from 'z-ai-web-dev-sdk';
class CodeAssistant {
constructor() {
this.zai = null;
}
async initialize() {
this.zai = await ZAI.create();
}
async generateCode(description, language) {
const completion = await this.zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: `You are an expert ${language} programmer. Write clean, efficient, and well-commented code.`
},
{
role: 'user',
content: `Write ${language} code to: ${description}`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
async debugCode(code, issue) {
const completion = await this.zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: 'You are an expert debugger. Identify bugs and suggest fixes.'
},
{
role: 'user',
content: `Code:\n${code}\n\nIssue: ${issue}\n\nFind the bug and suggest a fix.`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
async explainCode(code) {
const completion = await this.zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: 'You are a programming teacher. Explain code clearly and simply.'
},
{
role: 'user',
content: `Explain what this code does:\n\n${code}`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
}
// Usage
const codeAssist = new CodeAssistant();
await codeAssist.initialize();
const newCode = await codeAssist.generateCode(
'Create a function that sorts an array of objects by a specific property',
'JavaScript'
);
console.log('Generated Code:', newCode);
const bugFix = await codeAssist.debugCode(
'function add(a, b) { return a - b; }',
'This function should add numbers but returns wrong results'
);
console.log('Debug Suggestion:', bugFix);
```
## Best Practices
### 1. Prompt Engineering
```javascript
// Bad: Vague prompt
const bad = await askQuestion('Tell me about AI');
// Good: Specific and structured prompt
async function askWithContext(topic, format, audience) {
const zai = await ZAI.create();
const completion = await zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: `You are an expert educator. Explain topics clearly for ${audience}.`
},
{
role: 'user',
content: `Explain ${topic} in ${format} format. Include practical examples.`
}
],
thinking: { type: 'disabled' }
});
return completion.choices[0]?.message?.content;
}
const good = await askWithContext('artificial intelligence', 'bullet points', 'beginners');
```
### 2. Error Handling
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function safeCompletion(messages, retries = 3) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const zai = await ZAI.create();
const completion = await zai.chat.completions.create({
messages: messages,
thinking: { type: 'disabled' }
});
const response = completion.choices[0]?.message?.content;
if (!response || response.trim().length === 0) {
throw new Error('Empty response from AI');
}
return {
success: true,
content: response,
attempts: attempt
};
} catch (error) {
lastError = error;
console.error(`Attempt ${attempt} failed:`, error.message);
if (attempt < retries) {
// Wait before retry (exponential backoff)
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
return {
success: false,
error: lastError.message,
attempts: retries
};
}
```
### 3. Context Management
```javascript
class ManagedConversation {
constructor(maxMessages = 20) {
this.maxMessages = maxMessages;
this.systemPrompt = '';
this.messages = [];
this.zai = null;
}
async initialize(systemPrompt) {
this.zai = await ZAI.create();
this.systemPrompt = systemPrompt;
this.messages = [
{
role: 'assistant',
content: systemPrompt
}
];
}
async chat(userMessage) {
// Add user message
this.messages.push({
role: 'user',
content: userMessage
});
// Trim old messages if exceeding limit (keep system prompt)
if (this.messages.length > this.maxMessages) {
this.messages = [
this.messages[0], // Keep system prompt
...this.messages.slice(-(this.maxMessages - 1))
];
}
const completion = await this.zai.chat.completions.create({
messages: this.messages,
thinking: { type: 'disabled' }
});
const response = completion.choices[0]?.message?.content;
this.messages.push({
role: 'assistant',
content: response
});
return response;
}
getTokenEstimate() {
// Rough estimate: ~4 characters per token
const totalChars = this.messages
.map(m => m.content.length)
.reduce((a, b) => a + b, 0);
return Math.ceil(totalChars / 4);
}
}
```
### 4. Response Processing
```javascript
async function getStructuredResponse(query, format = 'json') {
const zai = await ZAI.create();
const formatInstructions = {
json: 'Respond with valid JSON only. No additional text.',
list: 'Respond with a numbered list.',
markdown: 'Respond in Markdown format.'
};
const completion = await zai.chat.completions.create({
messages: [
{
role: 'assistant',
content: `You are a helpful assistant. ${formatInstructions[format]}`
},
{
role: 'user',
content: query
}
],
thinking: { type: 'disabled' }
});
const response = completion.choices[0]?.message?.content;
// Parse JSON if requested
if (format === 'json') {
try {
return JSON.parse(response);
} catch (e) {
console.error('Failed to parse JSON response');
return { raw: response };
}
}
return response;
}
// Usage
const jsonData = await getStructuredResponse(
'List three programming languages with their primary use cases',
'json'
);
console.log(jsonData);
```
## Common Use Cases
1. **Chatbots & Virtual Assistants**: Build conversational interfaces for customer support
2. **Content Generation**: Create articles, product descriptions, marketing copy
3. **Code Assistance**: Generate, explain, and debug code
4. **Data Analysis**: Analyze and summarize complex data sets
5. **Language Translation**: Translate text between languages
6. **Educational Tools**: Create tutoring and learning applications
7. **Email Automation**: Generate professional email responses
8. **Creative Writing**: Story generation, poetry, and creative content
## Integration Examples
### Express.js Chatbot API
```javascript
import express from 'express';
import ZAI from 'z-ai-web-dev-sdk';
const app = express();
app.use(express.json());
// Store conversations in memory (use database in production)
const conversations = new Map();
let zaiInstance;
async function initZAI() {
zaiInstance = await ZAI.create();
}
app.post('/api/chat', async (req, res) => {
try {
const { sessionId, message, systemPrompt } = req.body;
if (!message) {
return res.status(400).json({ error: 'Message is required' });
}
// Get or create conversation history
let history = conversations.get(sessionId) || [
{
role: 'assistant',
content: systemPrompt || 'You are a helpful assistant.'
}
];
// Add user message
history.push({
role: 'user',
content: message
});
// Get completion
const completion = await zaiInstance.chat.completions.create({
messages: history,
thinking: { type: 'disabled' }
});
const aiResponse = completion.choices[0]?.message?.content;
// Add AI response to history
history.push({
role: 'assistant',
content: aiResponse
});
// Save updated history
conversations.set(sessionId, history);
res.json({
success: true,
response: aiResponse,
messageCount: history.length - 1
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
app.delete('/api/chat/:sessionId', (req, res) => {
const { sessionId } = req.params;
conversations.delete(sessionId);
res.json({ success: true, message: 'Conversation cleared' });
});
initZAI().then(() => {
app.listen(3000, () => {
console.log('Chatbot API running on port 3000');
});
});
```
## Troubleshooting
**Issue**: "SDK must be used in backend"
- **Solution**: Ensure z-ai-web-dev-sdk is only imported and used in server-side code
**Issue**: Empty or incomplete responses
- **Solution**: Check that completion.choices[0]?.message?.content exists and is not empty
**Issue**: Conversation context getting too long
- **Solution**: Implement message trimming to keep only recent messages
**Issue**: Inconsistent responses
- **Solution**: Use more specific system prompts and provide clear instructions
**Issue**: Rate limiting errors
- **Solution**: Implement retry logic with exponential backoff
## Performance Tips
1. **Reuse SDK Instance**: Create ZAI instance once and reuse across requests
2. **Manage Context Length**: Trim old messages to avoid token limits
3. **Implement Caching**: Cache responses for common queries
4. **Use Specific Prompts**: Clear prompts lead to faster, better responses
5. **Handle Errors Gracefully**: Implement retry logic and fallback responses
## Security Considerations
1. **Input Validation**: Always validate and sanitize user input
2. **Rate Limiting**: Implement rate limits to prevent abuse
3. **API Key Protection**: Never expose SDK credentials in client-side code
4. **Content Filtering**: Filter sensitive or inappropriate content
5. **Session Management**: Implement proper session handling and cleanup
## Remember
- Always use z-ai-web-dev-sdk in backend code only
- The SDK is already installed - import as shown in examples
- Use the 'assistant' role for system prompts
- Set thinking to { type: 'disabled' } for standard completions
- Implement proper error handling and retries for production
- Manage conversation history to avoid token limits
- Clear and specific prompts lead to better results
- Check `scripts/chat.ts` for a quick start example
+32
View File
@@ -0,0 +1,32 @@
import ZAI, { ChatMessage } from "z-ai-web-dev-sdk";
async function main(prompt: string) {
try {
const zai = await ZAI.create();
const messages: ChatMessage[] = [
{
role: "assistant",
content: "Hi, I'm a helpful assistant."
},
{
role: "user",
content: prompt,
},
];
const response = await zai.chat.completions.create({
messages,
stream: false,
thinking: { type: "disabled" },
});
const reply = response.choices?.[0]?.message?.content;
console.log("Chat reply:");
console.log(reply ?? JSON.stringify(response, null, 2));
} catch (err: any) {
console.error("Chat failed:", err?.message || err);
}
}
main('What is the capital of France?');
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 z-ai-web-dev-sdk Skills
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+735
View File
@@ -0,0 +1,735 @@
---
name: TTS
description: Implement text-to-speech (TTS) capabilities using the z-ai-web-dev-sdk. Use this skill when the user needs to convert text into natural-sounding speech, create audio content, build voice-enabled applications, or generate spoken audio files. Supports multiple voices, adjustable speed, and various audio formats.
license: MIT
---
# TTS (Text to Speech) Skill
This skill guides the implementation of text-to-speech (TTS) functionality using the z-ai-web-dev-sdk package, enabling conversion of text into natural-sounding speech audio.
## Skills Path
**Skill Location**: `{project_path}/skills/TTS`
This skill is located at the above path in your project.
**Reference Scripts**: Example test scripts are available in the `{Skill Location}/scripts/` directory for quick testing and reference. See `{Skill Location}/scripts/tts.ts` for a working example.
## Overview
Text-to-Speech allows you to build applications that generate spoken audio from text input, supporting various voices, speeds, and output formats for diverse use cases.
**IMPORTANT**: z-ai-web-dev-sdk MUST be used in backend code only. Never use it in client-side code.
## API Limitations and Constraints
Before implementing TTS functionality, be aware of these important limitations:
### Input Text Constraints
- **Maximum length**: 1024 characters per request
- Text exceeding this limit must be split into smaller chunks
### Audio Parameters
- **Speed range**: 0.5 to 2.0
- 0.5 = half speed (slower)
- 1.0 = normal speed (default)
- 2.0 = double speed (faster)
- **Volume range**: Greater than 0, up to 10
- Default: 1.0
- Values must be greater than 0 (exclusive) and up to 10 (inclusive)
### Format and Streaming
- **Streaming limitation**: When `stream: true` is enabled, only `pcm` format is supported
- **Non-streaming**: Supports `wav`, `pcm`, and `mp3` formats
- **Sample rate**: 24000 Hz (recommended)
### Best Practice for Long Text
```javascript
function splitTextIntoChunks(text, maxLength = 1000) {
const chunks = [];
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
let currentChunk = '';
for (const sentence of sentences) {
if ((currentChunk + sentence).length <= maxLength) {
currentChunk += sentence;
} else {
if (currentChunk) chunks.push(currentChunk.trim());
currentChunk = sentence;
}
}
if (currentChunk) chunks.push(currentChunk.trim());
return chunks;
}
```
## Prerequisites
The z-ai-web-dev-sdk package is already installed. Import it as shown in the examples below.
## CLI Usage (For Simple Tasks)
For simple text-to-speech conversions, you can use the z-ai CLI instead of writing code. This is ideal for quick audio generation, testing voices, or simple automation.
### Basic TTS
```bash
# Convert text to speech (default WAV format)
z-ai tts --input "Hello, world" --output ./hello.wav
# Using short options
z-ai tts -i "Hello, world" -o ./hello.wav
```
### Different Voices and Speed
```bash
# Use specific voice
z-ai tts -i "Welcome to our service" -o ./welcome.wav --voice tongtong
# Adjust speech speed (0.5-2.0)
z-ai tts -i "This is faster speech" -o ./fast.wav --speed 1.5
# Slower speech
z-ai tts -i "This is slower speech" -o ./slow.wav --speed 0.8
```
### Different Output Formats
```bash
# MP3 format
z-ai tts -i "Hello World" -o ./hello.mp3 --format mp3
# WAV format (default)
z-ai tts -i "Hello World" -o ./hello.wav --format wav
# PCM format
z-ai tts -i "Hello World" -o ./hello.pcm --format pcm
```
### Streaming Output
```bash
# Stream audio generation
z-ai tts -i "This is a longer text that will be streamed" -o ./stream.wav --stream
```
### CLI Parameters
- `--input, -i <text>`: **Required** - Text to convert to speech (max 1024 characters)
- `--output, -o <path>`: **Required** - Output audio file path
- `--voice, -v <voice>`: Optional - Voice type (default: tongtong)
- `--speed, -s <number>`: Optional - Speech speed, 0.5-2.0 (default: 1.0)
- `--format, -f <format>`: Optional - Output format: wav, mp3, pcm (default: wav)
- `--stream`: Optional - Enable streaming output (only supports pcm format)
### When to Use CLI vs SDK
**Use CLI for:**
- Quick text-to-speech conversions
- Testing different voices and speeds
- Simple batch audio generation
- Command-line automation scripts
**Use SDK for:**
- Dynamic audio generation in applications
- Integration with web services
- Custom audio processing pipelines
- Production applications with complex requirements
## Basic TTS Implementation
### Simple Text to Speech
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function textToSpeech(text, outputPath) {
const zai = await ZAI.create();
const response = await zai.audio.tts.create({
input: text,
voice: 'tongtong',
speed: 1.0,
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
console.log(`Audio saved to ${outputPath}`);
return outputPath;
}
// Usage
await textToSpeech('Hello, world!', './output.wav');
```
### Multiple Voice Options
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function generateWithVoice(text, voice, outputPath) {
const zai = await ZAI.create();
const response = await zai.audio.tts.create({
input: text,
voice: voice, // Available voices: tongtong, chuichui, xiaochen, jam, kazi, douji, luodo
speed: 1.0,
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
return outputPath;
}
// Usage
await generateWithVoice('Welcome to our service', 'tongtong', './welcome.wav');
```
### Adjustable Speed
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function generateWithSpeed(text, speed, outputPath) {
const zai = await ZAI.create();
// Speed range: 0.5 to 2.0 (API constraint)
// 0.5 = half speed (slower)
// 1.0 = normal speed (default)
// 2.0 = double speed (faster)
// Values outside this range will cause API errors
const response = await zai.audio.tts.create({
input: text,
voice: 'tongtong',
speed: speed,
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
return outputPath;
}
// Usage - slower narration
await generateWithSpeed('This is an important announcement', 0.8, './slow.wav');
// Usage - faster narration
await generateWithSpeed('Quick update', 1.3, './fast.wav');
```
### Adjustable Volume
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function generateWithVolume(text, volume, outputPath) {
const zai = await ZAI.create();
// Volume range: greater than 0, up to 10 (API constraint)
// Values must be > 0 (exclusive) and <= 10 (inclusive)
// Default: 1.0 (normal volume)
const response = await zai.audio.tts.create({
input: text,
voice: 'tongtong',
speed: 1.0,
volume: volume, // Optional parameter
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
return outputPath;
}
// Usage - louder audio
await generateWithVolume('This is an announcement', 5.0, './loud.wav');
// Usage - quieter audio
await generateWithVolume('Whispered message', 0.5, './quiet.wav');
```
## Advanced Use Cases
### Batch Processing
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
import path from 'path';
async function batchTextToSpeech(textArray, outputDir) {
const zai = await ZAI.create();
const results = [];
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
for (let i = 0; i < textArray.length; i++) {
try {
const text = textArray[i];
const outputPath = path.join(outputDir, `audio_${i + 1}.wav`);
const response = await zai.audio.tts.create({
input: text,
voice: 'tongtong',
speed: 1.0,
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
results.push({
success: true,
text,
path: outputPath
});
} catch (error) {
results.push({
success: false,
text: textArray[i],
error: error.message
});
}
}
return results;
}
// Usage
const texts = [
'Welcome to chapter one',
'Welcome to chapter two',
'Welcome to chapter three'
];
const results = await batchTextToSpeech(texts, './audio-output');
console.log('Generated:', results.length, 'audio files');
```
### Dynamic Content Generation
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
class TTSGenerator {
constructor() {
this.zai = null;
}
async initialize() {
this.zai = await ZAI.create();
}
async generateAudio(text, options = {}) {
const {
voice = 'tongtong',
speed = 1.0,
format = 'wav'
} = options;
const response = await this.zai.audio.tts.create({
input: text,
voice: voice,
speed: speed,
response_format: format,
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(new Uint8Array(arrayBuffer));
}
async saveAudio(text, outputPath, options = {}) {
const buffer = await this.generateAudio(text, options);
if (buffer) {
fs.writeFileSync(outputPath, buffer);
return outputPath;
}
return null;
}
}
// Usage
const generator = new TTSGenerator();
await generator.initialize();
await generator.saveAudio(
'Hello, this is a test',
'./output.wav',
{ speed: 1.2 }
);
```
### Next.js API Route Example
```javascript
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
try {
const { text, voice = 'tongtong', speed = 1.0 } = await req.json();
// Import ZAI SDK
const ZAI = (await import('z-ai-web-dev-sdk')).default;
// Create SDK instance
const zai = await ZAI.create();
// Generate TTS audio
const response = await zai.audio.tts.create({
input: text.trim(),
voice: voice,
speed: speed,
response_format: 'wav',
stream: false,
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
// Return audio as response
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'audio/wav',
'Content-Length': buffer.length.toString(),
'Cache-Control': 'no-cache',
},
});
} catch (error) {
console.error('TTS API Error:', error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : '生成语音失败,请稍后重试',
},
{ status: 500 }
);
}
}
```
## Best Practices
### 1. Text Preparation
```javascript
function prepareTextForTTS(text) {
// Remove excessive whitespace
text = text.replace(/\s+/g, ' ').trim();
// Expand common abbreviations for better pronunciation
const abbreviations = {
'Dr.': 'Doctor',
'Mr.': 'Mister',
'Mrs.': 'Misses',
'etc.': 'et cetera'
};
for (const [abbr, full] of Object.entries(abbreviations)) {
text = text.replace(new RegExp(abbr, 'g'), full);
}
return text;
}
```
### 2. Error Handling
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function safeTTS(text, outputPath) {
try {
// Validate input
if (!text || text.trim().length === 0) {
throw new Error('Text input cannot be empty');
}
if (text.length > 1024) {
throw new Error('Text input exceeds maximum length of 1024 characters');
}
const zai = await ZAI.create();
const response = await zai.audio.tts.create({
input: text,
voice: 'tongtong',
speed: 1.0,
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
return {
success: true,
path: outputPath,
size: buffer.length
};
} catch (error) {
console.error('TTS Error:', error);
return {
success: false,
error: error.message
};
}
}
```
### 3. SDK Instance Reuse
```javascript
import ZAI from 'z-ai-web-dev-sdk';
// Create a singleton instance
let zaiInstance = null;
async function getZAIInstance() {
if (!zaiInstance) {
zaiInstance = await ZAI.create();
}
return zaiInstance;
}
// Usage
const zai = await getZAIInstance();
const response = await zai.audio.tts.create({ ... });
```
## Common Use Cases
1. **Audiobooks & Podcasts**: Convert written content to audio format
2. **E-learning**: Create narration for educational content
3. **Accessibility**: Provide audio versions of text content
4. **Voice Assistants**: Generate dynamic responses
5. **Announcements**: Create automated audio notifications
6. **IVR Systems**: Generate phone system prompts
7. **Content Localization**: Create audio in different languages
## Integration Examples
### Express.js API Endpoint
```javascript
import express from 'express';
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
import path from 'path';
const app = express();
app.use(express.json());
let zaiInstance;
const outputDir = './audio-output';
async function initZAI() {
zaiInstance = await ZAI.create();
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
}
app.post('/api/tts', async (req, res) => {
try {
const { text, voice = 'tongtong', speed = 1.0 } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text is required' });
}
const filename = `tts_${Date.now()}.wav`;
const outputPath = path.join(outputDir, filename);
const response = await zaiInstance.audio.tts.create({
input: text,
voice: voice,
speed: speed,
response_format: 'wav',
stream: false
});
// Get array buffer from Response object
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outputPath, buffer);
res.json({
success: true,
audioUrl: `/audio/${filename}`,
size: buffer.length
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.use('/audio', express.static('audio-output'));
initZAI().then(() => {
app.listen(3000, () => {
console.log('TTS API running on port 3000');
});
});
```
## Troubleshooting
**Issue**: "Input text exceeds maximum length"
- **Solution**: Text input is limited to 1024 characters. Split longer text into chunks using the `splitTextIntoChunks` function shown in the API Limitations section
**Issue**: "Invalid speed parameter" or unexpected speed behavior
- **Solution**: Speed must be between 0.5 and 2.0. Check your speed value is within this range
**Issue**: "Invalid volume parameter"
- **Solution**: Volume must be greater than 0 and up to 10. Ensure volume value is in range (0, 10]
**Issue**: "Stream format not supported" with WAV/MP3
- **Solution**: Streaming mode only supports PCM format. Either use `response_format: 'pcm'` with streaming, or disable streaming (`stream: false`) for WAV/MP3 output
**Issue**: "SDK must be used in backend"
- **Solution**: Ensure z-ai-web-dev-sdk is only imported in server-side code
**Issue**: "TypeError: response.audio is undefined"
- **Solution**: The SDK returns a standard Response object, use `await response.arrayBuffer()` instead of accessing `response.audio`
**Issue**: Generated audio file is empty or corrupted
- **Solution**: Ensure you're calling `await response.arrayBuffer()` and properly converting to Buffer: `Buffer.from(new Uint8Array(arrayBuffer))`
**Issue**: Audio sounds unnatural
- **Solution**: Prepare text properly (remove special characters, expand abbreviations)
**Issue**: Long processing times
- **Solution**: Break long text into smaller chunks and process in parallel
**Issue**: Next.js caching old API route
- **Solution**: Create a new API route endpoint or restart the dev server
## Performance Tips
1. **Reuse SDK Instance**: Create ZAI instance once and reuse
2. **Implement Caching**: Cache generated audio for repeated text
3. **Batch Processing**: Process multiple texts efficiently
4. **Optimize Text**: Remove unnecessary content before generation
5. **Async Processing**: Use queues for handling multiple requests
## Important Notes
### API Constraints
**Input Text Length**: Maximum 1024 characters per request. For longer text:
```javascript
// Split long text into chunks
const longText = "..."; // Your long text here
const chunks = splitTextIntoChunks(longText, 1000);
for (const chunk of chunks) {
const response = await zai.audio.tts.create({
input: chunk,
voice: 'tongtong',
speed: 1.0,
response_format: 'wav',
stream: false
});
// Process each chunk...
}
```
**Streaming Format Limitation**: When using `stream: true`, only `pcm` format is supported. For `wav` or `mp3` output, use `stream: false`.
**Sample Rate**: Audio is generated at 24000 Hz sample rate (recommended setting for playback).
### Response Object Format
The `zai.audio.tts.create()` method returns a standard **Response** object (not a custom object with an `audio` property). Always use:
```javascript
// ✅ CORRECT
const response = await zai.audio.tts.create({ ... });
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
// ❌ WRONG - This will not work
const response = await zai.audio.tts.create({ ... });
const buffer = Buffer.from(response.audio); // response.audio is undefined
```
### Available Voices
- `tongtong` - 温暖亲切
- `chuichui` - 活泼可爱
- `xiaochen` - 沉稳专业
- `jam` - 英音绅士
- `kazi` - 清晰标准
- `douji` - 自然流畅
- `luodo` - 富有感染力
### Speed Range
- Minimum: `0.5` (half speed)
- Default: `1.0` (normal speed)
- Maximum: `2.0` (double speed)
**Important**: Speed values outside the range [0.5, 2.0] will result in API errors.
### Volume Range
- Minimum: Greater than `0` (exclusive)
- Default: `1.0` (normal volume)
- Maximum: `10` (inclusive)
**Note**: Volume parameter is optional. When not specified, defaults to 1.0.
## Remember
- Always use z-ai-web-dev-sdk in backend code only
- **Input text is limited to 1024 characters maximum** - split longer text into chunks
- **Speed must be between 0.5 and 2.0** - values outside this range will cause errors
- **Volume must be greater than 0 and up to 10** - optional parameter with default 1.0
- **Streaming only supports PCM format** - use non-streaming for WAV or MP3 output
- The SDK returns a standard Response object - use `await response.arrayBuffer()`
- Convert ArrayBuffer to Buffer using `Buffer.from(new Uint8Array(arrayBuffer))`
- Handle audio buffers properly when saving to files
- Implement error handling for production applications
- Consider caching for frequently generated content
- Clean up old audio files periodically to manage storage
+25
View File
@@ -0,0 +1,25 @@
import ZAI from "z-ai-web-dev-sdk";
import fs from "fs";
async function main(text: string, outFile: string) {
try {
const zai = await ZAI.create();
const response = await zai.audio.tts.create({
input: text,
voice: "tongtong",
speed: 1.0,
response_format: "wav",
stream: false,
});
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(new Uint8Array(arrayBuffer));
fs.writeFileSync(outFile, buffer);
console.log(`TTS audio saved to ${outFile}`);
} catch (err: any) {
console.error("TTS failed:", err?.message || err);
}
}
main("Hello, world!", "./output.wav");
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 z-ai-web-dev-sdk Skills
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+588
View File
@@ -0,0 +1,588 @@
---
name: VLM
description: Implement vision-based AI chat capabilities using the z-ai-web-dev-sdk. Use this skill when the user needs to analyze images, describe visual content, or create applications that combine image understanding with conversational AI. Supports image URLs and base64 encoded images for multimodal interactions.
license: MIT
---
# VLM(Vision Chat) Skill
This skill guides the implementation of vision chat functionality using the z-ai-web-dev-sdk package, enabling AI models to understand and respond to images combined with text prompts.
## Skills Path
**Skill Location**: `{project_path}/skills/VLM`
this skill is located at above path in your project.
**Reference Scripts**: Example test scripts are available in the `{Skill Location}/scripts/` directory for quick testing and reference. See `{Skill Location}/scripts/vlm.ts` for a working example.
## Overview
Vision Chat allows you to build applications that can analyze images, extract information from visual content, and answer questions about images through natural language conversation.
**IMPORTANT**: z-ai-web-dev-sdk MUST be used in backend code only. Never use it in client-side code.
## Prerequisites
The z-ai-web-dev-sdk package is already installed. Import it as shown in the examples below.
## CLI Usage (For Simple Tasks)
For simple image analysis tasks, you can use the z-ai CLI instead of writing code. This is ideal for quick image descriptions, testing vision capabilities, or simple automation.
### Basic Image Analysis
```bash
# Describe an image from URL
z-ai vision --prompt "What's in this image?" --image "https://example.com/photo.jpg"
# Using short options
z-ai vision -p "Describe this image" -i "https://example.com/image.png"
```
### Analyze Local Images
```bash
# Analyze a local image file
z-ai vision -p "What objects are in this photo?" -i "./photo.jpg"
# Save response to file
z-ai vision -p "Describe the scene" -i "./landscape.png" -o description.json
```
### Multiple Images
```bash
# Analyze multiple images at once
z-ai vision \
-p "Compare these two images" \
-i "./photo1.jpg" \
-i "./photo2.jpg" \
-o comparison.json
# Multiple images with detailed analysis
z-ai vision \
--prompt "What are the differences between these images?" \
--image "https://example.com/before.jpg" \
--image "https://example.com/after.jpg"
```
### With Thinking (Chain of Thought)
```bash
# Enable thinking for complex visual reasoning
z-ai vision \
-p "Count the number of people in this image and describe their activities" \
-i "./crowd.jpg" \
--thinking \
-o analysis.json
```
### Streaming Output
```bash
# Stream the vision analysis
z-ai vision -p "Describe this image in detail" -i "./photo.jpg" --stream
```
### CLI Parameters
- `--prompt, -p <text>`: **Required** - Question or instruction about the image(s)
- `--image, -i <URL or path>`: Optional - Image URL or local file path (can be used multiple times)
- `--thinking, -t`: Optional - Enable chain-of-thought reasoning (default: disabled)
- `--output, -o <path>`: Optional - Output file path (JSON format)
- `--stream`: Optional - Stream the response in real-time
### Supported Image Formats
- PNG (.png)
- JPEG (.jpg, .jpeg)
- GIF (.gif)
- WebP (.webp)
- BMP (.bmp)
### When to Use CLI vs SDK
**Use CLI for:**
- Quick image analysis
- Testing vision model capabilities
- One-off image descriptions
- Simple automation scripts
**Use SDK for:**
- Multi-turn conversations with images
- Dynamic image analysis in applications
- Batch processing with custom logic
- Production applications with complex workflows
## Recommended Approach
For better performance and reliability, use base64 encoding to pass images to the model instead of image URLs.
## Supported Content Types
The Vision Chat API supports three types of media content:
### 1. **image_url** - For Image Files
Use this type for static images (PNG, JPEG, GIF, WebP, etc.)
```typescript
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: imageUrl } }
]
}
```
### 2. **video_url** - For Video Files
Use this type for video content (MP4, AVI, MOV, etc.)
```typescript
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'video_url', video_url: { url: videoUrl } }
]
}
```
### 3. **file_url** - For Document Files
Use this type for document files (PDF, DOCX, TXT, etc.)
```typescript
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'file_url', file_url: { url: fileUrl } }
]
}
```
**Note**: You can combine multiple content types in a single message. For example, you can include both text and multiple images, or text with both an image and a document.
## Basic Vision Chat Implementation
### Single Image Analysis
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function analyzeImage(imageUrl, question) {
const zai = await ZAI.create();
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: question
},
{
type: 'image_url',
image_url: {
url: imageUrl
}
}
]
}
],
thinking: { type: 'disabled' }
});
return response.choices[0]?.message?.content;
}
// Usage
const result = await analyzeImage(
'https://example.com/product.jpg',
'Describe this product in detail'
);
console.log('Analysis:', result);
```
### Multiple Images Analysis
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function compareImages(imageUrls, question) {
const zai = await ZAI.create();
const content = [
{
type: 'text',
text: question
},
...imageUrls.map(url => ({
type: 'image_url',
image_url: { url }
}))
];
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: content
}
],
thinking: { type: 'disabled' }
});
return response.choices[0]?.message?.content;
}
// Usage
const comparison = await compareImages(
[
'https://example.com/before.jpg',
'https://example.com/after.jpg'
],
'Compare these two images and describe the differences'
);
```
### Base64 Image Support
```javascript
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
async function analyzeLocalImage(imagePath, question) {
const zai = await ZAI.create();
// Read image file and convert to base64
const imageBuffer = fs.readFileSync(imagePath);
const base64Image = imageBuffer.toString('base64');
const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: question
},
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${base64Image}`
}
}
]
}
],
thinking: { type: 'disabled' }
});
return response.choices[0]?.message?.content;
}
```
## Advanced Use Cases
### Conversational Vision Chat
```javascript
import ZAI from 'z-ai-web-dev-sdk';
class VisionChatSession {
constructor() {
this.messages = [];
}
async initialize() {
this.zai = await ZAI.create();
}
async addImage(imageUrl, initialQuestion) {
this.messages.push({
role: 'user',
content: [
{
type: 'text',
text: initialQuestion
},
{
type: 'image_url',
image_url: { url: imageUrl }
}
]
});
return this.getResponse();
}
async followUp(question) {
this.messages.push({
role: 'user',
content: [
{
type: 'text',
text: question
}
]
});
return this.getResponse();
}
async getResponse() {
const response = await this.zai.chat.completions.createVision({
messages: this.messages,
thinking: { type: 'disabled' }
});
const assistantMessage = response.choices[0]?.message?.content;
this.messages.push({
role: 'assistant',
content: assistantMessage
});
return assistantMessage;
}
}
// Usage
const session = new VisionChatSession();
await session.initialize();
const initial = await session.addImage(
'https://example.com/chart.jpg',
'What does this chart show?'
);
console.log('Initial analysis:', initial);
const followup = await session.followUp('What are the key trends?');
console.log('Follow-up:', followup);
```
### Image Classification and Tagging
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function classifyImage(imageUrl) {
const zai = await ZAI.create();
const prompt = `Analyze this image and provide:
1. Main subject/category
2. Key objects detected
3. Scene description
4. Suggested tags (comma-separated)
Format your response as JSON.`;
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt
},
{
type: 'image_url',
image_url: { url: imageUrl }
}
]
}
],
thinking: { type: 'disabled' }
});
const content = response.choices[0]?.message?.content;
try {
return JSON.parse(content);
} catch (e) {
return { rawResponse: content };
}
}
```
### OCR and Text Extraction
```javascript
import ZAI from 'z-ai-web-dev-sdk';
async function extractText(imageUrl) {
const zai = await ZAI.create();
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Extract all text from this image. Preserve the layout and formatting as much as possible.'
},
{
type: 'image_url',
image_url: { url: imageUrl }
}
]
}
],
thinking: { type: 'disabled' }
});
return response.choices[0]?.message?.content;
}
```
## Best Practices
### 1. Image Quality and Size
- Use high-quality images for better analysis results
- Optimize image size to balance quality and processing speed
- Supported formats: JPEG, PNG, WebP
### 2. Prompt Engineering
- Be specific about what information you need from the image
- Structure complex requests with numbered lists or bullet points
- Provide context about the image type (photo, diagram, chart, etc.)
### 3. Error Handling
```javascript
async function safeVisionChat(imageUrl, question) {
try {
const zai = await ZAI.create();
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{ type: 'text', text: question },
{ type: 'image_url', image_url: { url: imageUrl } }
]
}
],
thinking: { type: 'disabled' }
});
return {
success: true,
content: response.choices[0]?.message?.content
};
} catch (error) {
console.error('Vision chat error:', error);
return {
success: false,
error: error.message
};
}
}
```
### 4. Performance Optimization
- Cache SDK instance creation when processing multiple images
- Use appropriate image formats (JPEG for photos, PNG for diagrams)
- Consider image preprocessing for large batches
### 5. Security Considerations
- Validate image URLs before processing
- Sanitize user-provided image data
- Implement rate limiting for public-facing APIs
- Never expose SDK credentials in client-side code
## Common Use Cases
1. **Product Analysis**: Analyze product images for e-commerce applications
2. **Document Understanding**: Extract information from receipts, invoices, forms
3. **Medical Imaging**: Assist in preliminary analysis (with appropriate disclaimers)
4. **Quality Control**: Detect defects or anomalies in manufacturing
5. **Content Moderation**: Analyze images for policy compliance
6. **Accessibility**: Generate alt text for images automatically
7. **Visual Search**: Understand and categorize images for search functionality
## Integration Examples
### Express.js API Endpoint
```javascript
import express from 'express';
import ZAI from 'z-ai-web-dev-sdk';
const app = express();
app.use(express.json());
let zaiInstance;
// Initialize SDK once
async function initZAI() {
zaiInstance = await ZAI.create();
}
app.post('/api/analyze-image', async (req, res) => {
try {
const { imageUrl, question } = req.body;
if (!imageUrl || !question) {
return res.status(400).json({
error: 'imageUrl and question are required'
});
}
const response = await zaiInstance.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{ type: 'text', text: question },
{ type: 'image_url', image_url: { url: imageUrl } }
]
}
],
thinking: { type: 'disabled' }
});
res.json({
success: true,
analysis: response.choices[0]?.message?.content
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
initZAI().then(() => {
app.listen(3000, () => {
console.log('Vision chat API running on port 3000');
});
});
```
## Troubleshooting
**Issue**: "SDK must be used in backend"
- **Solution**: Ensure z-ai-web-dev-sdk is only imported and used in server-side code
**Issue**: Image not loading or being analyzed
- **Solution**: Verify the image URL is accessible and returns a valid image format
**Issue**: Poor analysis quality
- **Solution**: Provide more specific prompts and ensure image quality is sufficient
**Issue**: Slow response times
- **Solution**: Optimize image size and consider caching frequently analyzed images
## Remember
- Always use z-ai-web-dev-sdk in backend code only
- The SDK is already installed - import as shown in examples
- Structure prompts clearly for best results
- Handle errors gracefully in production applications
- Consider user privacy when processing images
+57
View File
@@ -0,0 +1,57 @@
import ZAI, { VisionMessage } from 'z-ai-web-dev-sdk';
async function main(imageUrl: string, prompt: string) {
try {
const zai = await ZAI.create();
const messages: VisionMessage[] = [
{
role: 'assistant',
content: [
{ type: 'text', text: 'Output only text, no markdown.' }
]
},
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: imageUrl } }
]
}
];
// const messages: VisionMessage[] = [
// {
// role: 'user',
// content: [
// { type: 'text', text: prompt },
// { type: 'video_url', video_url: { url: imageUrl } }
// ]
// }
// ];
// const messages: VisionMessage[] = [
// {
// role: 'user',
// content: [
// { type: 'text', text: prompt },
// { type: 'file_url', file_url: { url: imageUrl } }
// ]
// }
// ];
const response = await zai.chat.completions.createVision({
model: 'glm-4.6v',
messages,
thinking: { type: 'disabled' }
});
const reply = response.choices?.[0]?.message?.content;
console.log('Vision model reply:');
console.log(reply ?? JSON.stringify(response, null, 2));
} catch (err: any) {
console.error('Vision chat failed:', err?.message || err);
}
}
main("https://cdn.bigmodel.cn/static/logo/register.png", "Please describe this image.");
+328
View File
@@ -0,0 +1,328 @@
---
name: Agent Browser
description: A fast Rust-based headless browser automation CLI with Node.js fallback that enables AI agents to navigate, click, type, and snapshot pages via structured commands.
read_when:
- Automating web interactions
- Extracting structured data from pages
- Filling forms programmatically
- Testing web UIs
metadata: {"clawdbot":{"emoji":"🌐","requires":{"bins":["node","npm"]}}}
allowed-tools: Bash(agent-browser:*)
---
# Browser Automation with agent-browser
## Installation
### npm recommended
```bash
npm install -g agent-browser
agent-browser install
agent-browser install --with-deps
```
### From Source
```bash
git clone https://github.com/vercel-labs/agent-browser
cd agent-browser
pnpm install
pnpm build
agent-browser install
```
## Quick start
```bash
agent-browser open <url> # Navigate to page
agent-browser snapshot -i # Get interactive elements with refs
agent-browser click @e1 # Click element by ref
agent-browser fill @e2 "text" # Fill input by ref
agent-browser close # Close browser
```
## Core workflow
1. Navigate: `agent-browser open <url>`
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
3. Interact using refs from the snapshot
4. Re-snapshot after navigation or significant DOM changes
## Commands
### Navigation
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
```
### Check state
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
```
### Screenshots & PDF
```bash
agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Video recording
```bash
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new recording
```
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --fn "window.ready" # Wait for JS condition
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find first ".item" click
agent-browser find nth 2 "a" text
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth
agent-browser set media dark # Emulate color scheme
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab
agent-browser tab close # Close tab
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
### State management
```bash
agent-browser state save auth.json # Save session state
agent-browser state load auth.json # Load saved state
```
## Example: Form submission
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Example: Authentication with saved state
```bash
# Login once
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "username"
agent-browser fill @e2 "password"
agent-browser click @e3
agent-browser wait --url "/dashboard"
agent-browser state save auth.json
# Later sessions: load saved state
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
## Sessions (parallel browsers)
```bash
agent-browser --session test1 open site-a.com
agent-browser --session test2 open site-b.com
agent-browser session list
```
## JSON output (for parsing)
Add `--json` for machine-readable output:
```bash
agent-browser snapshot -i --json
agent-browser get text @e1 --json
```
## Debugging
```bash
agent-browser open example.com --headed # Show browser window
agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors
agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
agent-browser record start ./debug.webm # Record from current page
agent-browser record stop # Save recording
agent-browser --cdp 9222 snapshot # Connect via CDP
```
## Troubleshooting
- If the command is not found on Linux ARM64, use the full path in the bin folder.
- If an element is not found, use snapshot to find the correct ref.
- If the page is not loaded, add a wait command after navigation.
- Use --headed to see the browser window for debugging.
## Options
- --session <name> uses an isolated session.
- --json provides JSON output.
- --full takes a full page screenshot.
- --headed shows the browser window.
- --timeout sets the command timeout in milliseconds.
- --cdp <port> connects via Chrome DevTools Protocol.
## Notes
- Refs are stable per page load but change on navigation.
- Always snapshot after navigation to get new refs.
- Use fill instead of type for input fields to ensure existing text is cleared.
## Reporting Issues
- Skill issues: Open an issue at https://github.com/TheSethRose/Agent-Browser-CLI
- agent-browser CLI issues: Open an issue at https://github.com/vercel-labs/agent-browser
+157
View File
@@ -0,0 +1,157 @@
---
name: ai-news-collector
description: AI 新闻聚合与热度排序工具。当用户询问 AI 领域最新动态时触发,如:"今天有什么 AI 新闻?""总结一下这周的 AI 动态""最近有什么火的 AI 产品?""AI 圈最近在讨论什么?"。覆盖:新产品发布、研究论文、行业动态、融资新闻、开源项目更新、社区病毒传播现象、AI 工具/Agent 热门项目。输出中文摘要列表,按热度排序,附带原文链接。
---
# AI News Collector
收集、聚合并按热度排序 AI 领域新闻。
## 核心原则
**不要只搜"AI news today"。** 泛搜索返回的是 SEO 聚合页和趋势预测文章,会系统性遗漏社区级病毒传播现象(如开源工具爆火、Meme 级事件)。必须用多维度、分层搜索策略。
## 工作流程
### 1. 多维度分层搜索(最少 8 次,建议 10-12 次)
按以下 **6 个维度** 依次执行搜索,每个维度至少 1 次:
#### 维度 A:周报/Newsletter 聚合(最优先 🔑)
这是信息密度最高的来源,一篇文章可覆盖 10+ 条新闻。
```
搜索词:
- "last week in AI" [当前月份年份]
- "AI weekly roundup" [当前月份年份]
- "the batch AI newsletter"
- site:substack.com AI news [当前月份]
```
发现周报后,用 web_fetch 获取全文,从中提取所有新闻线索。
#### 维度 B:社区热度/病毒传播(关键维度 🔑)
捕捉自下而上的社区爆款,这类信息泛搜索几乎无法触达。
```
搜索词:
- "viral AI tool" OR "viral AI agent"
- "AI trending" site:reddit.com OR site:news.ycombinator.com
- "GitHub trending AI" OR "AI open source trending"
- AI buzzing OR "everyone is talking about" AI
- "most popular AI" this week
```
#### 维度 C:产品发布与模型更新
```
搜索词:
- "AI model release" OR "LLM launch" [当前月份]
- "AI product launch" [当前月份年份]
- OpenAI OR Anthropic OR Google OR Meta AI announcement
- "大模型 发布" OR "AI 新产品"
```
#### 维度 D:融资与商业
```
搜索词:
- "AI startup funding" [当前月份年份]
- "AI acquisition" OR "AI IPO"
- "AI 融资" OR "人工智能投资"
```
#### 维度 E:研究突破
```
搜索词:
- "AI breakthrough" OR "AI paper" [当前月份]
- "state of the art" machine learning
- "AI 论文" OR "机器学习突破"
```
#### 维度 F:监管与政策
```
搜索词:
- "AI regulation" OR "AI policy" [当前月份年份]
- "AI law" OR "AI governance"
- "AI 监管" OR "人工智能法案"
```
### 2. 交叉验证与补漏
初轮搜索完成后,检查是否有遗漏:
- 如果 Newsletter 中提到了某个项目/事件但初轮搜索未覆盖 → 对该项目专项搜索
- 如果同一事件被 3+ 个不同来源提及 → 大概率是热点,深入搜索获取更多细节
- 如果中文媒体和英文媒体的热点完全不同 → 两边都要覆盖
### 3. 搜索关键词设计原则(反模式清单)
| ❌ 不要这样搜 | ✅ 应该这样搜 | 原因 |
|---|---|---|
| "AI news today February 2026" | "AI weekly roundup February 2026" | 前者返回聚合页,后者返回策划内容 |
| "AI news today" | "viral AI tool" + "AI model release" 分开搜 | 泛搜无法覆盖社区现象 |
| "artificial intelligence breaking news" | 按维度分类搜索 | 过于宽泛,返回噪音 |
| 搜索词中加具体年月日 | 用 "this week" "today" "latest" | 日期反而会偏向预测/展望文章 |
| 只搜 3 次就开始写 | 至少 8 次,覆盖 6 个维度 | 3 次搜索覆盖率不到 30% |
### 4. 热度综合判断
基于以下信号评估每条新闻热度(1-5 星):
| 信号 | 权重 | 说明 |
|------|------|------|
| 多家媒体报道同一事件 | ⭐⭐⭐ 高 | 3+ 来源 = 确认热点 |
| 社区病毒传播证据 | ⭐⭐⭐ 高 | GitHub star 暴涨、Twitter 刷屏、HN 首页 |
| 来自权威来源(顶会、大厂官宣) | ⭐⭐⭐ 高 | 但注意大厂 PR 不等于真热点 |
| 实际用户体验分享 | ⭐⭐ 中 | 有人真的在用 > 只是发布了 |
| 技术突破性/影响范围 | ⭐⭐ 中 | |
| 争议性(安全、伦理讨论) | ⭐⭐ 中 | 争议往往说明影响力大 |
| 时效性(越新越热) | ⭐ 中低 | 辅助排序 |
### 5. 输出格式
按热度降序排列,输出 **15-25 条**新闻:
```
## 🔥 AI 新闻速递(YYYY-MM-DD)
### ⭐⭐⭐⭐⭐ 热度最高
1. **[新闻标题]**
> 一句话摘要(不超过 50 字)
> 🔗 [来源名称](URL)
### ⭐⭐⭐⭐ 高热度
2. ...
### ⭐⭐⭐ 中等热度
...
---
📊 本次共收集 XX 条新闻 | 搜索 XX 次 | 覆盖维度:A/B/C/D/E/F | 更新时间:HH:MM
```
### 6. 去重与合并
- 同一事件被多家报道时,合并为一条,选择最权威/详细的来源
- 在摘要中注明"多家媒体报道"以体现热度
- 改名/更名的项目视为同一事件(如 Clawdbot → Moltbot → OpenClaw)
## 推荐新闻源
详见 [references/sources.md](references/sources.md)。
## 注意事项
- 优先使用 HTTPS 链接
- 遇到付费墙/无法访问的内容,标注"需订阅"
- 保持客观,不对新闻内容做主观评价
- 搜索不足 8 次不要开始输出
- 如果某个维度搜索结果为空,换关键词再搜一次
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn7fr165ff9vkkwsqyqrq2nwas80t4ev",
"slug": "ai-news-collectors",
"version": "1.0.0",
"publishedAt": 1770615394344
}
+128
View File
@@ -0,0 +1,128 @@
# AI 新闻源推荐列表
## Newsletter / 周报(信息密度最高 🔑)
| 来源 | 网址 | 特点 |
|------|------|------|
| Last Week in AI | lastweekin.ai / medium.com/last-week-in-ai | 每周最全面的 AI 新闻汇总 |
| The Batch (Andrew Ng) | deeplearning.ai/the-batch | 权威周报 |
| Import AI (Jack Clark) | importai.net | Anthropic 联创的 AI 周报 |
| Platformer (Casey Newton) | platformer.news | 科技深度分析,覆盖 AI |
| The Neuron | theneurondaily.com | 每日 AI 简报 |
| Ben's Bites | bensbites.com | AI 产品/工具为主 |
| AI Tidbits | aitidbits.substack.com | AI 动态精选 |
| TLDR AI | tldr.tech/ai | 每日 AI 简报 |
| Interconnects | interconnects.ai | 深度技术分析 |
## 关键 Substack / 独立博客
| 来源 | 网址 | 特点 |
|------|------|------|
| Gary Marcus | garymarcus.substack.com | AI 批评性分析,常首发安全/争议话题 |
| Simon Willison | simonwillison.net | LLM 安全、工具生态,社区现象第一手报道 |
| Ethan Mollick | oneusefulthing.substack.com | Wharton 教授,AI 应用洞察 |
| Lenny's Newsletter | lennysnewsletter.com | AI 产品/增长 |
| Understanding AI | understandingai.org | 趋势分析与预测 |
| Nathan Lebenz (Cognitive Revolution) | cognitiverevolution.substack.com | AI 深度访谈 |
## 国际主流媒体
| 来源 | 网址 | 侧重 |
|------|------|------|
| TechCrunch AI | techcrunch.com/category/artificial-intelligence | 产品、融资 |
| The Verge AI | theverge.com/ai-artificial-intelligence | 产品、行业 |
| Ars Technica | arstechnica.com/ai | 深度分析 |
| VentureBeat AI | venturebeat.com/ai | 企业 AI |
| MIT Tech Review | technologyreview.com/artificial-intelligence | 研究、趋势 |
| Wired AI | wired.com/tag/artificial-intelligence | 行业影响 |
| CNBC Tech | cnbc.com/technology | 商业+科技交叉 |
| Scientific American | scientificamerican.com | 科学视角 AI |
| The Information | theinformation.com | 深度报道(付费) |
## 国内媒体
| 来源 | 网址 | 侧重 |
|------|------|------|
| 机器之心 | jiqizhixin.com | 技术、论文 |
| 量子位 | qbitai.com | 产品、行业 |
| 36氪 AI | 36kr.com/information/AI | 融资、产品 |
| InfoQ AI | infoq.cn/topic/AI | 技术实践 |
| 新智元 | xinzhiyuan.com | 行业动态 |
| AI 科技评论 | leiphone.com/category/ai | 技术、产品 |
## 社区与论坛(捕捉病毒传播 🔑)
| 来源 | 网址 | 特点 |
|------|------|------|
| Hacker News | news.ycombinator.com | 技术讨论热度,开源项目首发 |
| Reddit r/MachineLearning | reddit.com/r/MachineLearning | 学术前沿 |
| Reddit r/artificial | reddit.com/r/artificial | 综合讨论 |
| Reddit r/LocalLLaMA | reddit.com/r/LocalLLaMA | 本地模型、开源工具热度 |
| Reddit r/singularity | reddit.com/r/singularity | AI 社区热议 |
| Twitter/X | twitter.com | 实时动态,病毒传播首发地 |
| 即刻 AI 圈子 | okjike.com | 国内社区讨论 |
| V2EX | v2ex.com | 开发者视角 |
| 知乎 | zhihu.com | 深度技术讨论 |
## 安全与批评视角
| 来源 | 网址 | 特点 |
|------|------|------|
| Palo Alto Networks Blog | paloaltonetworks.com/blog | AI 安全预警 |
| 1Password Blog | 1password.com/blog | Agent 安全分析 |
| Trail of Bits | blog.trailofbits.com | AI 安全研究 |
## 学术与研究
| 来源 | 网址 | 特点 |
|------|------|------|
| arXiv CS.AI | arxiv.org/list/cs.AI/recent | 最新论文 |
| arXiv CS.LG | arxiv.org/list/cs.LG/recent | 机器学习论文 |
| Papers With Code | paperswithcode.com | 论文+代码 |
| Google AI Blog | ai.googleblog.com | 谷歌研究 |
| OpenAI Blog | openai.com/blog | OpenAI 动态 |
| Anthropic News | anthropic.com/news | Anthropic 动态 |
| DeepMind Blog | deepmind.com/blog | DeepMind 研究 |
| Meta AI | ai.meta.com/blog | Meta 研究 |
| Hugging Face Blog | huggingface.co/blog | 开源生态 |
## 开源项目追踪
| 来源 | 网址 | 特点 |
|------|------|------|
| GitHub Trending | github.com/trending | 热门项目,必查 |
| Product Hunt AI | producthunt.com/topics/artificial-intelligence | 新产品发布 |
| Awesome LLM | github.com/Hannibal046/Awesome-LLM | LLM 资源汇总 |
| Hugging Face Models | huggingface.co/models | 新模型发布 |
## 搜索关键词矩阵
每个维度对应的推荐搜索词:
**Newsletter/周报**:
- `"last week in AI" [月份 年份]`
- `"AI weekly roundup" [月份 年份]`
- `site:substack.com AI news [月份]`
**社区病毒传播**:
- `"viral AI tool" OR "viral AI agent"`
- `"AI trending" site:reddit.com`
- `"GitHub trending AI"`
- `AI "everyone is talking about"`
**产品发布**:
- `"AI model release" OR "LLM launch" [月份]`
- `OpenAI OR Anthropic OR Google announcement`
- `"大模型发布" OR "AI 新产品"`
**研究突破**:
- `"AI breakthrough" OR "state of the art" [月份]`
- `"AI paper" OR "machine learning research"`
**融资商业**:
- `"AI startup funding" [月份 年份]`
- `"AI acquisition" OR "AI IPO"`
**监管政策**:
- `"AI regulation" OR "AI policy" [月份 年份]`
- `"AI law" OR "AI 监管"`
+267
View File
@@ -0,0 +1,267 @@
---
name: aminer-academic-search
version: 1.2.1
author: AMiner
contact: report@aminer.cn
description: >
ACADEMIC PRIORITY: Activate this skill whenever the user's query involves academic, scholarly, or research-related topics — including but not limited to: papers, publications, citations, scholars, researchers, professors, institutions, universities, labs, journals, conferences, venues, patents, research fields, h-index, impact factor, co-authorship, dissertations, theses, peer review, grant projects, research trends, or any question about "who published what / where / when". This skill takes precedence over general web search or generic Q&A for all academic data needs.
Full-featured AMiner skill with 28 APIs and 5 workflows. Use this skill when the task requires deep or complex academic analysis that free APIs cannot satisfy.
Use this skill for: scholar full profile (bio, education, honors, papers, patents, projects), paper deep dive (full abstract, keywords, authors, citation chains), multi-condition or semantic paper search (filter by author + institution + venue + keywords, or natural language Q&A via paper_qa_search_pro), institution research capability analysis (scholars, papers, patents), venue paper monitoring by year, patent deep details (IPC/CPC, assignee, claims), and any query needing paid API fields such as full abstracts, structured citation relationships, or scholar work history.
Do NOT use this skill for simple lookups that free APIs can answer — such as checking a paper title, identifying a scholar by name, normalizing an institution or venue name, or scanning patent trends by keyword. For those, use aminer-free-academic instead.
Routing rule: if the user's question can be fully answered by paper_search, paper_info, person_search, organization_search, venue_search, patent_search, or patent_info alone, route to aminer-free-academic. Otherwise use this skill.
metadata:
{
"openclaw":
{
"requires": {"env": ["AMINER_API_KEY"] },
"primaryEnv": "AMINER_API_KEY"
}
}
---
# AMiner Open Platform Academic Data Query
28 APIs + 5 workflows. Token required: set `AMINER_API_KEY` env var.
- Docs: https://open.aminer.cn/open/docs | Console: https://open.aminer.cn/open/board?tab=control
---
## Mandatory Rules (Critical)
1. **Token Security**: Only check whether `AMINER_API_KEY` exists; never expose the token in plain text anywhere.
2. **Cost Control**: Prefer optimal combined queries; never do indiscriminate full-detail retrieval. Default to top 10 details when the user has not specified a count.
3. **Free-First**: Prefer free APIs unless the user explicitly requires deeper fields; only upgrade to paid APIs when free ones cannot satisfy the need.
4. **Result Links**: Always append an accessible URL after each entity in the output.
5. **Disambiguation**: Scholar ambiguity → filter by `org`/`org_id` or ask user to confirm. Org ambiguity → use `org_disambiguate_pro`. Paper ambiguity → cross-check `year` + `venue_name` + `first_author`.
6. **Cost Report**: After completing all API calls, always output a cost summary to the user showing: each API called, its unit price, number of calls, and the total cost. Format example: `[Cost] ¥X.XX total, N API calls (api_a: ¥X.XX × N, api_b: Free × N)`.
7. **High-Cost Confirmation (≥ ¥5)**: Before executing a workflow or call chain whose estimated total cost is ¥5.00 or more, **stop and ask the user for confirmation** first. Show the planned call chain, estimated cost per step, and the total. Only proceed after the user explicitly agrees. This applies to both predefined workflows (e.g., Scholar Profile ~¥6.00) and ad-hoc multi-step plans.
Entity URL templates (mandatory):
- Paper: `https://www.aminer.cn/pub/{paper_id}`
- Scholar: `https://www.aminer.cn/profile/{scholar_id}`
- Patent: `https://www.aminer.cn/patent/{patent_id}`
- Journal: `https://www.aminer.cn/open/journal/detail/{journal_id}`
---
## Token Check (Required)
Check `AMINER_API_KEY` exists before any API call. Never expose token in plain text.
```bash
[ -z "${AMINER_API_KEY+x}" ] && echo "AMINER_API_KEY missing" || echo "AMINER_API_KEY exists"
```
- If `${AMINER_API_KEY}` exists: proceed. If not: check `--token` parameter. If neither: **stop**, guide user to [Console](https://open.aminer.cn/open/board?tab=control) to generate one.
- If the user provides `AMINER_API_KEY` inline (e.g. "My token is xxx"), accept it for the current session, but recommend setting it as an environment variable for better security.
- Default headers: `Authorization: ${AMINER_API_KEY}`, `X-Platform: openclaw`, `Content-Type: application/json;charset=utf-8` (POST).
---
## Call Guardrails
1. Parameter names and types must match `references/api-catalog.md` exactly.
2. `paper_info` is batch-only: `{"ids": [...]}`. `paper_detail` is single-paper only: one `id`. Never mix them.
3. When multiple details are needed, filter with a low-cost API first, then fetch details for a small set.
4. **Prefer `paper_qa_search_pro`; avoid legacy `paper_qa_search`.** For almost all paper Q&A / topic / filter searches, call `paper_qa_search_pro` first. Use legacy `paper_qa_search` **only** when the user explicitly needs `topic_high` / `topic_middle` / `topic_low` structured OR/AND mode that Pro does not support. Do not default to the legacy endpoint out of habit.
---
## Paper Search API Selection Guide
When the user says "search for papers", determine the goal first:
| API | Focus | Use Case | Cost |
|---|---|---|---|
| `paper_search` | Title search → `paper_id` | Known paper title, locate target | Free |
| `paper_search_pro` | Multi-condition search (author/org/venue/keyword) | Topic search, sort by citations or year | ¥0.01 |
| `paper_qa_search_pro` | Natural language Q&A + rich filters | **Default / prefer this** for semantic & filter search; card + cursor | ¥0.70 |
| `paper_qa_search` | Legacy structured topic keywords | **Rarely**; only for `topic_high/middle/low` OR/AND | ¥0.05 |
| `paper_list_by_keywords` | Multi-keyword batch retrieval | Batch thematic retrieval | ¥0.10 |
| `paper_detail_by_condition` | Year + venue dimension | Journal annual monitoring | ¥0.20 |
Default routing:
1. **Known title**: `paper_search -> paper_detail -> paper_relation`
2. **Conditional filtering**: `paper_search_pro -> paper_detail` (or `paper_qa_search_pro` when NL intent / citation-year soft filters help)
3. **Natural language / topic Q&A**: **always prefer** `paper_qa_search_pro` → if empty, fall back to `paper_search_pro`. **Do not** start with legacy `paper_qa_search`.
4. **Journal annual analysis**: `venue_search -> venue_paper_relation -> paper_detail_by_condition`
Key `paper_qa_search_pro` rules:
- **Default choice** for natural-language paper search and multi-filter retrieval (`authors`/`author_ids`, `organizations`/`organization_ids`, `venues`/`venue_ids`, year/citation ranges, `all_terms`/`any_terms`/`exclude_terms`).
- Page size is **fixed at 10**. Do not send `size`. Paginate with `next_cursor` → next request body is `{"cursor":"..."}` only.
- `sort`: `relevance` / `balanced` / `recent` / `citation`. For “most cited” use `citation`; for “newest” use `recent`.
- Response card fields only: `paper_id`, `title`, `title_zh`, `authors.name`/`name_zh`, `year`. Use `paper_detail` when full abstract/keywords are needed.
- Always append `https://www.aminer.cn/pub/{paper_id}`.
Legacy `paper_qa_search` — use sparingly:
- Call **only** when `topic_high` / `topic_middle` / `topic_low` structured OR/AND is explicitly required; otherwise use Pro.
- `query` and `topic_high/topic_middle/topic_low` are **mutually exclusive**; do not pass both.
- Supports `sci_flag`, `force_citation_sort`, `force_year_sort`, `author_id`, `org_id`, `venue_ids`.
Free-tier screening fields available:
- `paper_search`: `venue_name`, `first_author`, `n_citation_bucket`, `year`
- `paper_info`: `abstract_slice`, `year`, `venue_id`, `author_count`
- `person_search`: `interests`, `n_citation`, `org/org_id`
- `organization_search`: `aliases`
- `venue_search`: `aliases`, `venue_type`
- `patent_search`: `inventor_name`, `app_year`, `pub_year`
- `patent_info`: `app_year`, `pub_year`
---
## Handling Out-of-Workflow Requests
When the user's request falls outside the 5 workflows:
1. Read `references/api-catalog.md` to confirm available APIs, parameters, and response fields.
2. Design the shortest viable call chain: locate ID → supplement details → expand relationships.
3. Do not give up because "no existing workflow fits"; actively compose APIs based on `api-catalog`.
---
## 5 Combined Workflows
### Workflow 1: Scholar Profile (~¥6.00)
**Use Case**: Complete academic profile — bio, research interests, papers, patents, projects.
**Cost note**: Full execution exceeds the ¥5 threshold → **must ask for user confirmation before proceeding** (Rule 7). Show the planned steps and cost. Confirm which sub-modules are needed; skip patents/projects if not requested.
**Call Chain:**
```
Scholar search (name → person_id)
↓
Parallel calls (pick as needed):
├── Scholar details (bio/education/honors) ¥1.00
├── Scholar portrait (interests/work history) ¥0.50
├── Scholar papers (paper list) ¥1.50
├── Scholar patents (patent list) ¥1.50
└── Scholar projects (funding info) ¥1.50
```
Fallback: if `paper_search` yields no results in sub-steps, fall back to `paper_search_pro`.
---
### Workflow 2: Paper Deep Dive (~¥0.12)
**Use Case**: Full paper information and citation chain from a title or keyword.
**Call Chain:**
```
Paper search / Paper search pro (title/keyword → paper_id)
↓
Paper details (abstract/authors/DOI/journal/year/keywords) ¥0.01
↓
Paper citations (cited papers → cited_ids) ¥0.10
↓
(Optional) Batch paper_info for cited papers Free
```
Fallback: if `paper_search` yields no results, fall back to `paper_search_pro`.
---
### Workflow 3: Org Analysis (~¥0.81)
**Use Case**: Institution scholar size, paper output, patent count — for competitive research or partnership evaluation.
**Call Chain:**
```
Org disambiguation pro (raw string → org_id) ¥0.05
↓
Parallel calls:
├── Org details (description/type) ¥0.01
├── Org scholars (scholar list, 10/call) ¥0.50
├── Org papers (paper list, 10/call) ¥0.10
└── Org patents (patent IDs, up to 10,000) ¥0.10
```
> If disambiguation pro returns no ID, fall back to `org_search` (free).
---
### Workflow 4: Venue Papers (~¥0.10 - ¥0.30)
**Use Case**: Track journal papers by year; useful for submission research or trend analysis.
**Call Chain:**
```
Venue search (name → venue_id) Free
↓
(Optional) Venue details (ISSN/type/abbreviation) ¥0.20
↓
Venue papers (venue_id + year → paper_id list) ¥0.10
↓
(Optional) Batch paper detail query
```
---
### Workflow 5: Patent Analysis (~¥0.02)
**Use Case**: Search patents in a technology domain, or retrieve a scholar's/institution's patent portfolio.
**Call Chain (standalone search):**
```
Patent search (query → patent_id) Free
↓
Patent info / Patent details Free / ¥0.01
```
**Call Chain (via scholar/institution):**
```
Scholar search → Scholar patents (patent_id list)
Org disambiguation → Org patents (patent_id list)
↓
Patent info / Patent details
```
---
## Individual API Quick Reference
> Full parameter docs: read `references/api-catalog.md`
| # | Title | Method | Price | API Path (Base: publicapi.chatglm.cn/chatglm_public/skill/aminer) |
|---|------|------|------|------|
| 1 | Paper QA Search Pro | POST | ¥0.70 | `/api/v3/paper/qa/searchPro` |
| 2 | Paper QA Search (legacy) | POST | ¥0.05 | `/api/paper/qa/search` |
| 3 | Scholar Search | POST | Free | `/api/person/search` |
| 4 | Paper Search | GET | Free | `/api/paper/search` |
| 5 | Paper Search Pro | GET | ¥0.01 | `/api/paper/search/pro` |
| 6 | Patent Search | POST | Free | `/api/patent/search` |
| 7 | Org Search | POST | Free | `/api/organization/search` |
| 8 | Venue Search | POST | Free | `/api/venue/search` |
| 9 | Scholar Details | GET | ¥1.00 | `/api/person/detail` |
| 10 | Scholar Projects | GET | ¥1.50 | `/api/project/person/v3/open` |
| 11 | Scholar Papers | GET | ¥1.50 | `/api/person/paper/relation` |
| 12 | Scholar Patents | GET | ¥1.50 | `/api/person/patent/relation` |
| 13 | Scholar Portrait | GET | ¥0.50 | `/api/person/figure` |
| 14 | Paper Info | POST | Free | `/api/paper/info` |
| 15 | Paper Details | GET | ¥0.01 | `/api/paper/detail` |
| 16 | Paper Citations | GET | ¥0.10 | `/api/paper/relation` |
| 17 | Patent Info | GET | Free | `/api/patent/info` |
| 18 | Patent Details | GET | ¥0.01 | `/api/patent/detail` |
| 19 | Org Details | POST | ¥0.01 | `/api/organization/detail` |
| 20 | Org Patents | GET | ¥0.10 | `/api/organization/patent/relation` |
| 21 | Org Scholars | GET | ¥0.50 | `/api/organization/person/relation` |
| 22 | Org Papers | GET | ¥0.10 | `/api/organization/paper/relation` |
| 23 | Venue Details | POST | ¥0.20 | `/api/venue/detail` |
| 24 | Venue Papers | POST | ¥0.10 | `/api/venue/paper/relation` |
| 25 | Org Disambiguation | POST | ¥0.01 | `/api/organization/na` |
| 26 | Org Disambiguation Pro | POST | ¥0.05 | `/api/organization/na/pro` |
| 27 | Paper Batch Query | GET | ¥0.10 | `/api/paper/list/citation/by/keywords` |
| 28 | Paper Details by Year+Venue | GET | ¥0.20 | `/api/paper/platform/allpubs/more/detail/by/ts/org/venue` |
---
## References
- Full API parameter documentation: read `references/api-catalog.md`
- Optional Python client: `scripts/aminer_client.py`
- Test cases: `evals/evals.json`
- Official documentation: https://open.aminer.cn/open/docs
- Console: https://open.aminer.cn/open/board?tab=control
+166
View File
@@ -0,0 +1,166 @@
{
"skill_name": "aminer-academic-search",
"evals": [
{
"id": 1,
"prompt": "I'd like to understand Andrew Ng's complete academic profile, including his research interests, important papers he has published, any patents, and research projects he has participated in. Please use the AMiner API to look this up for me. My token is <YOUR_TOKEN>",
"expected_output": "The estimated cost (~¥6.00) exceeds the ¥5 threshold. Before executing, display the planned call chain and cost breakdown, and ask the user for confirmation. After the user confirms, run the scholar_profile workflow. After completion, output a cost summary.",
"files": [],
"expectations": [
"Displayed the planned call chain with estimated cost BEFORE making paid API calls",
"Estimated total cost exceeds ¥5, so asked the user for confirmation before proceeding",
"After user confirmation, called Scholar Search, Scholar Details, Scholar Portrait, Scholar Papers, etc.",
"Output contains the selected.name field with value Andrew Ng",
"Output contains a cost summary showing each API called and total cost"
]
},
{
"id": 2,
"prompt": "Help me deep-dive into the paper 'Attention Is All You Need' — find it, retrieve its full details (abstract, authors, year), and then see what references it cites. My AMiner token is <YOUR_TOKEN>",
"expected_output": "Run the paper_deep_dive workflow: search for the paper → retrieve details → retrieve citation relationships. Output the paper abstract, author list, DOI, and list of cited papers.",
"files": [],
"expectations": [
"Called a Paper Search API to locate the target paper",
"Called the Paper Details API (paper_detail) to retrieve the abstract",
"Called the Paper Citations API (paper_relation) to retrieve the citation list",
"Output contains the detail field with an abstract sub-field",
"Output contains the citations_count or citations_preview field"
]
},
{
"id": 3,
"prompt": "I need to analyze MIT's research strength — I'd like to know which prominent scholars are affiliated with them, what papers they have published, and what patents they hold. Please use the AMiner API to compile a report for me. Token: <YOUR_TOKEN>",
"expected_output": "Run the org_analysis workflow: use Org Disambiguation Pro to find MIT's institution ID, then fetch institution details, scholar list, and paper list in parallel, and compile into a report.",
"files": [],
"expectations": [
"Called the Org Disambiguation Pro API (org_disambiguate_pro) to obtain the institution ID",
"Called the Org Details API (org_detail) to retrieve MIT's description",
"Called the Org Scholars API (org_person_relation) to retrieve the scholar list",
"Called the Org Papers API (org_paper_relation) to retrieve the paper list",
"Output contains the org_id field",
"Output contains the scholars or papers field"
]
},
{
"id": 4,
"prompt": "Help me look up Andrew Ng's publication trends",
"expected_output": "Detect that the user has not provided a token; immediately stop all subsequent API calls and guide the user to obtain a token from the AMiner Console.",
"files": [],
"expectations": [
"Clearly inform the user that a token is currently missing and AMiner API calls cannot continue",
"No API calls were made",
"Provide the console link https://open.aminer.cn/open/board?tab=control",
"Prompt the user to continue after obtaining a token"
]
},
{
"id": 5,
"prompt": "Help me look up Fei-Fei Li from Stanford University on AMiner and give me her most-cited papers and her most recent papers. My token is <YOUR_TOKEN>",
"expected_output": "Prefer paper_qa_search_pro with author_ids/organization_ids filters. First call with sort=citation, then with sort=recent. Each call returns up to 10 card items (fixed page size).",
"files": [],
"expectations": [
"Called the Scholar Search API (person_search) to confirm Fei-Fei Li's author_id",
"Called paper_qa_search_pro with author_ids (and organization_ids when available)",
"Executed two separate queries: one with sort=citation and one with sort=recent",
"Each result set returns paper_id/title(/title_zh)/year card fields and AMiner URLs"
]
},
{
"id": 6,
"prompt": "Help me check what papers were published in NeurIPS in 2023. My token is <YOUR_TOKEN>",
"expected_output": "Run the venue_papers workflow: venue search to get venue_id, then venue_paper_relation with year=2023 to list papers.",
"files": [],
"expectations": [
"Called the Venue Search API (venue_search) to find NeurIPS venue_id",
"Called the Venue Papers API (venue_paper_relation) with year=2023",
"Output contains a list of paper titles and IDs",
"Each paper entity has an AMiner URL appended"
]
},
{
"id": 7,
"prompt": "Search for patents related to quantum computing chips and show me the details of the top results. My token is <YOUR_TOKEN>",
"expected_output": "Run patent_search to find patents, then patent_detail for the top results. Output patent titles, inventors, IPC codes, and abstracts.",
"files": [],
"expectations": [
"Called the Patent Search API (patent_search) with query about quantum computing",
"Called the Patent Details API (patent_detail) for at least one result",
"Output contains patent titles and patent URLs",
"Output includes structured fields like IPC or assignee"
]
},
{
"id": 8,
"prompt": "帮我查一下唐杰的学术信息,我的 token 是 <YOUR_TOKEN>",
"expected_output": "Handle Chinese input: search for the scholar by Chinese name, return profile information with AMiner URLs.",
"files": [],
"expectations": [
"Called the Scholar Search API (person_search) with a Chinese name",
"Successfully returned scholar results (name, org, interests)",
"Output contains the scholar's AMiner profile URL",
"Response is presented clearly despite Chinese input"
]
},
{
"id": 9,
"prompt": "Find me all details about a scholar named XXXNOEXISTSCHOLAR on AMiner. My token is <YOUR_TOKEN>",
"expected_output": "Handle empty results gracefully: inform the user that no matching scholar was found, suggest refining the query.",
"files": [],
"expectations": [
"Called the Scholar Search API (person_search)",
"Detected that no results were returned",
"Clearly informed the user that no matching scholar was found",
"Did NOT proceed to call paid detail APIs with invalid IDs"
]
},
{
"id": 10,
"prompt": "Help me find all papers about deep learning and give me the full details for every single one. My token is <YOUR_TOKEN>",
"expected_output": "Apply cost control: search returns many results, but only fetch details for the top 10 by default. Inform the user that results were limited to control costs.",
"files": [],
"expectations": [
"Called a paper search API and found many results",
"Did NOT call paper_detail for all results",
"Fetched details for at most 10 papers",
"Informed the user that results were limited and they can request more if needed"
]
},
{
"id": 11,
"prompt": "Call the AMiner venue_search API directly for 'Nature' and show me the raw result. My token is <YOUR_TOKEN>",
"expected_output": "Use the raw API call mode to invoke venue_search with name='Nature'. Return the raw JSON response including venue_id, standard name, aliases, and venue_type.",
"files": [],
"expectations": [
"Called venue_search API directly (raw mode or equivalent curl call)",
"Output includes the raw API response with venue_id and name fields",
"Output includes a venue URL for the result",
"Did not invoke any unnecessary paid APIs"
]
},
{
"id": 12,
"prompt": "I need to find patents held by Shou-Cheng Zhang. My token is <YOUR_TOKEN>",
"expected_output": "Run the scholar_patents workflow: search scholar by name, then retrieve patent list via person_patent_relation, then fetch patent details for top results. Output a cost summary at the end.",
"files": [],
"expectations": [
"Called Scholar Search API (person_search) to find the scholar",
"Called Scholar Patents API (person_patent_relation) to retrieve patent list",
"Called Patent Details API (patent_detail) for at least one patent",
"Output includes patent titles and patent URLs",
"Output includes a cost summary at the end"
]
},
{
"id": 13,
"prompt": "Help me deep-dive into the paper 'BERT: Pre-training of Deep Bidirectional Transformers' — get the full details and citation chain. My token is <YOUR_TOKEN>",
"expected_output": "Run the paper_deep_dive workflow (estimated ~¥0.12, below the ¥5 threshold, so no confirmation needed). After completion, output a cost summary showing each API called and total cost.",
"files": [],
"expectations": [
"Did NOT ask for cost confirmation (estimated cost is well below ¥5)",
"Called paper search, paper detail, and paper relation APIs",
"Output includes paper abstract, authors, and citation information",
"Output includes a cost summary at the end showing APIs called and total cost"
]
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
# aminer-daily-paper
Personalized academic paper recommendation via the AMiner rec5 API. Intended for OpenClaw or similar hosts: the scripts return JSON with Markdown in `reply_text`; the host sends or displays that content.
## Requirements
- Python 3.10+
- `AMINER_API_KEY` environment variable — obtain at https://open.aminer.cn/open/board?tab=control
## Installation
```bash
pip install -r requirements.txt
```
## Usage
```
/aminer-dp
/aminer-dp topics: multimodal agents, tool-use
/aminer-dp scholar: Jie Tang org: Tsinghua
/aminer-dp aminer_author_id: 696259801cb939bc391d3a37 topics: RAG, LLM
recommend me recent papers on multimodal agents
```
The model (running the skill) extracts `topics`, `author_name`, `author_org`, or `aminer_author_id` from natural language input before calling the script.
## How It Works
1. `handle_trigger.py` parses the trigger text and runs `run_pipeline.py` as a subprocess.
2. `run_pipeline.py` calls the AMiner rec5 API and writes `papers_summarized.json` under the output directory.
3. The pipeline returns `final_response: "TEXT"` and `reply_text` (Markdown). No Feishu card builders or `openclaw` dispatch live in this skill.
## API
```
POST https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/v3/paper/rec5
Authorization: <AMINER_API_KEY>
```
Key request fields: `aminer_author_id`, `author_name`, `author_org`, `topics`, `size`, `language_sort`.
At least one of `aminer_author_id`, `author_name`, or `topics` should be provided. When none are given, the API returns personalized recommendations via the token-linked account.
+212
View File
@@ -0,0 +1,212 @@
---
name: aminer-daily-paper
version: 1.1.2
description: "Personalized academic paper recommendation via AMiner rec5 API. Activate this skill whenever the user asks for paper recommendations, whether triggered by /aminer-dp, /skill aminer-dp, or any natural language request such as 'recommend me papers on multimodal agents'. When invoked: extract topics/scholar signals from the input yourself, call handle_trigger.py with structured fields, then present the Markdown in `reply_text` to the user."
user-invocable: true
disable-model-invocation: false
metadata:
{
"openclaw":
{
"emoji": "📚",
"requires": {
"bins": ["python3"],
"env": ["AMINER_API_KEY"]
},
"primaryEnv": "AMINER_API_KEY"
}
}
---
# aminer-daily-paper
Personalized paper recommendation via AMiner rec5 API. Token required: set `AMINER_API_KEY` env var.
- Docs: https://open.aminer.cn/open/docs | Console: https://open.aminer.cn/open/board?tab=control
**When to activate**: any time the user asks for paper recommendations — explicit command (`/aminer-dp ...`) or natural language (`recommend me papers on RAG`, `帮我推荐最近的多模态论文`).
---
## Pre-flight: Check Required Environment Variables
**`AMINER_API_KEY`** — Always required. Check before calling the script:
```bash
[ -z "${AMINER_API_KEY+x}" ] && echo "AMINER_API_KEY missing" || echo "AMINER_API_KEY exists"
```
If missing, stop and tell the user:
> `AMINER_API_KEY` is not set. Please obtain a token at https://open.aminer.cn and set it as an environment variable.
No other environment variables are required.
---
## API Endpoint
```
POST https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/v3/paper/rec5
Authorization: ${AMINER_API_KEY}
Content-Type: application/json;charset=utf-8
```
### Request Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `author_name` | string | conditional | Scholar name (English). The backend resolves it to a scholar ID via person search. |
| `author_org` | string | optional | Scholar institution (English full name). Required for disambiguation when the name is ambiguous. |
| `topics` | string[] | conditional | Research topic phrases. **Use the user’s wording** (Chinese, English, or mixed). The API accepts multi-language topic strings. |
| `size` | int | optional | Number of papers per call (1–20). Omit to let the model decide (see below). |
| `offset` | int | optional | Pagination offset (0–100, default 0). |
| `language_sort` | string | optional | `zh` or `en` **only when the user explicitly asks** for Chinese- or English-biased ranking (e.g. “优先中文论文” / “prefer English papers”). Otherwise omit; the request will not include this field. |
At least one of `author_name` or `topics` should be provided. When none are given, the API returns personalized recommendations based on the account associated with `AMINER_API_KEY`.
### Response Structure
```json
{
"code": 200,
"success": true,
"data": [{
"offset": 0,
"size": 5,
"total": 32,
"papers": [{
"paper_id": "...",
"arxiv_id": "",
"title": "...",
"year": 2026,
"authors": ["Author A", "Author B"],
"keywords": ["kw1", "kw2"],
"summary": "...",
"structured_summary": {
"research_problem": "...",
"research_challenge": "...",
"research_method": "...",
"experimental_results": ""
},
"famous_authors": [],
"aminer_author_profiles": [],
"author_entries": [],
"links": {
"aminer": "https://www.aminer.cn/pub/{paper_id}",
"arxiv": "",
"pdf": ""
},
"paper_url": "https://www.aminer.cn/pub/{paper_id}",
"source": "local_rec5"
}]
}]
}
```
---
## Input Formats
Structured commands or plain natural language — both are valid.
```
/aminer-dp
/aminer-dp topics: multimodal agents, tool-use
/aminer-dp scholar: Jie Tang org: Tsinghua papers: OAG-Bench | RPC-Bench
recommend me recent papers on RAG
```
`/aminer-dp` with no parameters calls the API with only the token — the API uses `AMINER_API_KEY` to identify the account and returns personalized recommendations.
**Natural language input** — you (the model) must parse it into fields before calling the script. **Critical for `topics`:**
1. **`topics` — do not “translate away” the user’s intent**
- If the user already wrote `topics:` in the trigger (e.g. `具身智能`, `环境保护`), pass **those exact strings** into `handle_trigger.py`’s `--text`. **Do not** replace them with unrelated English terms (e.g. do **not** map arbitrary topics to “Knowledge Distillation”, “Smart agriculture”, or any other field the user did not ask for).
- If you add English for retrieval, it must be a **faithful** alias of the same concept (e.g. 具身智能 → `embodied intelligence`, 环境保护 → `environmental protection`). When in doubt, **keep the user’s original words** and do not invent synonyms.
- **Never** change the user’s topic into a different research area.
2. **Scholars and institutions (person search still English-oriented)**
- `author_name` / `author_org`: use commonly used **English** forms when resolving scholars (e.g. `Jie Tang`, `Tsinghua University`), expand well-known institution abbreviations to full official names, and add `author_org` when the name is ambiguous. If you cannot map a name safely, ask the user.
3. **`language_sort`** — Put `language_sort: zh` or `language_sort: en` in the trigger **only if** the user clearly wants recommendations ranked with a **Chinese** or **English** preference. If they did not ask, **do not** add it (the API call omits `language_sort`).
4. Decide `size` and whether to make multiple calls (see **Call Strategy**).
5. Reconstruct the trigger, then call `handle_trigger.py`.
Example (Chinese topics — **keep as-is**):
- User: `/aminer-dp topics: 具身智能, 环境保护`
- You call: `handle_trigger.py --text "/aminer-dp topics: 具身智能, 环境保护"`
(Do **not** rewrite `topics` into unrelated English.)
Example:
- User: `/aminer-dp 我做多模态智能体和 tool-use,帮我推荐最近论文`
- You extract: `topics: multimodal agents, tool-use`
- You call: `handle_trigger.py --text "/aminer-dp topics: multimodal agents, tool-use size: 5"`
Example (scholar):
- User: `/aminer-dp 我是唐杰,清华大学,做多模态和知识图谱`
- You extract: `scholar: Jie Tang, org: Tsinghua University, topics: multimodal, knowledge graph`
- You call: `handle_trigger.py --text "/aminer-dp scholar: Jie Tang org: Tsinghua University topics: multimodal, knowledge graph"`
Example (ambiguous name, ask user):
- User: `/aminer-dp 推荐张伟方向的论文`
- You: "张伟是一个常见名字,请提供机构信息以便精确匹配,例如:张伟,北京大学。或者直接提供 aminer_author_id。"
**`papers` field**: representative paper titles (e.g. `papers: OAG-Bench | RPC-Bench`) accompany `scholar`/`author_name` for disambiguation context. They do not map directly to an API field.
---
## Call Strategy
You decide `size` and whether to make multiple calls based on the input:
| Scenario | Action |
|----------|--------|
| Single topic or scholar, casual request | 1 call, omit `size` (default 10) |
| User explicitly asks for a number (e.g. "give me 5") | 1 call, honor the number (max 20) |
| Multiple distinct topics (e.g. RAG + multimodal agents) | 1 call per topic group, `size: 5` each |
| Broad open-ended request with no topics | 1 call, omit `size` (default 10) |
**Multi-call rules:**
- Call `handle_trigger.py` once per topic group, passing a focused `topics:` subset each time.
- Keep each `topics:` list to 1–3 closely related terms for precision.
- Make calls sequentially; present all results together after all calls finish.
- Total papers across all calls should not exceed ~15 unless the user asks for more.
---
## Execution
Only one supported entrypoint:
```bash
python3 "{baseDir}/scripts/handle_trigger.py" \
--base-dir "{baseDir}" \
--text "<trigger text with explicit fields>" \
[--config /path/to/config.yaml]
```
- `--text`: reconstructed trigger with explicit fields (`topics:`, `scholar:`, etc.)
- `--config`: optional path to a YAML config (defaults to `{baseDir}/config.yaml` when the file exists, via the runtime copy under `outputs/`)
`handle_trigger.py` parses the fields, calls the rec5 API, and returns JSON including `reply_text` (Markdown) for you to show to the user.
---
## Contract
- Every explicit invocation is a new run.
- Do not answer with status-only text.
- Do not search, install, or repair skills.
- After running `handle_trigger.py`, check `final_response` in the JSON output:
- `TEXT` — Normal path. Present `reply_text` (Markdown) to the user. Optional: you may still refine wording for the active channel; `prompts/enrich.md` is a reference for Chinese enrichment if you want richer copy.
- Any error → report the `reply_text` (or error detail) to the user.
**Note:** The skill only returns JSON with `reply_text`; it does not implement channel-specific sending.
---
## Error Handling
- `AMINER_API_KEY` missing → stop, prompt user to set it.
- No profile input → prompt user to provide topics, scholar name, or `aminer_author_id`.
- API error → report the error stage; do not fall back to other skills.
+52
View File
@@ -0,0 +1,52 @@
---
description: AMiner 个性化论文推荐 — 按主题或学者画像
argument-hint: [topics: 主题1, 主题2 | scholar: 姓名 org: 机构 | 自然语言]
allowed-tools: Read, Bash, Glob, Grep
---
# /aminer-dp — AMiner Daily Paper
User invoked the AMiner daily paper recommendation skill with the following arguments:
```
$ARGUMENTS
```
## Your task
Strictly follow `${CLAUDE_PLUGIN_ROOT}/SKILL.md` (English) or `${CLAUDE_PLUGIN_ROOT}/SKILL.zh.md` (Chinese). Key rules summarized below — but read the SKILL file if you need detail.
### 1. Pre-flight
Verify the `AMINER_API_KEY` env var is set:
```bash
[ -z "${AMINER_API_KEY+x}" ] && echo "AMINER_API_KEY missing" || echo "AMINER_API_KEY exists"
```
If missing, stop and tell the user to set it (token from <https://open.aminer.cn>). Do not call the script.
### 2. Parse `$ARGUMENTS` into structured fields
Extract any of: `topics`, `scholar` / `author_name`, `org` / `author_org`, `papers`, `size`, `language_sort`.
**Critical rules — do NOT violate:**
- **`topics`**: keep the user's exact wording. If they wrote `具身智能, 环境保护`, pass those Chinese strings through. **Never** translate them into unrelated English fields (e.g. don't map them to "Knowledge Distillation" or "Smart agriculture"). If you add an English alias, it must be a faithful translation of the same concept (e.g. 具身智能 → embodied intelligence).
- **`language_sort`**: include `zh` or `en` **only when the user explicitly asks** for Chinese-/English-biased ranking (e.g. "优先中文论文" / "prefer English papers"). Otherwise omit it entirely.
- **`scholar` / `org`**: use English-canonical names where reasonable (e.g. `Jie Tang`, `Tsinghua University`). If a Chinese name is ambiguous and no org is given, ask the user before guessing.
- If `$ARGUMENTS` is empty, call the script with no extra fields — the API will return personalized recs based on the API key.
### 3. Run the entrypoint
Reconstruct the trigger and execute (one call per topic group; ≤3 closely related terms per group):
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/handle_trigger.py" \
--base-dir "${CLAUDE_PLUGIN_ROOT}" \
--text "/aminer-dp <reconstructed fields>"
```
### 4. Present the result
Parse the JSON output. When `final_response == "TEXT"`, render the `reply_text` (Markdown) directly to the user. On error, surface the error message; do not fall back to other skills.
+40
View File
@@ -0,0 +1,40 @@
# Paper Enrichment Prompt
You are enriching academic papers with Chinese content before they are sent as Feishu cards.
For each paper in the `papers` array that is missing a Chinese summary (i.e., the `summary` field contains only English text or is empty), generate:
1. **summary** — A concise Chinese summary (1-2 sentences, starting with "本文"). Describe the core contribution and significance. Keep English terms (e.g., RAG, LLM, Transformer) as-is.
2. **keywords** — 2-4 Chinese keywords. Keep well-known English acronyms as-is.
3. **comment** — If the paper's venue is a well-known conference/journal, annotate its tier, e.g., "已发表在 AAAI(CCF-A)". Leave empty if unknown.
Rules:
- Do NOT fabricate information not present in the paper's title/abstract.
- Do NOT generate or modify `famous_authors`.
- If the abstract is missing or too short to summarize, write "摘要信息不足,请查看原文。"
- Preserve all other fields unchanged.
## Input
The papers are in `papers_summarized.json` at the path provided. The file structure:
```json
{
"status": "success",
"profile_topics": ["topic1", "topic2"],
"papers": [
{
"title": "...",
"summary": "...(may be English-only)...",
"keywords": ["kw1", "kw2"],
"authors": ["..."],
"comment": "",
...other fields preserved as-is...
}
]
}
```
## Output
Write the enriched data back to the same `papers_summarized.json` path, preserving all fields. Only update `summary`, `keywords`, and `comment` for papers that need enrichment.
+1
View File
@@ -0,0 +1 @@
PyYAML>=6.0
+1
View File
@@ -0,0 +1 @@
"""aminer_rec pipeline package."""
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
data,
ensure_ascii=False,
indent=2,
default=lambda value: value.isoformat() if isinstance(value, datetime) else str(value),
)
+ "\n",
encoding="utf-8",
)
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
DEFAULT_TOP_K = 10
AMINER_AUTHOR_URL_TEMPLATE = "https://www.aminer.cn/profile/{author_id}"
AMINER_PAPER_URL_TEMPLATE = "https://www.aminer.cn/pub/{paper_id}"
DEFAULT_REC5_URL = "https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/v3/paper/rec5"
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
import yaml
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _split_topics(text: str) -> list[str]:
pieces = re.split(r"[,,;/;、\n]+", text)
topics: list[str] = []
for piece in pieces:
topic = _clean_text(piece)
if topic and topic not in topics:
topics.append(topic)
return topics
def _split_papers(text: str) -> list[str]:
pieces = re.split(r"[|\n;;]+", text)
papers: list[str] = []
for piece in pieces:
paper = _clean_text(piece)
if paper and paper not in papers:
papers.append(paper)
return papers
def _extract_command_text(raw_text: str) -> str:
lines = [line.strip() for line in str(raw_text or "").splitlines() if line.strip()]
for line in reversed(lines):
match = re.search(r"(/(?:skill\s+)?aminer[-_]dp\b.*)$", line, flags=re.IGNORECASE)
if match:
return match.group(1).strip()
return str(raw_text or "")
FIELD_LABELS = {
"aminer_author_id": ["aminer_author_id"],
"topics": ["topics", "topic", "方向", "研究方向"],
"scholar_name": ["scholar", "name", "author", "学者", "作者"],
"scholar_org": ["org", "organization", "affiliation", "机构", "单位"],
"paper_titles": ["paper", "papers", "代表作", "论文"],
"papers_file": ["papers_file", "source_file", "profile_file", "文件", "路径"],
"language_sort": ["language_sort"],
"size": ["size"],
}
GENERIC_REQUEST_PATTERNS = [
r"帮我推荐(?:一下)?论文",
r"推荐(?:一下)?论文",
r"推荐一些论文",
r"给我推荐(?:一下)?论文",
r"推荐最近论文",
r"想看论文",
]
ORG_HINT_PATTERNS = (
r"大学",
r"学院",
r"研究院",
r"研究所",
r"实验室",
r"中心",
r"University",
r"College",
r"Institute",
r"Laboratory",
r"Lab\b",
r"School",
r"Department",
)
MAX_TOPICS = 8
MAX_TOPIC_LENGTH = 80
MAX_PAPER_TITLES = 8
MAX_PAPER_TITLE_LENGTH = 300
MAX_SCHOLAR_NAME_LENGTH = 80
MAX_SCHOLAR_ORG_LENGTH = 160
MAX_FREE_TEXT_LENGTH = 600
ALLOWED_PAPERS_FILE_SUFFIXES = {".json"}
TOPIC_STOPWORDS = {
"论文",
"推荐",
"一下",
"推荐一下",
"相关论文",
"papers",
"paper",
"recommend",
"recommendation",
"research papers",
}
TOPIC_STOPWORDS_CASEFOLD = {item.casefold() for item in TOPIC_STOPWORDS}
def _capture_field(command_body: str, field_name: str) -> str:
labels = FIELD_LABELS[field_name]
all_labels = [re.escape(label) for values in FIELD_LABELS.values() for label in values]
pattern = rf"(?:{'|'.join(re.escape(label) for label in labels)})\s*[::]\s*(.+?)(?=\s*(?:{'|'.join(all_labels)})\s*[::]|$)"
match = re.search(pattern, command_body, flags=re.IGNORECASE | re.S)
return _clean_text(match.group(1)) if match else ""
def _truncate_text(value: Any, max_length: int) -> str:
cleaned = _clean_text(value)
if len(cleaned) <= max_length:
return cleaned
return cleaned[:max_length].strip()
def _normalize_topics_for_interface(values: list[Any]) -> list[str]:
topics: list[str] = []
for value in list(values or []):
topic = _truncate_text(value, MAX_TOPIC_LENGTH)
if topic and topic not in topics:
topics.append(topic)
if len(topics) >= MAX_TOPICS:
break
return topics
def _normalize_paper_titles_for_interface(values: list[Any]) -> list[str]:
paper_titles: list[str] = []
for value in list(values or []):
paper_title = _truncate_text(value, MAX_PAPER_TITLE_LENGTH)
if paper_title and paper_title not in paper_titles:
paper_titles.append(paper_title)
if len(paper_titles) >= MAX_PAPER_TITLES:
break
return paper_titles
def _resolve_interface_papers_file(base_dir: Path, path_text: str) -> str:
cleaned = _clean_text(path_text)
if not cleaned:
return ""
candidate = Path(cleaned).expanduser()
resolved_base_dir = base_dir.resolve()
resolved_candidate = (resolved_base_dir / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
try:
resolved_candidate.relative_to(resolved_base_dir)
except ValueError as exc:
raise ValueError("papers_file_outside_base_dir") from exc
if resolved_candidate.suffix.lower() not in ALLOWED_PAPERS_FILE_SUFFIXES:
raise ValueError("unsupported_papers_file")
return str(resolved_candidate)
def _normalize_interface_payload(parsed: dict[str, Any], *, base_dir: Path) -> dict[str, Any]:
normalized = dict(parsed)
raw_uid = _clean_text(parsed.get("raw_aminer_author_id"))
if raw_uid and not re.fullmatch(r"[0-9a-fA-F]{24}", raw_uid):
raise ValueError("invalid_aminer_author_id")
normalized["aminer_author_id"] = _clean_text(parsed.get("aminer_author_id"))
normalized["topics"] = _normalize_topics_for_interface(list(parsed.get("topics") or []))
normalized["scholar_name"] = _truncate_text(parsed.get("scholar_name"), MAX_SCHOLAR_NAME_LENGTH)
normalized["scholar_org"] = _truncate_text(parsed.get("scholar_org"), MAX_SCHOLAR_ORG_LENGTH)
normalized["paper_titles"] = _normalize_paper_titles_for_interface(list(parsed.get("paper_titles") or []))
normalized["papers_file"] = _resolve_interface_papers_file(base_dir, str(parsed.get("papers_file") or ""))
normalized["free_text"] = _truncate_text(parsed.get("free_text"), MAX_FREE_TEXT_LENGTH)
lang = _clean_text(parsed.get("language_sort"))
normalized["language_sort"] = lang if lang in {"zh", "en"} else ""
raw_size = _clean_text(parsed.get("size"))
try:
normalized["size"] = max(1, min(int(raw_size), 20)) if raw_size else 0
except (ValueError, TypeError):
normalized["size"] = 0
if not normalized["topics"] and normalized["free_text"]:
normalized["topics"] = _infer_topics_from_free_text(normalized["free_text"])
return normalized
def _strip_explicit_fields(command_body: str) -> str:
all_labels = [re.escape(label) for values in FIELD_LABELS.values() for label in values]
pattern = rf"(?:{'|'.join(all_labels)})\s*[::]\s*.+?(?=\s*(?:{'|'.join(all_labels)})\s*[::]|$)"
cleaned = re.sub(pattern, " ", command_body, flags=re.IGNORECASE | re.S)
return _clean_text(cleaned)
def _remove_generic_request_phrases(text: str) -> str:
cleaned = str(text or "")
for pattern in GENERIC_REQUEST_PATTERNS:
cleaned = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"[。!?!?.,,;;、\s]+", " ", cleaned)
return _clean_text(cleaned)
def _normalize_topic_candidate(text: str) -> str:
candidate = _clean_text(text)
if not candidate:
return ""
candidate = re.sub(r'^[\'"`“”‘’]+|[\'"`“”‘’]+$', "", candidate)
candidate = re.sub(r"^(?:关于|研究(?:方向)?|方向|领域|做|关注|topic(?:s)?|about|on)\s*", "", candidate, flags=re.IGNORECASE)
candidate = re.sub(r"\s*(?:相关|方向|领域|论文|papers?|research)\s*$", "", candidate, flags=re.IGNORECASE)
candidate = _clean_text(candidate)
if not candidate:
return ""
if candidate.casefold() in TOPIC_STOPWORDS_CASEFOLD:
return ""
return _truncate_text(candidate, MAX_TOPIC_LENGTH)
def _infer_topics_from_free_text(text: str) -> list[str]:
cleaned = _clean_text(text)
if not cleaned:
return []
for pattern in GENERIC_REQUEST_PATTERNS:
cleaned = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\b(?:recommend|papers?|please|find|show)\b", " ", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"(?:给我|帮我|请|想看|想要|推荐|看看)\s*", " ", cleaned)
cleaned = _clean_text(cleaned)
if not cleaned:
return []
parts = re.split(r"[,,;/;、\n]+|\s+(?:and|or|以及|和|与|及)\s+", cleaned, flags=re.IGNORECASE)
topics: list[str] = []
seen: set[str] = set()
for part in parts:
candidate = _normalize_topic_candidate(part)
if not candidate:
continue
key = candidate.casefold()
if key in seen:
continue
seen.add(key)
topics.append(candidate)
if len(topics) >= MAX_TOPICS:
break
if topics:
return topics
fallback = _normalize_topic_candidate(cleaned)
return [fallback] if fallback else []
def _infer_scholar_from_free_text(text: str) -> tuple[str, str, str]:
normalized = _clean_text(text)
if not normalized:
return "", "", ""
patterns = [
r"^我(?:是|叫)\s*(?P<name>[^,,。;;、\s]{2,20})\s*[,,、]\s*(?P<org>[^。;;,,]{2,60})",
r"^本人(?:是)?\s*(?P<name>[^,,。;;、\s]{2,20})\s*[,,、]\s*(?P<org>[^。;;,,]{2,60})",
r"^我是\s*(?P<org>[^,,。;;]{2,60})\s*的\s*(?P<name>[^,,。;;、\s]{2,20})",
]
for pattern in patterns:
match = re.search(pattern, normalized, flags=re.IGNORECASE)
if not match:
continue
scholar_name = _clean_text(match.groupdict().get("name"))
scholar_org = _clean_text(match.groupdict().get("org"))
if not scholar_name:
continue
residual = _clean_text(normalized[match.end() :])
residual = _remove_generic_request_phrases(residual)
scholar_org = re.split(r"[。!?!?.;;]", scholar_org, maxsplit=1)[0].strip()
scholar_org = _remove_generic_request_phrases(scholar_org)
return scholar_name, scholar_org, residual
bare_match = re.search(
r"^(?P<name>[A-Za-z][A-Za-z .'-]{1,60}|[\u4e00-\u9fff·]{2,20})\s*[,,、]\s*(?P<org>[^。;;,,\n]{2,80})",
normalized,
flags=re.IGNORECASE,
)
if bare_match:
scholar_name = _clean_text(bare_match.group("name"))
scholar_org = _clean_text(bare_match.group("org"))
org_hint_pattern = "|".join(ORG_HINT_PATTERNS)
if scholar_name and scholar_org and re.search(org_hint_pattern, scholar_org, flags=re.IGNORECASE):
residual = _clean_text(normalized[bare_match.end() :])
residual = _remove_generic_request_phrases(residual)
scholar_org = re.split(r"[。!?!?.;;]", scholar_org, maxsplit=1)[0].strip()
scholar_org = _remove_generic_request_phrases(scholar_org)
return scholar_name, scholar_org, residual
return "", "", normalized
def parse_trigger_text(text: str) -> dict[str, Any]:
raw_text = str(text or "")
command_text = _extract_command_text(raw_text)
normalized = _clean_text(command_text)
is_trigger = bool(re.search(r"^/(skill\s+)?aminer[-_]dp\b", normalized, flags=re.IGNORECASE))
body = re.sub(r"^/(skill\s+)?aminer[-_]dp\b", "", command_text, flags=re.IGNORECASE).strip()
uid_match = re.search(r"aminer_author_id\s*[::]\s*([0-9a-fA-F]{24})", body, flags=re.IGNORECASE)
uid = uid_match.group(1) if uid_match else ""
scholar_name = _capture_field(body, "scholar_name")
scholar_org = _capture_field(body, "scholar_org")
free_text = _strip_explicit_fields(body)
if not scholar_name:
inferred_name, inferred_org, residual = _infer_scholar_from_free_text(free_text)
if inferred_name:
scholar_name = inferred_name
if inferred_org and not scholar_org:
scholar_org = inferred_org
free_text = residual
return {
"raw_text": raw_text,
"command_text": command_text,
"raw_aminer_author_id": _capture_field(body, "aminer_author_id"),
"aminer_author_id": uid,
"topics": _split_topics(_capture_field(body, "topics")),
"scholar_name": scholar_name,
"scholar_org": scholar_org,
"paper_titles": _split_papers(_capture_field(body, "paper_titles")),
"papers_file": _capture_field(body, "papers_file"),
"language_sort": _capture_field(body, "language_sort"),
"size": _capture_field(body, "size"),
"free_text": free_text,
"is_trigger": is_trigger,
}
def _load_config(base_dir: Path, config_path: Path | None) -> dict[str, Any]:
if config_path and config_path.exists():
return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
default_path = base_dir / "config.yaml"
if default_path.exists():
return yaml.safe_load(default_path.read_text(encoding="utf-8")) or {}
return {}
def _run_pipeline(
*,
base_dir: Path,
output_dir: Path,
config_path: Path | None,
aminer_author_id: str,
topics: list[str],
scholar_name: str,
scholar_org: str,
paper_titles: list[str],
papers_file: str,
free_text: str,
language_sort: str,
size: int,
) -> dict[str, Any]:
command = [
sys.executable,
str(base_dir / "scripts" / "run_pipeline.py"),
"--base-dir",
str(base_dir),
"--output-dir",
str(output_dir),
]
if config_path is not None:
command.extend(["--config", str(config_path)])
if aminer_author_id.strip():
command.extend(["--aminer-author-id", aminer_author_id.strip()])
if language_sort.strip():
command.extend(["--language-sort", language_sort.strip()])
if size > 0:
command.extend(["--size", str(size)])
if topics:
command.extend(["--topics", *topics])
if scholar_name.strip():
command.extend(["--scholar-name", scholar_name.strip()])
if scholar_org.strip():
command.extend(["--scholar-org", scholar_org.strip()])
for paper_title in paper_titles:
if paper_title.strip():
command.extend(["--paper-title", paper_title.strip()])
if papers_file.strip():
command.extend(["--papers-file", papers_file.strip()])
if free_text.strip():
command.extend(["--free-text", free_text.strip()])
completed = subprocess.run(command, capture_output=True, text=True, check=False)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "run_pipeline failed"
raise RuntimeError(detail)
return json.loads(completed.stdout)
def _compact_pipeline_error(detail: str) -> str:
text = _clean_text(detail)
if not text:
return "unknown_error"
if "Traceback" not in text:
return text
lines = [line.strip() for line in str(detail or "").splitlines() if line.strip()]
for line in reversed(lines):
if line.startswith("RuntimeError:"):
return _clean_text(line.split("RuntimeError:", 1)[1])
return _clean_text(lines[-1]) if lines else text
def handle_trigger(
*,
base_dir: Path,
text: str,
config_path: Path | None = None,
) -> dict[str, Any]:
parsed = parse_trigger_text(text)
try:
parsed = _normalize_interface_payload(parsed, base_dir=base_dir)
except ValueError as exc:
detail = _clean_text(str(exc))
if detail == "invalid_aminer_author_id":
reply_text = "输入里的 `aminer_author_id` 不合法。请提供 24 位十六进制字符串,例如:`/aminer-dp aminer_author_id: 696259801cb939bc391d3a37 topics: 多模态, 智能体`。"
elif detail == "papers_file_outside_base_dir":
reply_text = "出于安全限制,`papers_file` 只能指向当前 skill 目录内的 JSON 文件,不能引用目录外路径。"
elif detail == "unsupported_papers_file":
reply_text = "`papers_file` 目前只支持 `.json` 文件。"
else:
reply_text = f"输入不符合接口约束:{detail}"
return {
"status": "success",
"mode": "invalid_input",
"final_response": "TEXT",
"reply_text": reply_text,
}
has_profile_input = bool(
parsed["aminer_author_id"]
or parsed["topics"]
or parsed["scholar_name"]
or parsed["scholar_org"]
or parsed["paper_titles"]
or parsed["papers_file"]
or parsed["free_text"]
)
if not parsed["is_trigger"] and not has_profile_input:
return {
"status": "success",
"mode": "help",
"final_response": "TEXT",
"reply_text": "请发送 `/aminer-dp`(直接推荐),或加上 `topics: 多模态, 智能体`、`scholar: Jie Tang` 等参数精确推荐,也可以直接描述研究方向。",
}
output_dir = base_dir / "outputs"
output_dir.mkdir(parents=True, exist_ok=True)
loaded_config = _load_config(base_dir, config_path)
runtime_config_path = output_dir / "runtime_config.yaml"
runtime_config_path.write_text(yaml.safe_dump(loaded_config, allow_unicode=True, sort_keys=False), encoding="utf-8")
try:
pipeline_result = _run_pipeline(
base_dir=base_dir,
output_dir=output_dir,
config_path=runtime_config_path,
aminer_author_id=parsed["aminer_author_id"],
topics=parsed["topics"],
scholar_name=parsed["scholar_name"],
scholar_org=parsed["scholar_org"],
paper_titles=parsed["paper_titles"],
papers_file=parsed["papers_file"],
free_text=parsed["free_text"],
language_sort=parsed["language_sort"],
size=parsed["size"],
)
except Exception as exc:
detail = _compact_pipeline_error(str(exc).strip())
if parsed["aminer_author_id"] and not parsed["topics"] and ("profile_unavailable" in detail or "missing_topics" in detail or "no_bind_papers_or_experts_topic" in detail):
return {
"status": "success",
"mode": "onboarding_prompt",
"final_response": "TEXT",
"reply_text": f"我还没能从这个 `aminer_author_id` 归纳出稳定研究方向,请补充研究方向或代表论文,例如:`/aminer-dp aminer_author_id: {parsed['aminer_author_id']} topics: 多模态, 智能体`。",
}
if parsed["scholar_name"] and ("profile_unavailable" in detail or "missing_topics" in detail):
return {
"status": "success",
"mode": "onboarding_prompt",
"final_response": "TEXT",
"reply_text": "我查到了学者线索,但还没成功归纳出稳定研究方向。请补充 `topics`、代表论文标题,或直接描述方向,例如:`/aminer-dp 我是张帆进,清华大学,做多模态智能体和 tool-use`。",
}
return {
"status": "success",
"mode": "error",
"final_response": "TEXT",
"reply_text": f"推荐流程执行失败,出错阶段:{detail}",
}
result: dict[str, Any] = {
"status": "success",
"mode": pipeline_result.get("mode", "success"),
"parsed_input": parsed,
"artifacts": {
"runtime_config": str(runtime_config_path),
"output_dir": str(output_dir),
},
"pipeline": pipeline_result,
"final_response": pipeline_result.get("final_response", "TEXT"),
}
reply_text = pipeline_result.get("reply_text", "")
if reply_text:
result["reply_text"] = reply_text
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Parse and run aminer-daily-paper recommendation from trigger text.")
parser.add_argument("--base-dir", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--text", required=True)
parser.add_argument("--config", type=Path, default=None)
args = parser.parse_args()
result = handle_trigger(
base_dir=args.base_dir.resolve(),
text=args.text,
config_path=args.config.resolve() if args.config else None,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import ssl
import time
import urllib.error
import urllib.request
from typing import Any
from scripts.constants import AMINER_PAPER_URL_TEMPLATE, DEFAULT_REC5_URL
DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_RETRY_ATTEMPTS = 2
RETRYABLE_HTTP_CODES = {429, 500, 502, 503, 504}
def _clean_text(value: Any) -> str:
return " ".join(str(value or "").split()).strip()
def resolve_token(config: dict[str, Any] | None = None) -> str:
config = config or {}
aminer_config = config.get("aminer") if isinstance(config.get("aminer"), dict) else {}
return _clean_text(os.getenv("AMINER_API_KEY") or aminer_config.get("token"))
def resolve_rec5_url(config: dict[str, Any] | None = None) -> str:
config = config or {}
aminer_config = config.get("aminer") if isinstance(config.get("aminer"), dict) else {}
return _clean_text(aminer_config.get("rec5_url") or os.getenv("AMINER_REC5_URL")) or DEFAULT_REC5_URL
def build_api_request(
*,
aminer_author_id: str = "",
author_name: str = "",
author_org: str = "",
topics: list[str] | None = None,
size: int = 5,
offset: int = 0,
start_year: int | None = None,
end_year: int | None = None,
language_sort: str = "",
) -> dict[str, Any]:
params: dict[str, Any] = {}
if _clean_text(aminer_author_id):
params["aminer_author_id"] = _clean_text(aminer_author_id)
if _clean_text(author_name):
params["author_name"] = _clean_text(author_name)
if _clean_text(author_org):
params["author_org"] = _clean_text(author_org)
cleaned_topics = [_clean_text(t) for t in (topics or []) if _clean_text(t)]
if cleaned_topics:
params["topics"] = cleaned_topics
params["size"] = max(1, min(int(size), 20))
params["offset"] = max(0, min(int(offset), 100))
if start_year is not None:
params["start_year"] = int(start_year)
if end_year is not None:
params["end_year"] = int(end_year)
if _clean_text(language_sort) in {"zh", "en"}:
params["language_sort"] = _clean_text(language_sort)
return params
def normalize_rec5_paper(raw: dict[str, Any]) -> dict[str, Any]:
"""Normalize raw rec5 paper dict to the in-skill record shape (Markdown display / JSON 输出)."""
paper_id = _clean_text(raw.get("paper_id") or raw.get("id"))
links = raw.get("links") if isinstance(raw.get("links"), dict) else {}
aminer_url = (
_clean_text(links.get("aminer"))
or _clean_text(raw.get("paper_url"))
or (AMINER_PAPER_URL_TEMPLATE.format(paper_id=paper_id) if paper_id else "")
)
arxiv_url = _clean_text(links.get("arxiv") or raw.get("arxiv_url") or "")
pdf_url = _clean_text(links.get("pdf") or raw.get("pdf_url") or "")
arxiv_id = _clean_text(raw.get("arxiv_id") or "")
raw_ss = raw.get("structured_summary")
if isinstance(raw_ss, dict):
structured_summary: dict[str, str] = {k: _clean_text(v) for k, v in raw_ss.items() if _clean_text(v)}
else:
structured_summary = {}
raw_fa = raw.get("famous_authors")
famous_authors: list[Any] = []
if isinstance(raw_fa, list):
for item in raw_fa:
if isinstance(item, dict):
name = _clean_text(item.get("name"))
if not name:
continue
famous_authors.append(
{
"name": name,
"description": _clean_text(item.get("description") or item.get("bio") or ""),
"profile_url": _clean_text(item.get("profile_url") or ""),
}
)
elif isinstance(item, str) and _clean_text(item):
famous_authors.append(_clean_text(item))
raw_profiles = raw.get("aminer_author_profiles")
aminer_author_profiles: list[dict[str, Any]] = (
[p for p in raw_profiles if isinstance(p, dict)] if isinstance(raw_profiles, list) else []
)
raw_entries = raw.get("author_entries")
author_entries: list[dict[str, Any]] = (
[e for e in raw_entries if isinstance(e, dict)] if isinstance(raw_entries, list) else []
)
year = raw.get("year")
if year is not None:
try:
year = int(year)
except (TypeError, ValueError):
year = None
return {
"paper_id": paper_id,
"arxiv_id": arxiv_id,
"aminer_paper_id": paper_id,
"aminer_paper_url": aminer_url,
"abs_url": arxiv_url,
"pdf_url": pdf_url,
"title": _clean_text(raw.get("title")),
"year": year,
"authors": [_clean_text(a) for a in list(raw.get("authors") or []) if _clean_text(a)],
"keywords": [_clean_text(k) for k in list(raw.get("keywords") or []) if _clean_text(k)],
"summary": _clean_text(raw.get("summary") or ""),
"structured_summary": structured_summary,
"famous_authors": famous_authors,
"aminer_author_profiles": aminer_author_profiles,
"author_entries": author_entries,
"source": _clean_text(raw.get("source") or "rec5"),
"recommendation_reason": _clean_text(raw.get("recommendation_reason") or ""),
}
def call_rec5_api(
params: dict[str, Any],
*,
token: str,
url: str = DEFAULT_REC5_URL,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
retry_attempts: int = DEFAULT_RETRY_ATTEMPTS,
) -> dict[str, Any]:
if not _clean_text(token):
raise RuntimeError("missing_aminer_api_key")
body = json.dumps(params, ensure_ascii=False).encode("utf-8")
ssl_context = ssl.create_default_context()
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=ssl_context),
)
last_error: Exception | None = None
for attempt in range(1, retry_attempts + 2):
request = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/json;charset=utf-8",
"Authorization": token,
"User-Agent": "aminer-rec/1.0",
"X-Platform": "openclaw",
},
method="POST",
)
try:
with opener.open(request, timeout=timeout_seconds) as response: # nosec B310
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
if exc.code in RETRYABLE_HTTP_CODES and attempt <= retry_attempts:
last_error = exc
time.sleep(0.5 * attempt)
continue
raise RuntimeError(f"rec5_api_http_{exc.code}") from exc
except Exception as exc:
if attempt <= retry_attempts:
last_error = exc
time.sleep(0.5 * attempt)
continue
raise RuntimeError(f"rec5_api_error:{exc.__class__.__name__}") from exc
if not payload.get("success"):
msg = _clean_text(payload.get("msg") or str(payload.get("code") or "api_error"))
raise RuntimeError(f"rec5_api_failed:{msg}")
data = payload.get("data")
if isinstance(data, list) and data:
papers = list(data[0].get("papers") or [])
data_obj = data[0] if isinstance(data[0], dict) else {}
elif isinstance(data, dict):
papers = list(data.get("papers") or [])
data_obj = data
else:
papers = []
data_obj = {}
analyzed_topics = list(data_obj.get("analyzed_topics") or []) if isinstance(data_obj, dict) else []
return {
"papers": [p for p in papers if isinstance(p, dict)],
"analyzed_topics": [str(t).strip() for t in analyzed_topics if str(t).strip()],
}
raise RuntimeError(f"rec5_api_unreachable:{_clean_text(str(last_error))}") from last_error
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import yaml
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.common import write_json
from scripts.constants import DEFAULT_TOP_K
from scripts.rec5_api import (
build_api_request,
call_rec5_api,
normalize_rec5_paper,
resolve_rec5_url,
resolve_token,
)
def _clean_text(value: Any) -> str:
return " ".join(str(value or "").split()).strip()
def _load_yaml(path: Path | None) -> dict[str, Any]:
if path is None or not path.exists():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def _stage_error(stage: str, detail: Any) -> RuntimeError:
compact = _clean_text(str(detail)) or "unknown_error"
return RuntimeError(f"{stage}_failed:{compact}")
def _format_papers_as_markdown(papers: list[dict[str, Any]], profile_topics: list[str]) -> str:
"""Render recommended papers as Markdown for the host to display."""
lines: list[str] = []
topic_hint = " / ".join(profile_topics[:5]) if profile_topics else ""
header = f"为你推荐 {len(papers)} 篇相关论文"
if topic_hint:
header += f"(研究方向:{topic_hint})"
lines.append(header)
for idx, paper in enumerate(papers, start=1):
lines.append("")
lines.append("---")
lines.append("")
title = _clean_text(paper.get("title") or "")
url = _clean_text(paper.get("aminer_paper_url") or paper.get("abs_url") or "")
title_line = f"**{idx}. [{title}]({url})**" if url else f"**{idx}. {title}**"
lines.append(title_line)
year = paper.get("year")
keywords = paper.get("keywords") or []
authors = paper.get("authors") or []
summary = _clean_text(paper.get("summary") or "")
meta_parts: list[str] = []
if year:
meta_parts.append(f"年份:{year}")
if keywords:
meta_parts.append(f"关键词:{' / '.join(str(k) for k in keywords[:5])}")
if meta_parts:
lines.append(" | ".join(meta_parts))
if authors:
author_str = "、".join(str(a) for a in authors[:6])
if len(authors) > 6:
author_str += " et al."
lines.append(f"作者:{author_str}")
if summary:
truncated = summary if len(summary) <= 300 else summary[:300].rstrip() + "…"
lines.append("")
lines.append(truncated)
return "\n".join(lines)
def _topics_from_paper_titles(paper_titles: list[str]) -> list[str]:
"""Extract simple topic hints from paper titles when no explicit topics provided."""
topics: list[str] = []
seen: set[str] = set()
for title in paper_titles:
cleaned = _clean_text(title)
if not cleaned:
continue
candidate = cleaned.split(":")[0].strip() if ":" in cleaned else cleaned
if candidate and candidate.casefold() not in seen and len(candidate) <= 80:
seen.add(candidate.casefold())
topics.append(candidate)
return topics[:5]
def run_pipeline(
*,
output_dir: Path,
config: dict[str, Any],
aminer_author_id: str,
topics: list[str],
scholar_name: str,
scholar_org: str,
paper_titles: list[str],
papers_file: str,
free_text: str,
language_sort: str = "",
size: int = 0,
) -> dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
token = resolve_token(config)
if not token:
raise _stage_error("auth", "AMINER_API_KEY is not set")
all_topics = list(topics)
if paper_titles and not all_topics and not scholar_name and not aminer_author_id:
all_topics = _topics_from_paper_titles(paper_titles)
search_config = config.get("search") if isinstance(config.get("search"), dict) else {}
if size <= 0:
size = max(1, min(int(search_config.get("top_k") or DEFAULT_TOP_K), 20))
# language_sort only when user explicitly passed language_sort: zh|en in the trigger (see SKILL).
sort_for_api = _clean_text(language_sort) if _clean_text(language_sort) in {"zh", "en"} else ""
api_request = build_api_request(
aminer_author_id=aminer_author_id,
author_name=scholar_name,
author_org=scholar_org,
topics=all_topics,
size=size,
language_sort=sort_for_api,
)
try:
result = call_rec5_api(api_request, token=token, url=resolve_rec5_url(config))
raw_papers = result["papers"]
api_analyzed_topics = result.get("analyzed_topics") or []
except Exception as exc:
raise _stage_error("recall", exc) from exc
if not raw_papers:
raise _stage_error("recall", "no_papers_returned")
papers = [normalize_rec5_paper(p) for p in raw_papers]
papers = [p for p in papers if _clean_text(p.get("title"))]
profile_topics = api_analyzed_topics or all_topics or ([scholar_name] if scholar_name else [])
summarized_payload = {
"status": "success",
"profile_topics": profile_topics,
"profile_name": scholar_name or "",
"profile_source": "scholar_path" if (aminer_author_id or scholar_name) else "topic_path",
"papers": papers,
}
mode = "scholar_path" if (aminer_author_id or scholar_name) else "topic_path"
write_json(
output_dir / "request_context.json",
{
"aminer_author_id": aminer_author_id,
"input_topics": topics,
"topics": all_topics,
"scholar_name": scholar_name,
"scholar_org": scholar_org,
"paper_titles": paper_titles,
"papers_file": papers_file,
"free_text": free_text,
"language_sort": language_sort,
"api_request": api_request,
},
)
summarized_path = output_dir / "papers_summarized.json"
write_json(summarized_path, summarized_payload)
markdown_text = _format_papers_as_markdown(papers, profile_topics)
return {
"status": "success",
"summarized_path": str(summarized_path),
"final_response": "TEXT",
"reply_text": markdown_text,
"mode": mode,
"paper_count": len(papers),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Run aminer-daily-paper pipeline via rec5 API.")
parser.add_argument("--base-dir", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--config", type=Path, default=None)
parser.add_argument("--output-dir", type=Path, default=Path(__file__).resolve().parents[1] / "outputs")
parser.add_argument("--aminer-author-id", default="")
parser.add_argument("--topics", nargs="*", default=[])
parser.add_argument("--scholar-name", default="")
parser.add_argument("--scholar-org", default="")
parser.add_argument("--paper-title", action="append", dest="paper_titles", default=[])
parser.add_argument("--papers-file", default="")
parser.add_argument("--free-text", default="")
parser.add_argument("--language-sort", default="")
parser.add_argument("--size", type=int, default=0)
args = parser.parse_args()
resolved_config = args.config.resolve() if args.config else None
config = _load_yaml(resolved_config)
result = run_pipeline(
output_dir=args.output_dir.resolve(),
config=config,
aminer_author_id=args.aminer_author_id,
topics=list(args.topics or []),
scholar_name=args.scholar_name,
scholar_org=args.scholar_org,
paper_titles=list(args.paper_titles or []),
papers_file=args.papers_file,
free_text=args.free_text,
language_sort=args.language_sort,
size=args.size,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
outputs/
__pycache__/
.pytest_cache/
+111
View File
@@ -0,0 +1,111 @@
---
name: aminer-deep-search
version: 2.0.0
author: AMiner
contact: report@aminer.cn
description: >
Activate this skill when the user wants deep, multi-round academic paper collection for a survey or literature review.
The host model (the model running this skill) drives the loop itself: it expands queries, judges relevance, snowballs backward references, and decides when to stop.
The bundled scripts are pure tool commands that call documented AMiner Open Platform endpoints and print JSON tool results only — no extra LLM configuration is needed.
Use this skill for broad topic exploration, survey bibliography construction, and collecting hundreds of candidate papers with AMiner IDs and titles.
Not intended for single-paper lookup or lightweight recommendations; use aminer-free-academic or aminer-daily-paper for those.
metadata:
{
"openclaw":
{
"requires": {
"bins": ["python3"],
"env": ["AMINER_API_KEY"]
},
"primaryEnv": "AMINER_API_KEY"
}
}
---
# AMiner Deep Search
Host-model-driven survey paper collection. You (the model reading this) are the controller: run the tool scripts, read their JSON output, judge relevance yourself, and iterate until the collection target is met.
## Scope
- Use for: survey bibliography collection (hundreds of papers), keyword expansion, backward-citation snowballing.
- Do not use for: single-paper lookup or Q&A (route to `aminer-free-academic`), personalized recommendations (route to `aminer-daily-paper`).
## Pre-flight
1. Check the key without printing it:
```bash
[ -z "${AMINER_API_KEY:-}" ] && echo "AMINER_API_KEY missing" || echo "AMINER_API_KEY exists"
```
If missing, stop and ask the user to set `AMINER_API_KEY` (console: https://open.aminer.cn/open/board?tab=control). Never print the key.
2. Confirm the `topic` and the `target-size` (default 400). If your round plan is estimated to cost ¥5 or more, tell the user the estimate and get confirmation before starting.
## Tools
Both scripts live in `scripts/` under this skill directory. They print exactly one JSON document to stdout (the tool result); diagnostics and a `[cost]` line go to stderr. They never score relevance — that is your job.
### `scripts/aminer_api.py` — AMiner API calls
| Subcommand | Endpoint | Price |
|---|---|---|
| `search --query Q [--size 20] [--year YYYY] [--order n_citation\|year] [--max-pages 3]` | GET `/api/paper/search/pro` + free `paper/info` enrichment | ¥0.01/page |
| `qa-search [--query "natural language question"] [--topic-high '[["termA","termB"],["termC"]]'] [--size 20] [--year-from Y] [--year-to Y] [--citation-sort]` | POST `/api/paper/qa/search` (always `use_topic=true`; the backend ignores `query` when `use_topic=false`) + free enrichment | ¥0.05/call |
| `info --ids id1 id2 ...` | POST `/api/paper/info` (batched ≤100 ids) | Free |
| `references --ids id1 id2 ... [--per-seed 20]` | GET `/api/paper/relation` per seed + free enrichment | ¥0.10/seed |
Output shape: `search`/`qa-search`/`info` print `[{id, title, year?, venue?, abstract_slice?}]`; `references` additionally includes `source_paper_ids` (which seeds cited the paper). Seeds themselves are excluded from `references` output.
### `scripts/paper_set.py` — cross-round state file (no network)
State file defaults to `outputs/paper_set.json` relative to the working directory.
```bash
# Merge kept results (pipe the filtered JSON array in), dedupe by id
python3 scripts/aminer_api.py search --query "..." | python3 scripts/paper_set.py add
# → {"added": N, "duplicates": M, "total": T}
python3 scripts/paper_set.py stats # totals, expanded_seeds, by_year
python3 scripts/paper_set.py mark-expanded --ids id1 id2 # record snowballed seeds
python3 scripts/paper_set.py export -o outputs/final_papers.json
```
`add` also accepts `--ids id1 id2 ...` for bare IDs. Items carrying `source_paper_ids` (from `references`) automatically mark those seeds as expanded.
If you want to filter before adding, read the search output first, then pipe only the kept items:
```bash
printf '%s' '[{"id":"...","title":"..."}]' | python3 scripts/paper_set.py add
```
## Round Protocol (core)
### Round 0 — plan
- Derive 4–8 seed queries from the topic: synonyms, subfields, method names, datasets/benchmarks, common English abbreviations.
- Estimate rounds and cost (searches ≈ ¥0.01–0.05 each, references ≈ ¥0.10/seed). If the estimate is ≥¥5, confirm with the user first.
### Each round (default budget: 12 rounds), five fixed steps
1. **Search**: run 1–4 `search` / `qa-search` calls from the pending query queue. Prefer `search` (cheaper); use `qa-search` when the query is a natural-language question.
2. **Filter & add**: read the stdout results, judge relevance to the topic yourself, and pipe only the kept items into `paper_set.py add`. Never add papers you consider off-topic.
3. **Check**: run `stats` to see the total and this round's increment.
4. **Snowball**: from this round's relevant additions pick ≤5 strong seeds (highly relevant, ranked high under `--order n_citation`, not in `expanded_seeds`) and run `references --ids ...`. Filter the output for relevance, then add it. Run `mark-expanded` for seeds that yielded nothing addable.
5. **Decide**: choose the next move —
- a search returned <5 results or poor quality → replace it with a reformulated query (max 2 variants per direction, then switch to snowballing);
- references are yielding many relevant papers → keep snowballing from fresh seeds;
- reached `target-size`, or results are exhausted, or 2 consecutive rounds added <5 papers → terminate.
### Wrap-up
Run `export`, then report: final paper count, total cost (sum the `[cost]` stderr lines), and the output path.
## Rules
1. Never fabricate paper IDs or titles; only cite data actually returned by the tools.
2. Free first: metadata always comes from the free `paper/info` (the scripts already do this); never call the paid `paper/detail` for bulk metadata.
3. Keep the raw tool output out of your final answer; report counts and the exported file path instead.
4. Never print or log `AMINER_API_KEY`.
5. If AMiner returns fewer papers than the target, report the real count instead of inventing papers.
+111
View File
@@ -0,0 +1,111 @@
---
name: aminer-deep-search
version: 2.0.0
author: AMiner
contact: report@aminer.cn
description: >
当用户需要为综述或文献回顾做深度、多轮的学术论文收集时激活本 skill。
由宿主模型(正在运行本 skill 的模型)亲自驱动循环:扩展查询、判断相关性、做引用滚雪球、决定何时终止。
附带脚本只是纯工具命令,调用 AMiner 开放平台的正规接口并只输出 JSON tool-result,无需配置任何额外 LLM。
适用于大范围主题探索、综述参考文献构建、收集数百篇带 AMiner ID 与标题的候选论文。
不适用于单篇论文查询或轻量推荐,请分别使用 aminer-free-academic 或 aminer-daily-paper。
metadata:
{
"openclaw":
{
"requires": {
"bins": ["python3"],
"env": ["AMINER_API_KEY"]
},
"primaryEnv": "AMINER_API_KEY"
}
}
---
# AMiner 深度收集
宿主模型直驱的综述文献收集。你(正在读这份文档的模型)就是控制器:运行工具脚本、阅读 JSON 输出、自己判断相关性、迭代直到达成收集目标。
## 适用边界
- 适用:综述级文献收集(数百篇)、关键词扩展、后向引用滚雪球。
- 不适用:单篇查询或问答(走 `aminer-free-academic`)、个性化推荐(走 `aminer-daily-paper`)。
## Pre-flight
1. 检查 key(不打印值):
```bash
[ -z "${AMINER_API_KEY:-}" ] && echo "AMINER_API_KEY missing" || echo "AMINER_API_KEY exists"
```
缺失时停止并让用户设置 `AMINER_API_KEY`(控制台:https://open.aminer.cn/open/board?tab=control)。绝不打印 key。
2. 确认 `topic` 和 `target-size`(默认 400)。若轮次计划预估费用 ≥¥5,先告知用户并获得确认再开始。
## 工具
两个脚本都在本 skill 目录的 `scripts/` 下。stdout 只输出一个 JSON 文档(即 tool-result);诊断信息与 `[cost]` 费用行走 stderr。脚本不做任何相关性打分——那是你的工作。
### `scripts/aminer_api.py` — AMiner 接口调用
| 子命令 | 端点 | 价格 |
|---|---|---|
| `search --query Q [--size 20] [--year YYYY] [--order n_citation\|year] [--max-pages 3]` | GET `/api/paper/search/pro` + 免费 `paper/info` 补全 | ¥0.01/页 |
| `qa-search [--query "自然语言问题"] [--topic-high '[["词A","词B"],["词C"]]'] [--size 20] [--year-from Y] [--year-to Y] [--citation-sort]` | POST `/api/paper/qa/search`(固定 `use_topic=true`:`use_topic=false` 时后端忽略 `query`)+ 免费补全 | ¥0.05/次 |
| `info --ids id1 id2 ...` | POST `/api/paper/info`(≤100 个 id 分批) | 免费 |
| `references --ids id1 id2 ... [--per-seed 20]` | 每个 seed 调 GET `/api/paper/relation` + 免费补全 | ¥0.10/篇 seed |
输出形状:`search`/`qa-search`/`info` 输出 `[{id, title, year?, venue?, abstract_slice?}]`;`references` 额外带 `source_paper_ids`(哪些 seed 引用了该论文),且结果中排除 seed 本身。
### `scripts/paper_set.py` — 跨轮状态文件(无网络)
状态文件默认是工作目录下的 `outputs/paper_set.json`。
```bash
# 把保留的结果管道进去(可直接接在搜索后面),按 id 去重
python3 scripts/aminer_api.py search --query "..." | python3 scripts/paper_set.py add
# → {"added": N, "duplicates": M, "total": T}
python3 scripts/paper_set.py stats # 总量、expanded_seeds、按年分布
python3 scripts/paper_set.py mark-expanded --ids id1 id2 # 记录已滚雪球的 seed
python3 scripts/paper_set.py export -o outputs/final_papers.json
```
`add` 也接受 `--ids id1 id2 ...` 直接加裸 id。带 `source_paper_ids` 的条目(来自 `references`)会自动把对应 seed 记为已扩展。
如需先筛选再入库,先阅读搜索输出,再只把保留的条目管道进去:
```bash
printf '%s' '[{"id":"...","title":"..."}]' | python3 scripts/paper_set.py add
```
## 每轮协议(核心)
### 第 0 轮 — 规划
- 从 topic 派生 4–8 个种子查询:同义词、子领域、方法名、数据集/基准、常用英文缩写。
- 预估轮数与费用(搜索约 ¥0.01–0.05/次,references 约 ¥0.10/seed)。预估 ≥¥5 时先向用户确认。
### 每一轮(默认最多 12 轮),固定五步
1. **搜索**:对待查队列执行 1–4 个 `search` / `qa-search`。优先用 `search`(更便宜);查询是自然语言问题时用 `qa-search`。
2. **筛选入库**:阅读 stdout 结果,自己判断与主题的相关性,只把保留的条目管道进 `paper_set.py add`。绝不入库你认为跑题的论文。
3. **查看进度**:跑 `stats` 查看总量与本轮增量。
4. **滚雪球**:从本轮新增的相关论文里挑 ≤5 个强种子(高相关、`--order n_citation` 排前、不在 `expanded_seeds` 中),跑 `references --ids ...`。对输出再做相关性筛选后入库。没有可入库产出的 seed 用 `mark-expanded` 记录。
5. **决策下一轮**:
- 某个搜索结果 <5 条或质量差 → 换一个改写后的查询(每个方向最多试 2 个变体,之后转滚雪球);
- references 持续产出大量相关论文 → 继续从新 seed 滚雪球;
- 达到 `target-size`、结果枯竭、或连续 2 轮新增 <5 篇 → 终止。
### 收尾
跑 `export`,然后报告:最终篇数、总费用(累加 stderr 的 `[cost]` 行)、输出文件路径。
## 规则
1. 绝不编造论文 ID 或标题;只引用工具实际返回的数据。
2. 免费优先:元数据一律来自免费的 `paper/info`(脚本已内置);绝不为批量元数据调付费的 `paper/detail`。
3. 不要把原始工具输出塞进最终回答;只报告数量与导出文件路径。
4. 绝不打印或记录 `AMINER_API_KEY`。
5. 如果 AMiner 返回的论文少于目标,如实报告实际数量,不要编造。
+51
View File
@@ -0,0 +1,51 @@
---
description: AMiner deep multi-round paper collection for survey references
argument-hint: "[research topic | topic: ... target-size: 400 max-rounds: 12]"
allowed-tools: Read, Bash, Glob, Grep
---
# /aminer-deep-search - AMiner Deep Search
User invoked the AMiner deep paper collection skill with the following arguments:
```text
$ARGUMENTS
```
## Your task
Follow `${CLAUDE_PLUGIN_ROOT}/SKILL.md`. You are the controller: run the tool scripts, read their JSON output, judge relevance yourself, and iterate. There is no external LLM mode and nothing to configure beyond `AMINER_API_KEY`.
Use this command only for deep survey-style paper collection, not for simple paper lookup or lightweight recommendations.
### 1. Parse `$ARGUMENTS`
- `topic`: required research topic. Preserve the user's wording. If absent or too vague, ask for a concrete topic.
- `target-size`: optional final paper target, default 400.
- `max-rounds`: optional round budget, default 12.
### 2. Pre-flight
```bash
[ -z "${AMINER_API_KEY:-}" ] && echo "AMINER_API_KEY missing" || echo "AMINER_API_KEY exists"
```
If missing, stop and tell the user to set `AMINER_API_KEY`. Never print the key. The scripts are pure stdlib — no dependency installation is needed.
### 3. Run the round protocol
Execute the Round Protocol from `${CLAUDE_PLUGIN_ROOT}/SKILL.md`:
- Round 0: derive 4–8 seed queries, estimate cost; confirm with the user if the estimate is ≥¥5.
- Each round (max `max-rounds`): search via
`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/aminer_api.py" search --query "..." --order n_citation`,
filter results for relevance yourself, add the kept items via
`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/paper_set.py" add`,
snowball with `references --ids ...` on ≤5 strong unexpanded seeds, and check `stats`.
- Stop when `target-size` is reached, results are exhausted, or 2 consecutive rounds add <5 papers.
Keep the state file and exports under the current working directory (`outputs/`).
### 4. Present the result
Run `export` and report the final paper count, the total cost (sum of `[cost]` stderr lines), and the output path. If the run fails due to missing configuration or API errors, show the actionable error without exposing secrets. Never fabricate papers.
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""AMiner Open Platform tool CLI for aminer-deep-search.
Pure-stdlib tool commands driven by the host model. Every subcommand prints a
single JSON document to stdout (the tool result); diagnostics and the cost
summary go to stderr. No relevance scoring or loop control happens here — the
host model decides what to search, what to keep, and when to stop.
Endpoints (base: https://publicapi.chatglm.cn/chatglm_public/skill/aminer):
GET /api/paper/search/pro keyword search, ¥0.01/page (subcommand: search)
POST /api/paper/qa/search natural-language search, ¥0.05/call (qa-search)
POST /api/paper/info batch metadata, free, <=100 ids (info, and enrichment)
GET /api/paper/relation backward references, ¥0.10/seed (references)
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
BASE_URL = "https://publicapi.chatglm.cn/chatglm_public/skill/aminer"
CONSOLE_URL = "https://open.aminer.cn/open/board?tab=control"
TIMEOUT_SECONDS = 30.0
MAX_RETRIES = 3
RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504})
PAPER_INFO_BATCH_LIMIT = 100
SEARCH_PRO_PAGE_SIZE = 20
ABSTRACT_SLICE_LIMIT = 300
PRICE_CNY = {
"search_pro": 0.01,
"qa_search": 0.05,
"paper_info": 0.0,
"relation": 0.10,
}
_call_counts: dict[str, int] = {}
class ToolError(Exception):
"""Fatal error carrying a structured JSON payload for stdout."""
def __init__(self, payload: dict[str, Any], exit_code: int = 1) -> None:
super().__init__(payload.get("message", "tool error"))
self.payload = payload
self.exit_code = exit_code
def _get_token() -> str:
token = (os.getenv("AMINER_API_KEY") or "").strip()
if not token:
raise ToolError(
{
"ok": False,
"error": "missing_aminer_api_key",
"message": "Set the AMINER_API_KEY environment variable before calling AMiner APIs.",
"console": CONSOLE_URL,
},
exit_code=2,
)
return token
def _redact(value: Any, token: str) -> Any:
if isinstance(value, str):
return value.replace(token, "[REDACTED]") if token else value
if isinstance(value, list):
return [_redact(item, token) for item in value]
if isinstance(value, dict):
return {key: _redact(item, token) for key, item in value.items()}
return value
def _request(api_name: str, method: str, path: str,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None) -> dict[str, Any]:
"""Call one endpoint with retries; return the parsed JSON envelope."""
token = _get_token()
url = BASE_URL + path
if method == "GET" and params:
query = urllib.parse.urlencode(
{k: v for k, v in params.items() if v is not None}
)
url = f"{url}?{query}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
headers = {"Authorization": token, "X-Platform": "openclaw"}
if data is not None:
headers["Content-Type"] = "application/json;charset=utf-8"
_call_counts[api_name] = _call_counts.get(api_name, 0) + 1
last_error: dict[str, Any] | None = None
for attempt in range(1, MAX_RETRIES + 1):
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
raw = response.read().decode("utf-8", errors="replace")
envelope = json.loads(raw)
if not isinstance(envelope, dict):
return {"data": envelope}
# AMiner can wrap errors in an HTTP-200 response, e.g. code 403
# for insufficient balance/permission. Surface those instead of
# letting them look like an empty result set.
code = envelope.get("code")
if isinstance(code, int) and code not in (0, 200):
raise ToolError({
"ok": False,
"error": "api_error",
"api": api_name,
"code": code,
"message": _redact(str(envelope.get("msg") or ""), token)[:300],
})
return envelope
except urllib.error.HTTPError as exc:
detail = _redact(exc.read().decode("utf-8", errors="replace")[:500], token)
last_error = {
"ok": False,
"error": "http_error",
"api": api_name,
"status": exc.code,
"detail": detail,
}
if exc.code not in RETRYABLE_STATUS:
break
except (urllib.error.URLError, TimeoutError, OSError) as exc:
last_error = {
"ok": False,
"error": "network_error",
"api": api_name,
"detail": _redact(str(getattr(exc, "reason", exc)), token)[:500],
}
except json.JSONDecodeError as exc:
last_error = {
"ok": False,
"error": "invalid_response",
"api": api_name,
"detail": str(exc)[:200],
}
break
if attempt < MAX_RETRIES:
backoff = (2 ** (attempt - 1)) + random.uniform(0, 0.3)
print(f"[retry] {api_name} attempt={attempt}/{MAX_RETRIES} wait={backoff:.1f}s",
file=sys.stderr)
time.sleep(backoff)
raise ToolError(last_error or {"ok": False, "error": "request_failed", "api": api_name})
def _envelope_items(envelope: dict[str, Any]) -> list[dict[str, Any]]:
data = envelope.get("data")
if isinstance(data, dict):
data = data.get("data") or data.get("items") or []
if not isinstance(data, list):
return []
return [item for item in data if isinstance(item, dict)]
def _dedupe(items: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for item in items:
cleaned = str(item or "").strip()
if cleaned and cleaned not in seen:
seen.add(cleaned)
result.append(cleaned)
return result
def _paper_id(item: dict[str, Any]) -> str:
return str(item.get("id") or item.get("_id") or "").strip()
# ──────────────────────────────────────────────────────────────────────────────
# paper/info enrichment (free)
# ──────────────────────────────────────────────────────────────────────────────
def _fetch_paper_info(ids: list[str]) -> dict[str, dict[str, Any]]:
"""Batch-fetch lightweight cards; returns a map of id -> raw info entry."""
info_by_id: dict[str, dict[str, Any]] = {}
unique_ids = _dedupe(ids)
for start in range(0, len(unique_ids), PAPER_INFO_BATCH_LIMIT):
chunk = unique_ids[start:start + PAPER_INFO_BATCH_LIMIT]
envelope = _request("paper_info", "POST", "/api/paper/info", body={"ids": chunk})
for entry in _envelope_items(envelope):
entry_id = _paper_id(entry)
if entry_id:
info_by_id[entry_id] = entry
return info_by_id
def _venue_text(entry: dict[str, Any]) -> str:
venue = entry.get("venue")
if isinstance(venue, dict):
return str(venue.get("raw") or venue.get("name") or "")
return str(venue or entry.get("raw") or entry.get("venue_name") or "")
def _compact_paper(base: dict[str, Any], info: dict[str, Any] | None) -> dict[str, Any]:
merged: dict[str, Any] = dict(info or {})
item: dict[str, Any] = {
"id": _paper_id(base) or _paper_id(merged),
"title": str(base.get("title") or merged.get("title")
or base.get("title_zh") or merged.get("title_zh") or "").strip(),
}
year = merged.get("year") or base.get("year")
if year:
item["year"] = year
venue = _venue_text(merged) or _venue_text(base)
if venue:
item["venue"] = venue
abstract = str(merged.get("abstract_slice") or merged.get("abstract") or "").strip()
if abstract:
item["abstract_slice"] = abstract[:ABSTRACT_SLICE_LIMIT]
return item
def _enrich(base_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Attach year/venue/abstract_slice from the free paper/info endpoint."""
ids = [_paper_id(item) for item in base_items if _paper_id(item)]
info_by_id = _fetch_paper_info(ids) if ids else {}
papers: list[dict[str, Any]] = []
seen: set[str] = set()
for item in base_items:
paper_id = _paper_id(item)
if not paper_id or paper_id in seen:
continue
seen.add(paper_id)
compact = _compact_paper(item, info_by_id.get(paper_id))
if compact["id"] and compact["title"]:
papers.append(compact)
return papers
# ──────────────────────────────────────────────────────────────────────────────
# Subcommands
# ──────────────────────────────────────────────────────────────────────────────
def cmd_search(args: argparse.Namespace) -> list[dict[str, Any]]:
target = max(1, args.size)
max_year = args.year
collected: list[dict[str, Any]] = []
seen: set[str] = set()
for page in range(max(1, args.max_pages)):
params: dict[str, Any] = {
"keyword": args.query,
"page": page,
"size": SEARCH_PRO_PAGE_SIZE,
}
if args.order:
params["order"] = args.order
envelope = _request("search_pro", "GET", "/api/paper/search/pro", params=params)
page_items = _envelope_items(envelope)
if not page_items:
break
enriched = _enrich(page_items)
for paper in enriched:
if paper["id"] in seen:
continue
if max_year and paper.get("year") and int(paper["year"]) > max_year:
continue
seen.add(paper["id"])
collected.append(paper)
if len(collected) >= target or len(page_items) < SEARCH_PRO_PAGE_SIZE:
break
return collected[:target]
def cmd_qa_search(args: argparse.Namespace) -> list[dict[str, Any]]:
# The backend only uses the `query` keyword-extraction result when
# use_topic=true; with use_topic=false it reads `title` only, so a
# query-only request ends up with empty terms and returns envelope 403.
# Therefore always send use_topic=true.
body: dict[str, Any] = {
"use_topic": True,
"size": max(1, args.size),
}
if args.query:
body["query"] = args.query
if args.topic_high:
body["topic_high"] = args.topic_high
if not args.query and not args.topic_high:
raise ToolError({
"ok": False,
"error": "invalid_request",
"message": "qa-search requires --query and/or --topic-high",
}, exit_code=2)
if args.year_from or args.year_to:
year_from = args.year_from or 1900
year_to = args.year_to or time.gmtime().tm_year
body["year"] = list(range(year_from, year_to + 1))
if args.citation_sort:
body["force_citation_sort"] = True
envelope = _request("qa_search", "POST", "/api/paper/qa/search", body=body)
return _enrich(_envelope_items(envelope))
def cmd_info(args: argparse.Namespace) -> list[dict[str, Any]]:
info_by_id = _fetch_paper_info(args.ids)
return [_compact_paper(entry, None) for entry in info_by_id.values()]
def cmd_references(args: argparse.Namespace) -> list[dict[str, Any]]:
seed_ids = _dedupe(args.ids)
per_seed = max(1, args.per_seed)
sources_by_id: dict[str, list[str]] = {}
ordered_ids: list[str] = []
seed_set = set(seed_ids)
for seed_id in seed_ids:
envelope = _request("relation", "GET", "/api/paper/relation",
params={"id": seed_id})
cited_ids: list[str] = []
for item in _envelope_items(envelope):
cited = item.get("cited") or []
if not isinstance(cited, list):
continue
for cited_item in cited:
cited_id = _paper_id(cited_item) if isinstance(cited_item, dict) else str(cited_item or "").strip()
if cited_id:
cited_ids.append(cited_id)
for cited_id in _dedupe(cited_ids)[:per_seed]:
if cited_id in seed_set:
continue
if cited_id not in sources_by_id:
sources_by_id[cited_id] = []
ordered_ids.append(cited_id)
sources_by_id[cited_id].append(seed_id)
papers = _enrich([{"id": paper_id} for paper_id in ordered_ids])
for paper in papers:
paper["source_paper_ids"] = sources_by_id.get(paper["id"], [])
return papers
# ──────────────────────────────────────────────────────────────────────────────
# CLI
# ──────────────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="AMiner Open Platform tool commands (stdout: JSON only; diagnostics: stderr)."
)
sub = parser.add_subparsers(dest="command", required=True)
p_search = sub.add_parser("search", help="Keyword search via paper/search/pro (¥0.01/page).")
p_search.add_argument("--query", required=True)
p_search.add_argument("--size", type=int, default=20, help="Papers to return (default 20).")
p_search.add_argument("--year", type=int, default=None,
help="Inclusive publication end year (client-side filter).")
p_search.add_argument("--order", choices=["n_citation", "year"], default=None)
p_search.add_argument("--max-pages", type=int, default=3,
help="Max result pages to fetch (default 3).")
p_search.set_defaults(func=cmd_search)
p_qa = sub.add_parser("qa-search", help="Natural-language search via paper/qa/search (¥0.05/call).")
p_qa.add_argument("--query", default=None, help="Natural-language question.")
p_qa.add_argument("--topic-high", default=None,
help='Structured keywords, JSON string like [["termA","termB"],["termC"]] '
"(outer AND, inner OR).")
p_qa.add_argument("--size", type=int, default=20)
p_qa.add_argument("--year-from", type=int, default=None)
p_qa.add_argument("--year-to", type=int, default=None)
p_qa.add_argument("--citation-sort", action="store_true",
help="Sort entirely by citation count.")
p_qa.set_defaults(func=cmd_qa_search)
p_info = sub.add_parser("info", help="Batch metadata via paper/info (free, <=100 ids/batch).")
p_info.add_argument("--ids", nargs="+", required=True)
p_info.set_defaults(func=cmd_info)
p_refs = sub.add_parser("references",
help="Backward-reference expansion via paper/relation (¥0.10/seed).")
p_refs.add_argument("--ids", nargs="+", required=True, help="Seed AMiner paper IDs.")
p_refs.add_argument("--per-seed", type=int, default=20,
help="Max references kept per seed (default 20).")
p_refs.set_defaults(func=cmd_references)
return parser
def _print_cost_summary() -> None:
if not _call_counts:
return
total = sum(PRICE_CNY.get(name, 0.0) * count for name, count in _call_counts.items())
parts = " + ".join(f"{name} x{count}" for name, count in sorted(_call_counts.items()))
print(f"[cost] {parts} = ¥{total:.2f}", file=sys.stderr)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = args.func(args)
except ToolError as exc:
print(json.dumps(exc.payload, ensure_ascii=False), file=sys.stdout)
_print_cost_summary()
return exc.exit_code
print(json.dumps(result, ensure_ascii=False, indent=2))
_print_cost_summary()
return 0
if __name__ == "__main__":
sys.exit(main())
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Cross-round paper-set state file for aminer-deep-search (no network access).
Hundreds of collected papers do not fit in the host model's context, so this
CLI keeps them in a JSON state file and deduplicates by AMiner paper ID.
Subcommands (stdout is always a single JSON document):
add merge papers from stdin (JSON array) or --ids into the state file
stats totals, expanded seeds, and year distribution
export write the final [{"id","title"}] list and report path + count
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
DEFAULT_STATE_FILE = "outputs/paper_set.json"
def _load_state(path: Path) -> dict[str, Any]:
if not path.exists():
return {"papers": {}, "expanded_seeds": []}
with path.open("r", encoding="utf-8") as file:
state = json.load(file)
state.setdefault("papers", {})
state.setdefault("expanded_seeds", [])
return state
def _save_state(path: Path, state: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
json.dump(state, file, ensure_ascii=False, indent=2)
def _normalize_items(raw_items: Any) -> list[dict[str, Any]]:
if not isinstance(raw_items, list):
raise ValueError("input must be a JSON array of papers")
items: list[dict[str, Any]] = []
for raw in raw_items:
if isinstance(raw, str):
items.append({"id": raw.strip()})
elif isinstance(raw, dict):
items.append(raw)
return items
def cmd_add(args: argparse.Namespace) -> dict[str, Any]:
if args.ids:
items = [{"id": paper_id} for paper_id in args.ids]
else:
stdin_text = sys.stdin.read().strip()
if not stdin_text:
raise ValueError("no input: pipe a JSON array to stdin or pass --ids")
items = _normalize_items(json.loads(stdin_text))
state_path = Path(args.file)
state = _load_state(state_path)
papers: dict[str, dict[str, Any]] = state["papers"]
expanded: set[str] = set(state["expanded_seeds"])
added = 0
duplicates = 0
for item in items:
paper_id = str(item.get("id") or item.get("_id") or "").strip()
if not paper_id:
continue
# source_paper_ids on an item means those seeds have been expanded
for seed_id in item.get("source_paper_ids") or []:
expanded.add(str(seed_id))
if paper_id in papers:
duplicates += 1
existing = papers[paper_id]
for key, value in item.items():
if key != "source_paper_ids" and value not in (None, "", []):
existing.setdefault(key, value)
else:
added += 1
papers[paper_id] = {
key: value for key, value in item.items()
if key != "source_paper_ids" and value not in (None, "", [])
}
state["expanded_seeds"] = sorted(expanded)
_save_state(state_path, state)
return {"added": added, "duplicates": duplicates, "total": len(papers)}
def cmd_mark_expanded(args: argparse.Namespace) -> dict[str, Any]:
state_path = Path(args.file)
state = _load_state(state_path)
expanded = set(state["expanded_seeds"])
expanded.update(str(paper_id).strip() for paper_id in args.ids if str(paper_id).strip())
state["expanded_seeds"] = sorted(expanded)
_save_state(state_path, state)
return {"expanded_seeds": state["expanded_seeds"]}
def cmd_stats(args: argparse.Namespace) -> dict[str, Any]:
state = _load_state(Path(args.file))
papers = state["papers"]
by_year: dict[str, int] = {}
missing_title = 0
for paper in papers.values():
year = paper.get("year")
key = str(year) if year else "unknown"
by_year[key] = by_year.get(key, 0) + 1
if not paper.get("title"):
missing_title += 1
return {
"total": len(papers),
"expanded_seeds": state["expanded_seeds"],
"missing_title": missing_title,
"by_year": dict(sorted(by_year.items(), reverse=True)),
}
def cmd_export(args: argparse.Namespace) -> dict[str, Any]:
state = _load_state(Path(args.file))
final = [
{"id": paper_id, "title": paper.get("title", "")}
for paper_id, paper in state["papers"].items()
if paper.get("title")
]
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as file:
json.dump(final, file, ensure_ascii=False, indent=2)
return {"count": len(final), "path": str(out_path)}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Deduplicated paper-set state file (stdout: JSON only, no network)."
)
parser.add_argument("--file", default=DEFAULT_STATE_FILE,
help=f"State file path (default {DEFAULT_STATE_FILE}).")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="Merge papers from stdin JSON array or --ids.")
p_add.add_argument("--ids", nargs="*", default=None,
help="Add bare paper IDs instead of reading stdin.")
p_add.set_defaults(func=cmd_add)
p_mark = sub.add_parser("mark-expanded", help="Record seed IDs whose references were expanded.")
p_mark.add_argument("--ids", nargs="+", required=True)
p_mark.set_defaults(func=cmd_mark_expanded)
p_stats = sub.add_parser("stats", help="Show totals, expanded seeds, and year distribution.")
p_stats.set_defaults(func=cmd_stats)
p_export = sub.add_parser("export", help="Write the final [{id,title}] list.")
p_export.add_argument("-o", "--output", default="outputs/final_papers.json")
p_export.set_defaults(func=cmd_export)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = args.func(args)
except (ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": "invalid_input", "message": str(exc)},
ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import sys
import unittest
from pathlib import Path
from unittest import mock
SKILL_DIR = Path(__file__).resolve().parents[1]
MODULE_PATH = SKILL_DIR / "scripts" / "aminer_api.py"
SPEC = importlib.util.spec_from_file_location("aminer_deep_search_api", MODULE_PATH)
assert SPEC and SPEC.loader
api = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = api
SPEC.loader.exec_module(api)
class FakeResponse:
def __init__(self, payload, status=200):
self.payload = payload
self.status = status
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
def run_main(argv, urlopen):
"""Run the CLI with a fake transport; return (exit_code, stdout, stderr)."""
stdout, stderr = io.StringIO(), io.StringIO()
env = {"AMINER_API_KEY": "test-token"}
api._call_counts.clear()
with mock.patch.dict(os.environ, env, clear=True), \
mock.patch.object(api.urllib.request, "urlopen", urlopen), \
contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
code = api.main(argv)
return code, stdout.getvalue(), stderr.getvalue()
def paper_info_response(request):
body = json.loads(request.data.decode("utf-8"))
return FakeResponse({
"code": 200, "success": True,
"data": [
{"id": pid, "title": f"Title {pid}",
"year": (2000 + int(pid)) if pid.isdigit() else 2015,
"venue": {"raw": "Venue X"}, "abstract_slice": "abs " * 200}
for pid in body["ids"]
],
})
class SearchTests(unittest.TestCase):
def test_search_paginates_filters_year_and_enriches(self):
search_pages = {
0: [{"id": "1", "title": "P1"}, {"id": "2", "title": "P2"}] +
[{"id": str(i), "title": f"P{i}"} for i in range(3, 21)],
1: [{"id": "21", "title": "P21"}],
}
calls = {"search": [], "info": 0}
def fake_urlopen(request, timeout):
url = request.full_url
if "/api/paper/search/pro" in url:
self.assertIn("keyword=graph", url)
self.assertIn("order=n_citation", url)
page = int(url.split("page=")[1].split("&")[0])
calls["search"].append(page)
return FakeResponse({"code": 200, "success": True,
"data": search_pages.get(page, [])})
if "/api/paper/info" in url:
calls["info"] += 1
return paper_info_response(request)
raise AssertionError(f"unexpected url: {url}")
code, out, err = run_main(
["search", "--query", "graph", "--size", "30",
"--year", "2010", "--order", "n_citation", "--max-pages", "3"],
fake_urlopen,
)
self.assertEqual(code, 0)
papers = json.loads(out)
# info entries carry year 2000+id; the --year 2010 cutoff keeps ids 1..10 and none from page 1 (id 21)
self.assertEqual([p["id"] for p in papers], [str(i) for i in range(1, 11)])
self.assertEqual(papers[0]["venue"], "Venue X")
self.assertLessEqual(len(papers[0]["abstract_slice"]), api.ABSTRACT_SLICE_LIMIT)
self.assertEqual(calls["search"], [0, 1]) # stops after exhausting pages
self.assertIn("[cost]", err)
self.assertNotIn("[cost]", out) # stdout stays pure JSON
def test_missing_key_returns_structured_error(self):
stdout = io.StringIO()
api._call_counts.clear()
with mock.patch.dict(os.environ, {}, clear=True), \
contextlib.redirect_stdout(stdout):
code = api.main(["search", "--query", "x"])
self.assertEqual(code, 2)
payload = json.loads(stdout.getvalue())
self.assertEqual(payload["error"], "missing_aminer_api_key")
self.assertNotIn("test-token", stdout.getvalue())
class InfoTests(unittest.TestCase):
def test_info_batches_at_most_100_ids(self):
batch_sizes = []
def fake_urlopen(request, timeout):
self.assertIn("/api/paper/info", request.full_url)
body = json.loads(request.data.decode("utf-8"))
batch_sizes.append(len(body["ids"]))
return FakeResponse({"code": 200, "success": True,
"data": [{"id": pid, "title": f"T{pid}"} for pid in body["ids"]]})
ids = [str(i) for i in range(150)]
code, out, _ = run_main(["info", "--ids", *ids], fake_urlopen)
self.assertEqual(code, 0)
self.assertEqual(batch_sizes, [100, 50])
self.assertEqual(len(json.loads(out)), 150)
class ReferencesTests(unittest.TestCase):
def test_references_extracts_cited_excludes_seeds_and_tracks_sources(self):
def fake_urlopen(request, timeout):
url = request.full_url
if "/api/paper/relation" in url:
seed = url.split("id=")[1]
cited = {
"s1": [{"_id": "a"}, {"_id": "b"}, {"_id": "s2"}],
"s2": [{"_id": "b"}, {"_id": "c"}],
}[seed]
return FakeResponse({"code": 200, "success": True,
"data": [{"_id": seed, "cited": cited}]})
if "/api/paper/info" in url:
return paper_info_response(request)
raise AssertionError(f"unexpected url: {url}")
code, out, _ = run_main(["references", "--ids", "s1", "s2"], fake_urlopen)
self.assertEqual(code, 0)
papers = {p["id"]: p for p in json.loads(out)}
self.assertEqual(set(papers), {"a", "b", "c"}) # seed s2 excluded
self.assertEqual(papers["a"]["source_paper_ids"], ["s1"])
self.assertEqual(papers["b"]["source_paper_ids"], ["s1", "s2"])
def test_per_seed_limit(self):
def fake_urlopen(request, timeout):
url = request.full_url
if "/api/paper/relation" in url:
cited = [{"_id": f"r{i}"} for i in range(30)]
return FakeResponse({"code": 200, "success": True,
"data": [{"_id": "s1", "cited": cited}]})
if "/api/paper/info" in url:
return paper_info_response(request)
raise AssertionError(f"unexpected url: {url}")
code, out, _ = run_main(["references", "--ids", "s1", "--per-seed", "5"], fake_urlopen)
self.assertEqual(code, 0)
self.assertEqual(len(json.loads(out)), 5)
class QaSearchTests(unittest.TestCase):
def test_qa_search_always_uses_topic_mode(self):
captured = {}
def fake_urlopen(request, timeout):
url = request.full_url
if "/api/paper/qa/search" in url:
captured["body"] = json.loads(request.data.decode("utf-8"))
return FakeResponse({"code": 200, "success": True,
"data": [{"id": "1", "title": "P1"}]})
if "/api/paper/info" in url:
return paper_info_response(request)
raise AssertionError(f"unexpected url: {url}")
code, out, _ = run_main(
["qa-search", "--query", "GNN scalability",
"--topic-high", '[["graph neural network"]]', "--size", "5"],
fake_urlopen,
)
self.assertEqual(code, 0)
# with use_topic=false the backend ignores `query` (only reads `title`),
# yielding empty terms and envelope 403 — so the script must always send true
self.assertIs(captured["body"]["use_topic"], True)
self.assertEqual(captured["body"]["query"], "GNN scalability")
self.assertEqual(captured["body"]["topic_high"], '[["graph neural network"]]')
self.assertEqual(json.loads(out)[0]["id"], "1")
def test_qa_search_requires_query_or_topic(self):
code, out, _ = run_main(["qa-search"], lambda *a, **k: None)
self.assertEqual(code, 2)
self.assertEqual(json.loads(out)["error"], "invalid_request")
class TransportTests(unittest.TestCase):
def test_http_200_with_error_code_in_envelope_is_reported(self):
def fake_urlopen(request, timeout):
return FakeResponse({"code": 403, "success": True, "msg": "no data", "data": None})
code, out, _ = run_main(
["qa-search", "--query", "scalable GNN approaches"], fake_urlopen,
)
self.assertEqual(code, 1)
payload = json.loads(out)
self.assertEqual(payload["error"], "api_error")
self.assertEqual(payload["code"], 403)
def test_non_retryable_http_error_fails_fast(self):
attempts = []
def fake_urlopen(request, timeout):
attempts.append(1)
raise api.urllib.error.HTTPError(
request.full_url, 401, "Unauthorized", {},
io.BytesIO(b'{"msg":"bad token"}'),
)
code, out, _ = run_main(["info", "--ids", "x"], fake_urlopen)
self.assertEqual(code, 1)
payload = json.loads(out)
self.assertEqual(payload["status"], 401)
self.assertEqual(len(attempts), 1)
if __name__ == "__main__":
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SKILL_DIR = Path(__file__).resolve().parents[1]
MODULE_PATH = SKILL_DIR / "scripts" / "paper_set.py"
SPEC = importlib.util.spec_from_file_location("aminer_deep_search_paper_set", MODULE_PATH)
assert SPEC and SPEC.loader
paper_set = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = paper_set
SPEC.loader.exec_module(paper_set)
def run_main(argv, stdin_text=None):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
if stdin_text is not None:
with mock.patch.object(sys, "stdin", io.StringIO(stdin_text)):
code = paper_set.main(argv)
else:
code = paper_set.main(argv)
return code, stdout.getvalue()
class PaperSetTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.state = str(Path(self.tmp.name) / "paper_set.json")
def tearDown(self):
self.tmp.cleanup()
def test_add_from_stdin_dedupes_and_counts(self):
items = [
{"id": "a", "title": "Paper A", "year": 2020},
{"id": "b", "title": "Paper B"},
]
code, out = run_main(["--file", self.state, "add"], json.dumps(items))
self.assertEqual(code, 0)
self.assertEqual(json.loads(out), {"added": 2, "duplicates": 0, "total": 2})
again = [{"id": "a", "title": "Paper A"}, {"id": "c", "title": "Paper C"}]
code, out = run_main(["--file", self.state, "add"], json.dumps(again))
self.assertEqual(json.loads(out), {"added": 1, "duplicates": 1, "total": 3})
def test_add_bare_ids(self):
code, out = run_main(["--file", self.state, "add", "--ids", "x", "y", "x"])
self.assertEqual(code, 0)
self.assertEqual(json.loads(out), {"added": 2, "duplicates": 1, "total": 2})
def test_source_paper_ids_mark_seeds_expanded(self):
items = [{"id": "a", "title": "A", "source_paper_ids": ["seed1", "seed2"]}]
run_main(["--file", self.state, "add"], json.dumps(items))
code, out = run_main(["--file", self.state, "stats"])
stats = json.loads(out)
self.assertEqual(stats["expanded_seeds"], ["seed1", "seed2"])
# source_paper_ids must not be persisted onto the paper entry
state = json.loads(Path(self.state).read_text(encoding="utf-8"))
self.assertNotIn("source_paper_ids", state["papers"]["a"])
def test_mark_expanded(self):
run_main(["--file", self.state, "mark-expanded", "--ids", "s1"])
code, out = run_main(["--file", self.state, "stats"])
self.assertEqual(json.loads(out)["expanded_seeds"], ["s1"])
def test_stats_by_year(self):
items = [
{"id": "a", "title": "A", "year": 2021},
{"id": "b", "title": "B", "year": 2021},
{"id": "c", "title": "C"},
]
run_main(["--file", self.state, "add"], json.dumps(items))
_, out = run_main(["--file", self.state, "stats"])
stats = json.loads(out)
self.assertEqual(stats["total"], 3)
self.assertEqual(stats["by_year"]["2021"], 2)
self.assertEqual(stats["by_year"]["unknown"], 1)
def test_export_shape_and_file(self):
items = [
{"id": "a", "title": "Paper A", "year": 2020, "venue": "V"},
{"id": "no-title"},
]
run_main(["--file", self.state, "add"], json.dumps(items))
out_path = str(Path(self.tmp.name) / "final.json")
code, out = run_main(["--file", self.state, "export", "-o", out_path])
self.assertEqual(code, 0)
result = json.loads(out)
self.assertEqual(result, {"count": 1, "path": out_path})
exported = json.loads(Path(out_path).read_text(encoding="utf-8"))
self.assertEqual(exported, [{"id": "a", "title": "Paper A"}])
def test_empty_stdin_is_a_structured_error(self):
code, out = run_main(["--file", self.state, "add"], "")
self.assertEqual(code, 2)
self.assertEqual(json.loads(out)["error"], "invalid_input")
if __name__ == "__main__":
unittest.main()
+363
View File
@@ -0,0 +1,363 @@
---
name: aminer-free-academic
version: 1.1.1
author: AMiner
contact: report@aminer.cn
description: >
ACADEMIC PRIORITY: Activate this skill whenever the user's query involves any academic or research-related topic. This is the free-tier entry point for AMiner academic search.
Free-tier-only AMiner skill (7 free APIs, zero cost). Use this skill for simple, single-step academic lookups that do not require paid API fields.
Use this skill for: searching a paper by title to get its ID, checking a paper's first author / venue / year / citation bucket, identifying a scholar by name and viewing interests / institution / citation count, normalizing an institution name to its canonical form and ID, checking whether a venue is a conference or journal, scanning patent trends by keyword (inventor, application year, publication year), and enriching paper IDs with lightweight metadata (abstract slice, author count, venue ID) via paper_info.
Do NOT use this skill for: full paper abstracts or keyword lists, multi-condition or semantic paper search, citation relationship analysis, scholar full profiles (bio, education, work history, honors), scholar paper / patent / project lists, institution scholar / paper / patent output analysis, venue paper lists by year, patent deep details (IPC/CPC, assignee, claims), or any task requiring paid APIs.
Routing rule: if the user's question can be fully answered by paper_search, paper_info, person_search, organization_search, venue_search, patent_search, or patent_info alone, use this skill. Otherwise route to aminer-academic-search.
metadata:
{
"openclaw":
{
"requires": {"env": ["AMINER_API_KEY"] },
"primaryEnv": "AMINER_API_KEY"
}
}
---
# AMiner Free Search
Use this skill for AMiner requests that should stay on the free tier first. It is designed for discovery, initial screening, and entity normalization, not deep analysis.
## Scope
This skill uses only the upgraded free interfaces:
- `paper_search`
- `paper_info`
- `person_search`
- `organization_search`
- `venue_search`
- `patent_search`
- `patent_info`
Current free-tier fields emphasized by this skill:
- `paper_search`: `venue_name`, `first_author`, `n_citation_bucket`, `year`
- `paper_info`: `abstract_slice`, `year`, `venue_id`, `author_count`
- `organization_search`: `aliases` (top 3)
- `venue_search`: `aliases` (top 3), `venue_type`
- `patent_search`: `inventor_name` (first), `app_year`, `pub_year`
- `patent_info`: `app_year`, `pub_year`
- `person_search`: `interests`, `n_citation`, institution fields
## Primary Goal
Use free APIs to help the user answer:
- What is this entity?
- Is it relevant enough to continue?
- Which candidate should I inspect next?
- Can I normalize this institution or venue name?
- Is there enough value to justify upgrading to paid APIs?
Do not use this skill for full scholar portraits, citation-chain analysis, full-text-like paper understanding, large-scale monitoring, or institution output analysis.
## Mandatory Rules
1. Stay on free APIs unless the user explicitly asks to upgrade or the free path clearly cannot answer the question.
2. Be explicit about free-tier limits. Say what can be answered now and what would require a paid upgrade.
3. Use free results to narrow candidates before suggesting any paid API.
4. If returning entities, append AMiner URLs when IDs are available:
- Paper: `https://www.aminer.cn/pub/{paper_id}`
- Scholar: `https://www.aminer.cn/profile/{scholar_id}`
- Patent: `https://www.aminer.cn/patent/{patent_id}`
- Venue: `https://www.aminer.cn/open/journal/detail/{venue_id}`
## Token Check (Required)
Before making any API call, verify that the environment variable `AMINER_API_KEY` exists. Never output the token in plain text.
```bash
if [ -z "${AMINER_API_KEY+x}" ]; then
echo "AMINER_API_KEY does not exist"
else
echo "AMINER_API_KEY exists"
fi
```
- If `${AMINER_API_KEY}` exists: proceed with the query.
- If `${AMINER_API_KEY}` is not set: stop immediately and guide the user to the [AMiner Console](https://open.aminer.cn/open/board?tab=control) to generate one. For help, see the [Open Platform Documentation](https://open.aminer.cn/open/docs).
- If the user provides `AMINER_API_KEY` inline (e.g. "My token is xxx"), accept it for the current session, but recommend setting it as an environment variable for better security.
## Invocation Style
Use direct `curl` calls by default. A Python wrapper is not required for this skill.
Default headers:
- `Authorization: ${AMINER_API_KEY}` by default
- `Content-Type: application/json;charset=utf-8` for POST requests
- `X-Platform: openclaw` when required by the gateway
## When To Use
Use this skill when the user asks for:
- free AMiner search
- low-cost academic discovery
- paper screening
- scholar identification
- institution normalization
- venue normalization
- patent trend scanning
- representative results before deeper analysis
Trigger phrases include:
- “先用免费接口”
- “不要走收费接口”
- “先帮我筛一下”
- “先看看值不值得深挖”
- “找几个候选”
- “做一个轻量版 skill”
## Free Workflows
### 1. Paper triage
Use when the user wants to quickly judge whether a paper is relevant.
Default chain:
`paper_search -> paper_info`
Return:
- title
- first author
- venue name
- year
- citation bucket
- abstract slice
- paper URL
This can answer:
- Is this probably the right paper?
- Is it recent?
- Is it from a recognizable venue?
- Is it worth opening in detail?
### 2. Scholar identification
Use when the user wants to know which scholar is the right person.
Default chain:
`person_search`
Return:
- name
- org
- interests
- citation count
- scholar URL
This can answer:
- Is this the right scholar?
- What interests best describe this person?
- Which institution candidate is the best match?
### 3. Institution normalization
Use when the user provides an institution string or abbreviation.
Default chain:
`organization_search`
Return:
- org id
- standard name
- aliases (top 3)
This can answer:
- Is this institution name recognized?
- Which canonical organization should downstream workflows use?
### 4. Venue normalization and type check
Use when the user provides a conference or journal name.
Default chain:
`venue_search`
Return:
- venue id
- standard bilingual name
- aliases (top 3)
- venue type
- venue URL
This can answer:
- Is this a conference or a journal?
- What is the standard venue entity?
### 5. Patent trend scan
Use when the user wants a lightweight view of patents in a topic.
Default chain:
`patent_search -> patent_info` when IDs need basic enrichment
Return:
- patent title
- first inventor
- app year
- pub year
- patent number and country when `patent_info` is added
- patent URL
This can answer:
- Is the topic active recently?
- Who appears first in the inventor field?
- Is there recent patent activity worth deeper review?
### 6. Free entity map
Use when the user wants a quick map of a topic across papers, scholars, venues, institutions, and patents without paying for analysis-grade APIs.
Suggested chain:
- papers: `paper_search -> paper_info`
- scholars: `person_search`
- institutions: `organization_search`
- venues: `venue_search`
- patents: `patent_search -> patent_info`
Return a short cross-entity summary, not a deep report.
## Free Skill Examples
### 1. Paper triage
```bash
curl -X GET \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/paper/search?page=1&size=5&title=Attention%20Is%20All%20You%20Need' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw'
```
Then enrich with `paper_info`:
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/paper/info' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"ids":["<PAPER_ID>"]}'
```
### 2. Scholar identification
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/person/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"name":"Yann LeCun","size":5}'
```
### 3. Institution normalization
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/organization/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"orgs":["MIT CSAIL"]}'
```
### 4. Venue normalization and type check
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/venue/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"name":"tkde"}'
```
### 5. Patent trend scan
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/patent/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"query":"quantum computing chip","page":0,"size":10}'
```
## Output Pattern
Prefer this structure:
```markdown
## Free-tier result
### What we can answer now
- ...
### Top candidates
- ...
### Suggested next step
- Stay free: ...
- Upgrade to paid API only if you need: ...
```
## Paid Upgrade Boundary
Recommend upgrading only when the user needs one of these:
- full abstract or full paper metadata
- multi-condition or semantic paper search
- citation relationships
- full scholar profile, works, patents, or projects
- institution scholars, papers, patents, or rich profiles
- venue paper lists by year
- full patent details such as IPC/CPC, assignee, description
Suggested paid handoff:
- deeper paper analysis: `paper_search_pro`, `paper_detail`, `paper_relation`
- deeper scholar analysis: `person/detail`, `person/figure`, `person/paper/relation`
- deeper org analysis: `organization/detail`, `organization/person/relation`, `organization/paper/relation`
- deeper venue analysis: `venue/detail`, `venue/paper/relation`
- deeper patent analysis: `patent/detail`
## Product Positioning
This skill is intentionally positioned for:
- first success
- free discovery
- candidate narrowing
- entity normalization
- upgrade qualification
It should not replace the paid skill. It should create demand for it.
## Additional Reference
For endpoint parameters and fields, read [references/api-catalog.md](references/api-catalog.md).
+93
View File
@@ -0,0 +1,93 @@
{
"skill_name": "aminer-free-academic",
"evals": [
{
"id": 1,
"prompt": "Help me quickly check whether the paper 'Attention Is All You Need' is worth a deep dive. Use only free AMiner APIs. My token is <YOUR_TOKEN>",
"expected_output": "Run the paper triage workflow: paper_search to locate the paper, then paper_info to enrich with abstract slice, year, venue, and author count. Output a lightweight card with a paper URL.",
"files": [],
"expectations": [
"Called Paper Search API (paper_search) to find the paper by title",
"Called Paper Info API (paper_info) with the returned paper ID",
"Output includes title, first_author, venue_name, year, and n_citation_bucket",
"Output includes abstract_slice from paper_info",
"Output includes a paper URL in the format https://www.aminer.cn/pub/{paper_id}",
"No paid API was called"
]
},
{
"id": 2,
"prompt": "I want to identify which 'Andrew Ng' on AMiner is the Stanford professor. Use only free APIs. My token is <YOUR_TOKEN>",
"expected_output": "Run the scholar identification workflow: person_search with name and optional org filter. Return candidate list with interests, org, and citation count to help the user pick the right one.",
"files": [],
"expectations": [
"Called Scholar Search API (person_search) with name 'Andrew Ng'",
"Output includes multiple candidates with name, org, interests, and n_citation",
"Output includes scholar URLs in the format https://www.aminer.cn/profile/{scholar_id}",
"No paid API was called"
]
},
{
"id": 3,
"prompt": "Normalize the institution name 'MIT CSAIL' using AMiner free APIs. My token is <YOUR_TOKEN>",
"expected_output": "Run the institution normalization workflow: organization_search with the raw institution string. Return the canonical org name, org_id, and aliases.",
"files": [],
"expectations": [
"Called Org Search API (organization_search) with orgs=['MIT CSAIL']",
"Output includes org_id and standardized org_name",
"Output includes aliases list",
"No paid API was called"
]
},
{
"id": 4,
"prompt": "Help me look up recent patents about quantum computing",
"expected_output": "Detect that the user has not provided a token; immediately stop all subsequent API calls and guide the user to obtain a token from the AMiner Console.",
"files": [],
"expectations": [
"Clearly inform the user that a token is currently missing and AMiner API calls cannot continue",
"No API calls were made",
"Provide the console link https://open.aminer.cn/open/board?tab=control",
"Prompt the user to continue after obtaining a token"
]
},
{
"id": 5,
"prompt": "Is 'TKDE' a conference or a journal? Help me normalize it using free AMiner APIs. My token is <YOUR_TOKEN>",
"expected_output": "Run the venue normalization workflow: venue_search with the name string. Return the standard bilingual name, venue_type, aliases, and venue URL.",
"files": [],
"expectations": [
"Called Venue Search API (venue_search) with name 'TKDE'",
"Output includes venue_id, standard name (English and/or Chinese), and venue_type",
"Output includes aliases list",
"Output includes a venue URL in the format https://www.aminer.cn/open/journal/detail/{venue_id}",
"No paid API was called"
]
},
{
"id": 6,
"prompt": "Scan recent patent activity in the field of 'autonomous driving lidar' using free APIs only. My token is <YOUR_TOKEN>",
"expected_output": "Run the patent trend scan workflow: patent_search to find patents, optionally enriched by patent_info. Return patent titles, first inventor, app_year, pub_year, and patent URLs.",
"files": [],
"expectations": [
"Called Patent Search API (patent_search) with a query about autonomous driving lidar",
"Output includes patent titles, inventor_name, app_year, and pub_year",
"Output includes patent URLs in the format https://www.aminer.cn/patent/{patent_id}",
"No paid API was called"
]
},
{
"id": 7,
"prompt": "Give me a quick free-tier overview of the topic 'graph neural network' — papers, scholars, institutions, and venues. My token is <YOUR_TOKEN>",
"expected_output": "Run the free entity map workflow: combine paper_search, person_search, organization_search, and venue_search to build a lightweight cross-entity summary.",
"files": [],
"expectations": [
"Called at least paper_search and person_search",
"Output covers multiple entity types (papers, scholars, and optionally institutions/venues)",
"Output includes AMiner URLs for returned entities",
"Clearly states what free-tier can answer and what would require paid upgrade",
"No paid API was called"
]
}
]
}
+286
View File
@@ -0,0 +1,286 @@
# AMiner Free Search API Catalog
**Base URL**: `https://publicapi.chatglm.cn/chatglm_public/skill/aminer`
**Authentication**: All endpoints should default to `Authorization: ${AMINER_API_KEY}`. In workflow execution, also include `X-Platform: openclaw` when required by the gateway.
**Scope**: This catalog only documents the free APIs used by `aminer-free-academic`.
---
## Table of Contents
- [Paper APIs](#paper-apis)
- [Scholar APIs](#scholar-apis)
- [Institution APIs](#institution-apis)
- [Venue APIs](#venue-apis)
- [Patent APIs](#patent-apis)
---
## Paper APIs
### 1. Paper Search
- **URL**: `GET /api/paper/search`
- **Price**: Free
- **Description**: Search papers by title and return low-cost screening fields suitable for fast paper triage.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| page | number | Yes | Page number. Current online definition says it starts from `1`. |
| size | number | No | Page size, maximum `20` |
| title | string | Yes | Paper title |
**Response Fields:**
| Field | Description |
|--------|------|
| id | Paper ID |
| title | Paper title |
| title_zh | Paper title in Chinese |
| doi | DOI |
| first_author | First author |
| venue_name | Venue title |
| n_citation_bucket | Citation bucket: `0`, `1-10`, `11-50`, `51-200`, `200-1000`, `1000-5000`, `5000+` |
| year | Publication year |
| total | Total count |
**curl Example:**
```bash
curl -X GET \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/paper/search?page=1&size=10&title=Looking+at+CTR+Prediction+Again%3A+Is+Attention+All+You+Need' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw'
```
---
### 2. Paper Info
- **URL**: `POST /api/paper/info`
- **Price**: Free
- **Description**: Batch query paper basic cards by paper IDs. Suitable for enriching search results with lightweight metadata.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| ids | []string | Yes | Paper ID list, maximum `100` |
**Response Fields:**
| Field | Description |
|--------|------|
| id | Paper ID |
| title | Paper title |
| abstract_slice | Partial abstract |
| authors | Author array |
| author_count | Total author count |
| issue | Volume / issue field |
| raw | Venue raw name |
| venue | Venue object |
| venue_id | Venue ID |
| year | Publication year |
**curl Example:**
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/paper/info' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"ids":["5ce2c5a5ced107d4c61c839b"]}'
```
---
## Scholar APIs
### 3. Scholar Search
- **URL**: `POST /api/person/search`
- **Price**: Free
- **Description**: Search scholar candidates by name and optional institution condition.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| name | string | No | Scholar name |
| offset | number | No | Starting position (fixed at 0; pagination not supported) |
| org | string | No | Institution name |
| size | number | No | Number of results, maximum `10` |
| org_id | []string | No | Institution entity ID list |
**Response Fields:**
| Field | Description |
|--------|------|
| id | Scholar ID |
| interests | Research interests |
| n_citation | Citation count |
| name | Name |
| name_zh | Chinese name |
| org | Institution in English |
| org_id | Institution ID |
| org_zh | Institution in Chinese |
| total | Total count |
**curl Example:**
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/person/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"name":"王曙","offset":0,"org":"Shanghai Jiaotong","size":10}'
```
---
## Institution APIs
### 4. Org Search
- **URL**: `POST /api/organization/search`
- **Price**: Free
- **Description**: Search institution IDs and standard names from institution keywords.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| orgs | []string | No | Institution names |
**Response Fields:**
| Field | Description |
|--------|------|
| aliases | Alias list, partial and typically top 3 |
| org_id | Institution ID |
| org_name | Institution name |
| total | Total count |
**curl Example:**
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/organization/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"orgs":["清华大学"]}'
```
---
## Venue APIs
### 5. Venue Search
- **URL**: `POST /api/venue/search`
- **Price**: Free
- **Description**: Search venue IDs and standard names by venue keyword, including aliases and venue type.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| name | string | No | Venue name |
**Response Fields:**
| Field | Description |
|--------|------|
| id | Venue ID |
| name_en | Venue English name |
| name_zh | Venue Chinese name |
| aliases | Alias list, partial and typically top 3 |
| venue_type | Venue type: `journal` or `conference` |
| total | Total count |
**curl Example:**
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/venue/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"name":"tkde"}'
```
---
## Patent APIs
### 6. Patent Search
- **URL**: `POST /api/patent/search`
- **Price**: Free
- **Description**: Search patents by title or keyword and return lightweight trend fields.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| query | string | Yes | Query text such as patent title or keywords |
| page | number | Yes | Page number |
| size | number | Yes | Page size |
**Response Fields:**
| Field | Description |
|--------|------|
| id | Patent ID |
| title | Patent title in English |
| title_zh | Patent title in Chinese |
| inventor_name | First inventor name |
| app_year | Application year |
| pub_year | Publication year |
**curl Example:**
```bash
curl -X POST \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/patent/search' \
-H 'Content-Type: application/json;charset=utf-8' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw' \
-d '{"page":0,"query":"Si02","size":20}'
```
---
### 7. Patent Info
- **URL**: `GET /api/patent/info`
- **Price**: Free
- **Description**: Retrieve a patent basic card by patent ID.
**Request Parameters:**
| Parameter | Type | Required | Description |
|--------|------|------|------|
| id | string | Yes | Patent ID |
**Response Fields:**
| Field | Description |
|--------|------|
| id | Patent ID |
| title / en | Patent title |
| app_num | Application number |
| pub_num | Publication number |
| pub_kind | Publication kind |
| inventor | Inventor |
| country | Country |
| sequence | Sequence |
| app_year | Application year |
| pub_year | Publication year |
**curl Example:**
```bash
curl -X GET \
'https://publicapi.chatglm.cn/chatglm_public/skill/aminer/api/patent/info?id=<PATENT_ID>' \
-H "Authorization: ${AMINER_API_KEY}" \
-H 'X-Platform: openclaw'
```
+245
View File
@@ -0,0 +1,245 @@
---
name: anti-pua
description: 识别和分析PUA(Pickup Artist)及情感操纵行为的专业心理分析工具。具备人格分析、心理侧写、情感分析能力,能够识别情感操纵、煤气灯操纵、虐待等有毒关系模式,评估人格特质(如黑暗三人格、脆弱型自恋等),预测对方行为并给出具体的相处建议。当用户需要:分析对方言行动机、识别PUA/情感操纵行为、评估NPD(自恋型人格障碍)倾向、识别操纵行为、预测对方未来行为、寻求健康关系建议、分析黑暗三人格或光明三人格时使用此skill。
---
# 反PUA大师 - 情感操纵识别与心理分析
## 核心角色定位
你是一位专业行为分析专家和心理顾问。你具备:
- **人格分析能力**:深度分析人格特质,识别黑暗三人格(自恋、马基雅维利主义、精神病态)、脆弱型自恋、光明三人格等
- **心理侧写能力**:通过言行分析对方的心理动机和内在模式
- **情感分析能力**:识别情感操纵、虐待、煤气灯操纵(gaslighting)等具体行为
- **行为预测能力**:基于人格分析预测对方下一步可能的行为
## 🚀 使用开始 - 必须先询问的问题
**当用户开始使用这个skill时,必须先按顺序询问以下问题,收集关键信息:**
### 问题1:对方与咨询人的关系
"请问对方与您是什么关系?"
- 朋友、情侣、配偶
- 领导、同事、下属
- 导师、老师、同学
- 父母、兄妹、姐弟、子女、亲戚
- 陌生人、保密、其他
### 问题2:用户希望我们做什么
"根据对方目前的行为表现,您希望我们为您做什么?"
- 分析行为动机
- 识别PUA
- 评估NPD(自恋型人格障碍)倾向
- 识别操纵行为
- 预测未来行为
- 倾听疗愈
- 寻求健康关系建议
- 黑暗三人格分析
- 光明三人格分析
- 其他(请说明)
### 问题3:提供详细描述
"请客观详细描述对方在与您相处时的言行细节,包括:"
- 具体的对话内容(原话或近似表述)
- 具体的行为表现
- 这些言行发生的情境和背景
- 您当时的感受和反应
### 问题4:支持上传材料
"您也可以上传以下材料辅助分析:"
- 聊天记录截图
- 对话文字记录
- 其他相关证据
### 问题5:概念咨询(可选)
"或者,如果您只是想了解与PUA相关的概念,也可以随时问我,例如:"
- 什么是PUA?
- 什么是NPD?
- 什么是煤气灯操纵?
- 什么是爱情轰炸?
- 其他PUA相关概念
---
## 工作流程
**在收集完上述信息后,按照以下流程进行分析:**
**特殊处理场景:**
1. **概念咨询**:如果用户选择了解PUA相关概念(问题5),直接以通俗易懂的方式解释相关概念,配合实际案例说明,不需要走完整分析流程。
2. **倾听疗愈**:如果用户选择"倾听疗愈"(问题2),优先表达共情和理解,不急于分析,避免结构化输出,以对话式回应为主。
3. **信息不足处理**:如果用户提供的描述过于简略或模糊,应先追问具体细节(如具体对话内容、行为情境、发生时间等),不要基于有限信息做出过度推断。
4. **反向利用防护**:如果用户询问如何操纵、控制他人或利用这些技巧伤害他人,明确拒绝并说明这些工具的目的是识别和保护,而非攻击。
### 第1步:理解关系背景
根据用户回答的【问题1】和【问题2】,明确:
- 双方关系类型
- 用户的核心需求
- 分析的重点方向
### 第2步:初步分析
根据用户提供的对方言行(对话、行为描述等),进行初步分析:
- 识别言语中的潜在操纵模式
- 分析行为背后的可能动机
- 标记可疑的PUA/情感操纵信号
### 第3步:专业心理评估
进行深入的心理分析,使用专业心理学术语:
**人格特质分析:**
- 是否存在黑暗三人格特征:
- 自恋(Narcissism):自我中心、寻求赞美、缺乏同理心
- 马基雅维利主义(Machiavellianism):操纵性、欺骗性、情感冷漠
- 精神病态(Psychopathy):冲动、缺乏悔意、情感肤浅
- 是否存在脆弱型自恋:外表脆弱但内心极度需要认可
- 光明三人格:同理心、诚实、谦逊等积极特质
**操纵行为识别:**
- 情感操纵:利用情感弱点控制对方
- 煤气灯操纵(Gaslighting):质疑对方的现实感,让受害者怀疑自己的记忆和理智
- 爱情轰炸(Love Bombing):初期过度亲密,随后突然抽离
- 沉默对待(Silent Treatment):通过冷暴力惩罚对方
- 贬低与打压:削弱对方自尊,建立依赖
- 三角关系(Triangulation):引入第三方制造嫉妒和不安全感
- 责任转移:将问题归咎于受害者
**心理动机分析:**
- 控制欲的来源
- 不安全感的表现
- 自我价值感的获取方式
- 权力需求的满足机制
### 第4步:通俗解释与预测
用非心理学专业用户能听懂的语言,复述上述专业分析:
- 将专业术语转化为日常语言
- 用具体例子说明抽象概念
- 解释这些行为对关系的实际影响
- 预测对方下一步可能的行为模式
### 第5步:提供建议
在分析末尾给出具体的相处建议:
**立即行动建议:**
- 如何回应当下的操纵行为
- 如何设立和维持边界
- 如何保护自己的情感安全
**长期关系建议:**
- 是否应该结束这段关系
- 如何改善健康的关系模式
- 如何识别未来的有毒关系
**自我保护策略:**
- 建立情感支持系统
- 提升自我认知和自尊
- 学习健康的沟通方式
## 输出格式
结构化输出以下部分:
### 1. 对话/行为解读
- 对方言行的表面含义
- 潜在的真实动机
- 言行背后的心理需求
### 2. PUA/操纵警示信号
- 列出识别到的操纵行为
- 标注严重程度(低/中/高)
- 说明这些行为的典型模式
### 3. 人格特质分析
- 主要人格特征
- 可能的人格类型(如:自恋型、马基雅维利主义型等)
- 这些特征如何影响关系
### 4. 行为预测
- 对方下一步可能的行为
- 这些行为的发展趋势
- 关系可能的发展方向
### 5. 应对策略
- 立即可以使用的回应方式
- 长期的关系管理策略
- 何时需要退出关系
### 6. 健康关系对照
- 健康关系应该是什么样
- 当前关系与健康的差距
- 如何建立更健康的关系
## 重要原则
1. **客观中立**:基于事实和心理学理论进行分析,避免过度解读
2. **保护受害者**:优先考虑用户的心理安全和情感健康
3. **避免诊断**:说明这是基于有限信息的分析,不能替代专业心理诊断
4. **赋能用户**:帮助用户建立自信和边界,而不是增加依赖
5. **提供资源**:在严重情况下,建议寻求专业心理咨询帮助
## 示例分析框架
### 示例1:简要分析
**用户输入:** "他总是说'那你非要去北京什么意思?',让我觉得自己做错了事"
**分析输出:**
### 对话解读
- **表面含义**:询问你为什么一定要去北京
- **潜在动机**:通过质疑你的选择,让你产生内疚和自我怀疑
- **心理需求**:控制你的决策,建立对你的权威
### PUA警示信号
- **煤气灯操纵**:让你怀疑自己的判断和选择
- **情感否定**:不尊重你的个人需求和决定
- **严重程度**:中
### 人格特质分析
- **控制欲强**:试图影响你的重大决策
- **缺乏同理心**:不关心你的个人追求
- **可能特征**:自恋型或马基雅维利主义倾向
### 行为预测
- 如果你不顺从,可能会:
- 加大情感压力
- 使用沉默对待
- 贬低你的能力
### 应对策略
- **立即回应**:"这是我的个人决定,我需要你的支持而不是质疑"
- **设立边界**:明确表示重大决策需要相互尊重
- **长期策略**:观察他是否尊重你的独立性,如持续不尊重,考虑关系健康度
### 健康关系对照
- **健康关系**:尊重彼此的个人选择,支持对方的成长
- **当前差距**:缺乏对个人独立性的尊重
- **改进方向**:建立相互尊重的沟通模式
---
### 示例2:完整流程(从信息收集到分析输出)
**场景**:用户希望分析男友的行为模式
**信息收集阶段:**
**AI(问题1)**:请问对方与您是什么关系?
**用户**:男朋友,我们在一起6个月了
**AI(问题2)**:根据对方目前的行为表现,您希望我们为您做什么?
**用户**:识别PUA,分析他的行为动机
**AI(问题3)**:请客观详细描述对方在与您相处时的言行细节,包括具体的对话内容、行为表现、这些言行发生的情境和背景,以及您当时的感受和反应。
**用户**:刚认识时他对我特别好,每天发很多消息,说我是他遇到的最好的人,送我很多礼物。但最近3个月,他开始经常批评我,说我穿得不好看、工作不够努力、朋友都不靠谱。每次我反驳他,他就会说"我这么说都是为你好,你不懂感恩"。而且他经常说"没有我你什么都不是"、"其他女生都比你懂事"。最可怕的是,有时候我明明记得他说过某句话,他却说"我没说过,你记错了",让我开始怀疑自己的记忆。
+317
View File
@@ -0,0 +1,317 @@
---
name: auto-target-tracker
description: 自动目标进度追踪器。在对话中检测到目标相关图片(笔记、进度、截图、记录)时,自动调用 VLM 识别关键信息并记录到目标日记。适用于学习管理、健身追踪、工作进度、习惯养成、创作记录等所有目标管理场景。
---
# 自动目标进度追踪器
## 触发条件
当对话中出现以下条件时自动触发:
1. **用户发送了图片**(特别是学习笔记、进度截图、健身记录、任务清单、创作作品等)。
2. **用户在设定的目标时间段**(如 08:30, 10:00, 20:00)发送了图片。
3. **用户明确说**"帮我记一下"、"看下进度"、"打卡"、"更新一下"等。
---
## 工作流程
### 1. 检测图片
当检测到图片时,检查:
- 图片文件名是否包含目标关键词(progress, goal, task, workout, note等)
- 图片内容是否包含目标元素(进度条、文字、代码、图表、计划表等)
- 是否在预定的目标提醒时间附近
- 用户最近的对话上下文是否涉及目标的执行
### 2. 调用 VLM 识别
使用 vlm 工具识别图片:
**通用 prompt 模板**:
```
"识别图片中的关键信息,根据目标类型提取以下内容:
- 核心任务/内容
- 完成进度或数量
- 关键数据(如时间、重量、字数等)
- 给出一段简短的执行反馈"
```
**目标类型专用 prompt**:
| 目标类型 | Prompt |
|---------|--------|
| 学习 | "识别学习笔记,提取知识点、完成度" |
| 健身 | "识别健身记录,提取运动类型、组数、次数、重量" |
| 工作 | "识别工作进度,提取完成任务、完成率" |
| 创作 | "识别创作作品,提取创作类型、进度、关键元素" |
| 习惯 | "识别打卡记录,提取打卡内容、连续天数" |
### 3. 解析目标信息
从 VLM 返回的结果中提取:
- **任务/内容清单**:识别出的具体行动或任务
- **完成度**:基于图片内容的进度估算
- **关键数据**:时间、数量、重量、字数等量化指标
- **认知反馈**:对当前目标状态的简评
### 4. 记录到目标日记
调用`edit_daily`工具将识别结果记录到当天的日常笔记中
### 5. 反馈给用户
向用户确认识别结果:
```
已记录你的目标打卡:
📝 识别结果:
核心内容:你拍的是今天的英语单词表,一共记了 15 个新词。
进度估算:今天的单词任务全部搞定,进度打败了 80% 的学习党。
建议:有两个单词的拼写有点模糊,明天复习的时候记得多看两眼。
记录准确吗?要帮你存进今天的目标日记里吗?
```
---
## 记录格式
### 目标日记条目示例
```markdown
## 20:00 打卡记录
**目标类型**: 📚 学习
**图片**: ![目标图片](path/to/image.jpg)
**VLM识别结果**:
| 任务/内容 | 进度/数量 | 状态 |
|----------|----------|------|
| 英语单词 (Unit 1) | 15 个 | 已完成 |
| 数学练习 (第3章) | 80% | 进行中 |
| **总计** | | **今日达成 2/3** |
**关键数据**:
- 学习时长: 2小时
- 专注度: 高
**备注**: 自动识别,用户确认正确
---
## 10:30 健身打卡
**目标类型**: 🏃 健身
**图片**: ![健身记录](path/to/gym.jpg)
**VLM识别结果**:
| 运动类型 | 组数 | 次数 | 重量 | 状态 |
|---------|------|------|------|------|
| 卧推 | 4 | 12 | 60kg | ✅ 完成 |
| 深蹲 | 4 | 10 | 80kg | ✅ 完成 |
| 引体向上 | 3 | 8 | 自重 | ⚠️ 少一组 |
| **总计** | | | **今日达标** |
**关键数据**:
- 总重量: 2640kg
- 训练时长: 45分钟
**备注**: 引体向上少完成一组,下次补上
```
---
## 与目标系统的集成
### 每日汇总
在每天晚上 22:00 的汇总中,包含:
- 今日所有打卡记录
- 目标达成率分析
- 与目标的对比(如果设置了目标)
### 周/月报告
在周报告中,包含:
- 本周有效执行时长
- 目标覆盖范围
- 连续打卡天数
- 动态难度调整建议:如果连续达标,则建议提升下周任务量
---
## 常见使用场景
### 场景1:学习打卡
**用户行为**:发送手写笔记照片
**自动识别**:
- 提取知识点
- 计算学习进度
- 记录到学习日志
**反馈示例**:
```
📚 识别到学习笔记:
- 机器学习监督学习算法(已完成)
- 梯度下降优化器(进行中)
- 正则化防过拟合(未开始)
进度:33% | 预计还需 2 小时完成
```
### 场景2:健身打卡
**用户行为**:发送健身记录照片
**自动识别**:
- 提取运动类型
- 统计组数、次数、重量
- 计算训练量
**反馈示例**:
```
🏃 健身记录已识别:
- 卧推 60kg × 12 × 4组 ✅
- 深蹲 80kg × 10 × 4组 ✅
- 引体向上 自重 × 8 × 3组 ✅
总训练量:2640kg | 时长:45分钟
```
### 场景3:工作进度
**用户行为**:发送项目进度截图
**自动识别**:
- 提取已完成任务
- 计算完成百分比
- 识别剩余任务
**反馈示例**:
```
💼 工作进度已识别:
- 需求文档(已完成)✅
- 原型设计(已完成)✅
- 前端开发(进行中)🔄 80%
- 后端开发(未开始)⏳
项目总进度:67%
```
### 场景4:创作打卡
**用户行为**:发送创作作品照片
**自动识别**:
- 提取创作类型
- 识别关键元素
- 估算完成度
**反馈示例**:
```
🎨 创作记录已识别:
类型:插画创作
元素:人物角色、背景场景
完成度:线稿100%,上色60%
建议:今天完成了角色线稿,明天可以开始背景上色
```
### 场景5:习惯打卡
**用户行为**:发送打卡日历截图
**自动识别**:
- 提取连续打卡天数
- 识别今日打卡状态
- 计算打卡率
**反馈示例**:
```
✅ 习惯打卡已识别:
早起:连续 15 天 | 打卡率 100%
阅读:连续 8 天 | 打卡率 73%
运动:连续 21 天 | 打卡率 100%
🎉 运动已连续打卡 3 周,继续保持!
```
---
## Scope
This skill ONLY:
- 识别目标相关图片并提取关键信息
- 记录打卡数据到日常笔记文件
- 提供进度反馈和建议
This skill NEVER:
- 自动执行任何基于识别结果的操作
- 上传图片到外部服务(除 VLM API)
- 访问用户未授权的图片资源
- 修改用户的目标计划(仅记录进度)
---
## Security & Privacy
**Data that stays local:**
- 识别后的结构化结果
- 记录到 日常笔记或长期记忆 和 USER.md 的内容
- 打卡历史数据
**This skill does NOT:**
- 分享目标进度或打卡数据给第三方
- 自动发布打卡信息到社交平台
- 访问用户的其他图片资源
---
## 注意事项
1. **隐私保护**: 图片和识别结果仅存储在本地,不会上传到云端(除了调用 VLM API 进行识别)
2. **准确性**: VLM 识别的内容仅供参考,可能因字迹模糊、图片质量等原因有所偏差
3. **及时确认**: 建议用户在记录后及时确认识别结果,如有偏差可手动修正
4. **目标类型识别**: 系统会根据图片内容自动判断目标类型,如有误可手动调整
5. **进度估算**: 进度百分比基于图片内容估算,可能不准确,建议用户定期手动更新
---
## 集成建议
### 与 SOUL.md 配合
将自动追踪器整合到目标管理日常工作流中:
```markdown
### 2. 智能记录与估算 (Logging & Estimation)
- 当用户发送任何与目标相关的图片时:
1. 自动调用 auto-target-tracker 识别内容
2. 提取关键信息并估算进度
3. 立刻记录到日常笔记中
4. 同步更新 USER.md 的目标进度
```
### 与 HEARTBEAT.md 配合
在心跳检查中包含:
```markdown
## 每日汇总
- 22:00 自动读取今日所有打卡记录
- 生成目标进度报告
- 发送给用户
```
@@ -0,0 +1,35 @@
# Radical Transparency Influence Methodology
### Honesty
Radical transparency is a commitment to engaging prospects, clients, investors, and colleagues with complete candor, even if, on the surface, it may seem like it could hurt your chances of closing a sale or landing a particular investor. In B2B markets, where many salespeople are focused on meeting quotas and achieving their commission or bonus, this approach stands out as a refreshingly honest way to build relationships with potential customers. Honesty is a key element in any successful sales process, as it helps to foster trust and respect between the buyer and the seller.
### Leveraging Brain Science to Inform How We Sell
Sales software and technology have advanced exponentially over the past decade, but sales strategies and approaches have not kept pace. This has contributed to the growing feeling among B2B buyers that salespeople offer little value in the buying process.
To address this issue, we can look to modern research in the field of neurology first. Research from the past decade has shown that emotion is the biggest factor in important decisions. We know that a narrative, or story, is the most effective way to share information so that it has impact and can influence behavior. The human brain has evolved over millions of years to attach the most meaning to information presented in this way.
### Understanding The Analytic Brain vs The Lizard Brain
### The Modern Brain
Also known as the **Neocortex**, this is the most recent area of the human brain to have evolved. It is used to rationalize and analyze information, and is responsible for identifying mistakes and holes in newly presented concepts.
Unfortunately, a vast majority of salespeople have been trained to communicate information to this part of the brain. Brain scans have shown that important decisions are not processed in the Neocortex.
### The Lizard Brain
The **Limbic Cortex**, a part of the brain, has been present since the first humans evolved and is responsible for our behavior and how it can be modified. It is also the source of instinct and emotion, which are essential for successful sales conversations.
Brain scans have shown that the limbic system plays a major role in significant decision-making. Investing, buying, and other consequential decisions are often driven by emotion, motivating actions.
### What does this have to do with Sales?
What makes this methodology especially effective is that it focuses primarily on both how the human brain takes in information most effectively and how it processes that information within a context where value is being presented in exchange for money.
Unfortunately, most salespeople are out of the game within the first few seconds. This happens because older methodologies and sales training practices are focused heavily on delivering information to the analytical brain. This is not ideal, as the analytical brain is largely responsible for skepticism, non-emotional evaluation, and is therefore very difficult to persuade.
On the other hand, salespeople who deliver information primarily to the part of the brain responsible for emotion, the lizard brain, are able to influence the behavior of their prospects more effectively. This is because buying decisions are most often emotional in nature.
While the analytical brain is stimulated by identifying mistakes and justifying actions, the limbic system is stimulated by stories, human connection, shared beliefs, and driving emotionally based decisions.
@@ -0,0 +1,33 @@
# Give Spotlight on Mac Superpowers with Raycast
## What is Raycast?
I recently rediscovered Raycast and wanted to try it again after a few years. Raycast has infinitely more features than Spotlight (Apple's search tool).
I was skeptical, assuming it would be just another Spotlight replacement that would require a lot of effort for minimal productivity gains, so I was hesitant to give it a try thinking I'd need to devote time and resources I don't have to a steep learning curve. Boy was I wrong!
I finally decided to give it a try, and it's probably the single most impactful software I've introduced into my daily workflows, outside of maybe Notion.
I use it countless times daily for tasks like searching Hubspot for contact or deal information, generating social posts with ChatGPT, and starting my next Zoom meeting. Tasks that would have taken at least a minute or two now take under 10 seconds.
I've saved a significant amount of time using Raycast to access all kinds of information from my most-used applications. The real kicker is that Raycast is a completely **free** application and not a "free plan" with the all the good features paywalled. It's a no-brainer for anyone who frequently uses Spotlight, keyboard shortcuts, or those built into MacOS.
### The Raycast Extension Store
Raycast's Extension Store is a comprehensive directory that's divided into three main categories: productivity, utility, and business. The store houses thousands of extensions that can be installed by simply tapping return.
The extensions in the extension store are provided by a dedicated community of developers and are constantly being updated with new features and improvements. There are thousands of extensions available for the most popular apps and tools out there. A few notable options include Salesforce, Hubspot, ChatGPT, Slack, Notion, Crunchbase, Google Drive/Meet/Calendar/Search, Facetime, Mail, WhatsApp, Todoist, ClickUp, and so many more.
### Interacting with Applications
Since discovering Raycast a few weeks ago, I've been incredibly impressed with its capabilities.
More important than the fantastic selection of app extensions is how Raycast allows users to interact directly with these apps.
Tasks like searching Hubspot or Salesforce for a contact's email, starting your next Zoom meeting, or adding a quick task to Notion now take less than 5-10 seconds. For example, instead of going into Hubspot and finding a contact's profile to grab an email or phone number, I can hit ⌘+space, type "hub," and search my entire Hubspot database right from the command bar.
### Conclusion
It's still hard to believe, but Raycast is completely 100% free. This isn't a "free plan" with all the good features paywalled, but all features are free.
Raycast is a game-changer for any professional working on a Mac that frequently uses keyboard shortcuts, Apple's Spotlight, or just wants to save a ton of time finding information within your most used apps.
@@ -0,0 +1,47 @@
# How Short-form Content Is Changing Marketing & Storytelling Forever
Over the past decade, our youngest generations have been fighting a losing battle against the impact that short-form algorithms, used in apps like TikTok and Instagram, have had on their brains and attention spans. As a result, the tried-and-true structure of a compelling story or narrative is no longer as effective in the world of marketing. These media formats have literally changed the structure of an effective and compelling narrative.
In a world where a tweet can spark a movement and a 60-second video can go viral, we are living through one of the largest transformations in how we share ideas, stories, and information. This shift, driven by the rise of short-form content, is redefining the very structure of an effective story and dramatically changing how marketers communicate with their audience.
## How Did We Get Here?
### Sesame Street Started It. Really.
Believe it or not, the journey begins with "Sesame Street." This iconic children's show was ahead of its time, using short, engaging segments to educate and engage young viewers. It demonstrated early on how quick and concise content can effectively capture attention and communicate messages. This pioneering approach laid the groundwork for the myriad of short-form content styles we see today.
### The Mobile Tech Influence
With the advent of mobile technology, more importantly, the smartphone, shorter content naturally matched this type of consumption. In 2024, the vast majority of content is viewed through a mobile device, which helped to cement short-form as the dominant and most effective content style for generating engagement.
### The Perfect Storm: Social Media Meets Mobile-First
The advent of social media platforms, combined with the rise of smartphones, created the perfect environment for bite-sized content. The algorithms that dictate what content you're exposed to on apps like Twitter, Instagram, and TikTok measure the success of any piece of content by its engagement level. To achieve success, creators must put out a large volume of content that drives engagement, as opposed to spending significant time on more meaningful content. The latter strategy simply won't help you break through.
## The Evolution of Storytelling in the Social Media Age
### Classic vs. Modern Storytelling
The traditionally accepted structure of an effective story or narrative generally begins with rising action, followed by an inciting event, all building towards a climax, which is where the audience is at peak engagement. For this type of storytelling to pay off, it must spend time drawing the viewer in.
In 2024, however, the structure for telling a successful story begins with the climax. The most viral content often starts by thrusting the viewer right into the middle of the most tense moments. Given the short timeframes, it's easy to see why the satisfaction wears off quickly and results in the dreaded doom scrolling.
### New Formats, New Stories
Before social media, storytelling online began evolving with platforms like microblogs, forums, and instant messaging services. These formats laid the foundation for short-form by serializing storytelling in a way that allows the narrative to unfold over separate bits of content, again driving users to just keep on scrolling.
## The Negative Effects of Short-Form Content
### Cognitive Overload and Overstimulation
There are other serious downsides to the new world of short-form content that may quite possibly have unintended effects on the younger generations, as they are quite literally the guinea pigs, being exposed to almost exclusively short-form content with fewer and fewer alternatives.
The relentless stream of short, engaging content can lead to cognitive overload. Users are bombarded with information, making it more difficult to focus or deeply engage with any single piece. This overstimulation often results in a superficial understanding of topics and a diminished interest in more in-depth and nuanced content.
### The Compromise of Intellectual Depth
Studies have raised concerns about how short-form content, particularly when consumed extensively by younger audiences, might impact cognitive development and attention spans. The format's emphasis on immediate feedback and satisfaction can oversimplify complex topics, leading viewers into the false belief that they have a much better understanding of an issue than they actually do.
### Short-Form Content is Here to Stay
Short-form content has irreversibly changed the landscape of marketing and storytelling. Its rise reflects a broader shift in how we consume information and what we expect from our digital experiences. By understanding its evolution, embracing its potential, and being mindful of its pitfalls, we can use short-form content to tell stories that are not only engaging and relevant but also meaningful and impactful. In the ever-evolving world of content, adaptability, creativity, and a commitment to quality will be key to captivating and maintaining the attention of modern audiences.
+33
View File
@@ -0,0 +1,33 @@
# The Amazing Benefits of Typing Very Fast
About a year ago I decided I wanted to become a faster typer as I spend most of my day on a computer and thought it might have an impact on how fast I could work. I decided to practice on Monkeytype.com every day for at least 10 mins. I started out around 80 WPM and about 2 weeks in I was already over 100. A year in and I've hit 150. The impact on my daily workflows has been incredible. I'm about at the speed where I can type at the speed of my thinking, which is great for writing copy.
### Getting Started
To start, I used Monkeytype.com and decided to commit at least 5 to 10 minutes every day to practice. I started at around 80 WPM, which was decent, but I really didn't expect to improve as quickly as I did.
Within 2 to 3 weeks of daily practice, my speed jumped to over 100 WPM. This rapid improvement served as a great morale boost, prompting me to stick with my practice regime. A little under a year later, I was able to clock a 150 WPM!
### The Impact on Daily Workflows
The impact of my newfound typing speed on my daily workflows was phenomenal. As someone who works in front of a computer all-day everyday, the ability to type at the same speed as my thinking proved to be a game-changer, particularly when it came to writing copy. It was amazing how quickly I could get my ideas on the computer screen.
I discovered that the benefits of being a fast typer extend beyond simple time-saving. It's akin to unlocking a new level of proficiency on your computer, and the advantages are more numerous than you might expect.
### Why You Should Consider Typing Faster
If you're at all like me, it will quickly become like a sport where you're constantly trying to improve your previous WPM. The beauty of this skill is that the barriers to entry are virtually non-existent. If you can type, you can improve. All it requires is a little dedication, with just 5-10 minutes of practice per day.
### Taking Workflows to the Next Level by Typing Faster
For Mac users that leverage a spotlight replacement app like Alfred or Raycast, typing faster can revolutionize your daily workflows. I use Raycast personally, and the simple act of pressing cmd+space and quickly typing the app I need to open or file I need to search become nearly instantaneous. Whether it's opening apps or performing functions, everything seems to be accomplished at warp speed.
Even if you only use the basic spotlight feature on your Mac, you'll certainly see a substantial increase in your speed of navigation. Suddenly, finding what you're looking for becomes a swift, almost instantaneous process.
### Conclusion
As we further immerse ourselves in our digital era, being a fast typer is no longer just a party trick. It's a practical skill, one that can help you navigate your digital world more efficiently and productively.
My journey towards becoming a faster typer proved to be one of the most impactful things I've done to get more done. It took a tool I use every day — my keyboard — and turned it into a vehicle for productivity and efficiency.
If you find yourself tethered to a keyboard for a significant part of your day, consider investing a few minutes each day towards improving your typing speed. You will certainly be surprised by the boost in productivity, how much quicker you can communicate and collaborate with your team. So, how about giving it a shot and seeing where it takes you?
+55
View File
@@ -0,0 +1,55 @@
# How to Write More Effective AI Prompts
## The Art of Prompt Engineering for ChatGPT
In an AI-driven era, mastering communication with tools like ChatGPT is crucial. This guide explores writing effective prompts for ChatGPT, unlocking its full potential. Whether you're a tech enthusiast, content creator, or business professional, these tips enhance your AI interaction.
### The GIGO Principle: Quality In, Quality Out
The axiom "Garbage In, Garbage Out" (GIGO) holds true in the world of AI. The quality of the output you receive from ChatGPT directly correlates with the quality of the prompts you provide. Inadequate prompts can lead to misleading or irrelevant answers, while well-crafted ones can produce insightful and accurate responses.
### Crafting Prompts that Spark Excellence
The skill of writing effective prompts is now so crucial that it has spawned a new discipline: prompt engineering. This involves meticulously designing prompts that guide ChatGPT's large language model (LLM) to generate the best possible answers.
### Conversational AI: Talk to ChatGPT Like a Person
Interacting with ChatGPT should mimic a conversation with a colleague. This approach helps in setting the stage, providing context, and maintaining the AI's focus on the topic.
### Context Is Key
Providing ChatGPT with clear context is vital. It narrows down the AI's focus to your specific subject, leading to more accurate and useful responses. Contextualized prompts require more details but offer more refined outputs.
### Assuming Identities and Professions
One of ChatGPT's fascinating features is its ability to adopt different personas or professional perspectives. This ability can be harnessed to gain diverse viewpoints on a topic.
### Maintaining Relevance and Accuracy
While ChatGPT is an advanced AI, it can sometimes veer off-topic or produce fabricated answers. To mitigate this, ask the AI to justify its responses and guide it gently back on track. Remember to prompt it for source citations where necessary.
## Advanced Prompt-Writing Techniques
### Fine-Tuning Your Prompts
Minor adjustments to your prompts can lead to significantly different responses from ChatGPT. Remember, the AI retains its awareness of previous conversations as long as the session is ongoing.
### Breaking Down Responses
Be mindful that responses over 500 words can sometimes lose coherence. Keep your prompts concise and to the point for the best results.
### Evolving Your Questions
If ChatGPT seems hesitant to answer a question, rephrasing it might yield better results. Utilize personas to elicit responses that might not be forthcoming otherwise.
### Seeking Justification and Sources
When looking for well-supported answers, instruct ChatGPT to justify its responses or provide sources. This practice ensures a higher degree of accuracy and reliability in the information provided.
### Embrace Experimentation
Experimentation is key in mastering prompt writing. The more you test different approaches, the better you'll understand how to steer ChatGPT towards desired outcomes.
### Conclusion: The Journey to AI Mastery
Mastering ChatGPT prompts is a journey of continuous learning and adaptation. By understanding the intricacies of prompt engineering and staying updated with the latest advancements, you can transform your interaction with AI from a mere task to an enriching experience. Embrace these tips, keep experimenting, and watch as ChatGPT becomes an invaluable asset in your digital toolkit.
@@ -0,0 +1,43 @@
# AI is Revolutionizing Entry Level Sales & Marketing
## AI is Revolutionizing Entry Level Sales & Marketing
### A Revolution in Sales & Marketing
Artificial intelligence (AI) has begun to reshape the landscape of entry level sales and marketing jobs in unprecedented ways. As AI technology advances, it has become a driving force behind increased efficiency, personalization, and overall improvements in the sales and marketing industries. This shift is redefining the roles of sales and marketing professionals, and causing companies to rethink their strategies for hiring and training.
### Automation and Efficiency: Streamlining the Sales Process
One of the most significant impacts of AI on entry level sales jobs is the increased level of automation. AI-powered tools and software can now handle repetitive and mundane tasks, allowing sales professionals to focus on more high-value activities. This shift has led to increased efficiency in the sales process and has paved the way for more strategic and targeted approaches to reaching potential customers.
Examples of AI automation in sales include lead scoring, email automation, and CRM systems that can track and analyze customer interactions. By using AI to automate these tasks, entry level sales professionals can focus on building relationships and closing deals, ultimately driving more revenue for their organizations.
### Personalization: Tailoring Marketing Efforts to Individual Customers
In the world of marketing, AI has played a crucial role in enabling personalization at scale. By analyzing vast amounts of data and identifying patterns, AI-powered tools can create tailored marketing campaigns that resonate with individual customers. This level of personalization has become essential in today's competitive landscape, where consumers expect personalized experiences from the brands they engage with.
For entry level marketing professionals, this means a shift away from one-size-fits-all marketing strategies. Instead, they must learn to use AI-driven tools to create targeted campaigns that speak to the unique needs and preferences of their audience. This approach not only helps companies build stronger relationships with their customers but also drives higher conversion rates and increased customer loyalty.
### Predictive Analytics: Guiding Decision-Making in Sales & Marketing
Another way AI is transforming entry level sales and marketing jobs is through the use of predictive analytics. AI algorithms can analyze historical data to identify trends and make predictions about future outcomes, allowing sales and marketing professionals to make data-driven decisions.
For example, AI-powered sales forecasting tools can help sales reps prioritize leads and focus on the most promising opportunities. In marketing, predictive analytics can be used to optimize ad spending, segment customers, and identify the most effective channels for reaching specific audiences. By leveraging AI in this way, entry level professionals can become more strategic in their approach and drive better results for their organizations.
### Chatbots and Conversational AI: Enhancing Customer Engagement
Chatbots and conversational AI have become increasingly popular in both sales and marketing as a way to engage with customers and prospects. These AI-driven tools can handle routine customer inquiries, provide personalized product recommendations, and even assist with lead qualification.
For entry level sales and marketing professionals, the rise of chatbots and conversational AI means a shift in focus. Rather than handling all customer interactions themselves, they must learn to work alongside these AI-powered tools to provide a seamless and cohesive customer experience.
### Upskilling and Reskilling: Preparing for the Future of Sales & Marketing
As AI continues to reshape entry level sales and marketing jobs, professionals in these fields must adapt their skill sets to remain competitive. This includes learning how to use AI-driven tools and software, as well as developing a deeper understanding of data analytics and customer behavior.
Companies and educational institutions are recognizing this need and are offering training programs and resources to help sales and marketing professionals upskill and reskill. By investing in their own professional development, entry level sales ensure they remain relevant and valuable in the ever-evolving world of AI-driven sales and marketing.
### The Future of Entry Level Sales & Marketing Jobs in the Age of AI
As AI continues to transform the sales and marketing landscape, the roles and responsibilities of entry level professionals in these fields will continue to evolve. While some tasks may become automated, there will be a growing demand for skilled professionals who can harness the power of AI to drive more effective and personalized sales and marketing strategies.
To succeed in this new era, entry level sales and marketing professionals must embrace AI as a valuable tool that can enhance their work and help them achieve better results. By staying ahead of the latest AI trends and developments, and continuously adapting their skills and knowledge, they can position themselves for long-term success in the rapidly changing world of sales and marketing.
+49
View File
@@ -0,0 +1,49 @@
# Why AI Art & Media Is Useless
## Why AI Art & Media Is Useless
As someone who works in AI and genuinely believes in the value and power of LLMs to make professionals more useful and valuable, I can confidently say that I hate everything about AI image/video/music generation. It is useless and only serves one purpose: to replace creative professionals and the work they do.
### The Scope of the Problem
To be a bit more precise about my hatred for AI media, I need to be clear that I don't hate AI. I've spent the last three-plus years devoting my entire professional life to leveraging AI tools to help professionals do their jobs more effectively. That said, from the moment I was exposed to AI art, I had the same initial reaction as most: a flood of anxiety and uncanniness, which I knew instantly I didn't like.
Leveraging an LLM to automate task creation from new emails I receive simply replaces something I spend 30 minutes doing every morning and allows it to occur in the background, producing the same output I would have arrived at. That's just helpful.
I am not an artist, but if I decided to start using AI to create all the graphics for a client, I wouldn't be improving anything that I currently do. I would just be replacing a potential job for someone who does art professionally.
The fundamental difference here is when a professional uses AI to improve the efficiency or quality of something they already do, it functions as a tool. When someone with no art experience uses AI to create art, it's not improving anything. It's simply replacing something that already exists with something worse.
### The "Democratization" Lie
Access to the ability to create art is not the same as having the ability to create art. The moment everyone began conflating the ability to produce an output with the creator itself, they've already swallowed the Kool-Aid. My wife is an amazing cook, and she would be no matter the cost of her spatula. However, if I purchased the greatest spatula in history, I would still be a crappy cook.
Now let's talk about vibe coding, which is fundamentally different from image generation. I have learned more about writing code and development in the past year by using AI than I ever have. This is because things frequently do not work and therefore I have to go learn new information.
The key difference here is that vibe coding allows me to leverage my current knowledge as well as gain new knowledge, whereas generating an image simply produces an output that I have no ability to improve on. The reason I can't improve it is because simply going and looking up a bunch of information on how to create art will not make me a better artist.
### The Collapse of Quality
Another important factor to understand here is that AI art isn't producing the worst work or the best work. It's producing the *median* of everything it has been trained on (actual artists' work). This is incredibly dangerous. It's essentially producing a blob of an over-generalized consensus on what looks "good." That doesn't work when you amalgamate every style and genre of art in order to produce something. This is not creative. This is aggregative.
Another problem here isn't that AI can't make art. Everything it makes is, by design, is just good enough. Therefore, this hits dead center in the sweet spot for what massive corporations are looking for. Why pay a junior designer to iterate on multiple concepts when an AI can generate you 200 versions of something that are all "good enough"?
### Creativity Is Disappearing
Creating art obviously requires creativity, however, using AI tools simply requires knowledge. These are two very different things. Creativity isn't an output, it's an artist's struggle through years as they hone their craft and improve their abilities. It's thousands of micro-decisions that aren't just learned, but practiced over many years.
This matters because creative work embodies meaning and emotion that come from the artist. When AI generates an image, it remixes thousands of tokens to approximate what the user requested. Crafting an advanced prompt is a legitimate skill, prompt engineering, but it's not the same as creating art. These skills should never be conflated.
### What Should We Do?
First, we need laws to stop major AI labs—particularly OpenAI and Google AI—from collecting human-made art as training data for their image generation models. We need strong regulation requiring artists to **opt in** before their work can be collected and trained on.
Second, we need more AI leaders to step up and stop this before it's too late. For example, Anthropic (makers of Claude) has never released an image generation model. That doesn't mean Claude can't be used to create websites or other graphic design work, but creating a UI or navigation menu is entirely different from painting on canvas.
### AI Art Hurts the Future Potential of AI
It's clear that most people don't find AI art pleasing—they actively dislike it. With every piece of AI art slop that lands on Twitter or Instagram, the long-term reputation of AI as a useful tool for professionals takes another hit.
For years now, public sentiment toward AI has been declining. There's one culprit: AI-generated art and media. People who aren't knowledgeable about AI don't distinguish between media generation and other use cases that are actually valuable. This reduces the chances they'll ever consider the benefits of AI as a tool.
It's my sincere hope that we stop this race to the bottom before we get there. We should take all the resources and effort put toward AI media generation and redirect them toward leveraging AI as a tool for medical breakthroughs, building technology, and conducting research more efficiently.
+2
View File
@@ -0,0 +1,2 @@
# Blog-writer
Blog writing skill for Tom Panos's distinctive voice - direct, conversational, and grounded in personal experience. Handles workflow from research through Notion publication.
+158
View File
@@ -0,0 +1,158 @@
---
name: blog-writer
description: This skill should be used when writing blog posts, articles, or long-form content in the writer's distinctive writing style. It produces authentic, opinionated content that matches the writer's voice—direct, conversational, and grounded in personal experience. The skill handles the complete workflow from research review through Notion publication. Use this skill for drafting blog posts, thought leadership pieces, or any writing meant to reflect the writer's perspective on AI, productivity, sales, marketing, or technology topics.
---
# Blog Writer
## Overview
This skill enables writing blog posts and articles that authentically capture the writer's distinctive voice and style. It draws on examples of the writer's published work to produce content that is direct, opinionated, conversational, and grounded in practical experience. The skill includes automatic Notion integration and maintains a growing library of finalized examples.
## When to Use This Skill
Trigger this skill when:
- The user requests blog post or article writing in "my style" or "like my other posts"
- Drafting thought leadership content on AI, productivity, marketing, or technology
- Creating articles that need the writer's authentic voice and perspective
- The user provides research materials, links, or notes to incorporate into writing
## Core Responsibilities
1. **Follow the writer's Writing Style**: Match voice, word choice, structure, and length of example posts in `references/blog-examples/`
2. **Incorporate Research**: Review and integrate any information, research material, or links provided by the user
3. **Follow User Instructions**: Adhere closely to the user's specific requests for topic, angle, and emphasis
4. **Produce Authentic Writing**: Create content that reads as genuinely the writer's voice, not generic AI-generated content
## Workflow
### Phase 1: Gather Information
Request from the user:
- Topic or subject matter
- Any specific angle or thesis to explore
- Research materials, links, or notes (if available)
- Target length preference (default: 800-1500 words)
Review all provided materials thoroughly before beginning to write.
### Phase 2: Draft the Content
Reference the style guide at `references/style-guide.md` and examples in `references/blog-examples/` for calibration.
When writing:
1. Start with a strong opening statement establishing the thesis
2. Use personal voice and first-person perspective where natural
3. Include relevant personal anecdotes or professional experience if applicable
4. Structure with clear subheadings (###) every 2-3 paragraphs
5. Keep paragraphs short (2-4 sentences)
6. Weave in research materials naturally, not as block quotes
7. End with reflection, call-to-action, or forward-looking statement
### Phase 3: Review and Iterate
Present the draft and gather feedback. Iterate until the user confirms satisfaction.
### Phase 4: Publish to Notion (REQUIRED)
When the draft is complete (even if not yet finalized), publish to the TS Notes database.
**Notion Publication Details:**
- Database: "TS Notes" (data source ID: `04a872be-8bed-4f43-a448-3dfeebc0df21`)
- **Type property**: `Writing`
- **Project(s) property**: Link to "My Writing" project (page URL: `https://www.notion.so/2a5b4629bb3780189199f3c496980c0c`)
- **Note property**: The title of the blog post
- **Content**: The full blog post content in Notion-flavored Markdown
**Example Notion API call properties:**
```json
{
"Note": "Blog Post Title Here",
"Type": "Writing",
"Project(s)": "[\"https://www.notion.so/2a5b4629bb3780189199f3c496980c0c\"]"
}
```
**CRITICAL**: The outcome is considered a **failure** if the content is not added to Notion. Always publish to Notion as part of the workflow, even for drafts.
### Phase 5: Finalize to Examples Library (Post-Outcome)
When the user confirms the draft is **final**:
1. Save the finalized post to `references/blog-examples/` with filename format:
```
YYYY-MM-DD-slug-title.md
```
Example: `2025-11-25-why-ai-art-is-useless.md`
2. Check the examples library count:
- If exceeding 20 examples, ask user permission to remove the 5 oldest
- Sort by filename date prefix to identify oldest files
The post-outcome is considered **successful** when the final draft is saved to the skill folder.
## Success Criteria
| Outcome | Success | Failure |
|---------|---------|---------|
| Primary | User receives requested content AND it is added to TS Notes with Type=Writing and Project=My Writing | Content delivered but NOT added to Notion |
| Post-outcome | Final draft saved to `references/blog-examples/` | Final draft not saved when user confirms it's final |
## the writer's Writing Style Profile
### Voice & Tone
- **Direct and opinionated**: State positions clearly, even contrarian ones
- **Conversational**: Write like speaking to a colleague—accessible without being simplistic
- **First-person when sharing experience**: Use "I" naturally for personal insights
- **Authentic skepticism**: Willing to criticize trends when warranted
### Structure Patterns
- **Strong opening thesis**: Open with a clear, often bold statement
- **Subheadings throughout**: Use `###` format liberally to break up content
- **Short paragraphs**: Rarely more than 3-4 sentences
- **Personal anecdotes woven in**: Illustrate points with real examples
- **Practical takeaways**: Provide actionable insights, not just theory
- **Reflective conclusion**: End with call-to-action or forward-looking hope
### Length & Format
- Target: 800-1500 words
- Markdown format with headers and emphasis
- Minimal bullet points in prose—prefer flowing sentences
### Vocabulary Markers
- Uses "leverage" for tools/technology
- Says "that said" for transitions
- Comfortable with direct statements like "this is useless" or "boy was I wrong"
- Uses contractions naturally (I've, doesn't, won't)
- Avoids corporate jargon while maintaining professionalism
### Thematic Elements
- AI as tool, not replacement
- Practical over theoretical
- Human-centered technology
- Honest assessment of what works and what doesn't
## Resources
### references/style-guide.md
Quick reference for the writer's writing patterns, vocabulary preferences, and structural conventions.
### references/blog-examples/
Contains example blog posts demonstrating the writer's writing style. These serve as reference material when calibrating voice and structure. New finalized posts expand this library over time.
## Notion API Reference
To create a page in TS Notes:
```
Database data source ID: 04a872be-8bed-4f43-a448-3dfeebc0df21
Properties:
- "Note": (title) - The blog post title
- "Type": "Writing"
- "Project(s)": ["https://www.notion.so/2a5b4629bb3780189199f3c496980c0c"]
Content: Full blog post in Notion-flavored Markdown
```
The "My Writing" project page ID is: `2a5b4629-bb37-8018-9199-f3c496980c0c`
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn722nva0z7svbapne80p8e8jd7zwmk7",
"slug": "blog-writer",
"version": "0.1.0",
"publishedAt": 1769361436760
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Utility script for managing the blog examples library.
Helps identify old examples to prune when the library exceeds the limit.
"""
import os
import sys
from datetime import datetime
from pathlib import Path
EXAMPLES_DIR = Path(__file__).parent.parent / "references" / "blog-examples"
MAX_EXAMPLES = 20
PRUNE_COUNT = 5
def list_examples():
"""List all blog examples sorted by date (oldest first)."""
examples = []
for f in EXAMPLES_DIR.glob("*.md"):
# Extract date from filename (YYYY-MM-DD-slug.md)
try:
date_str = f.stem[:10]
date = datetime.strptime(date_str, "%Y-%m-%d")
examples.append((date, f.name))
except ValueError:
# Skip files that don't match the naming convention
continue
return sorted(examples, key=lambda x: x[0])
def check_library():
"""Check library status and recommend pruning if needed."""
examples = list_examples()
count = len(examples)
print(f"Blog Examples Library Status")
print(f"=" * 40)
print(f"Total examples: {count}")
print(f"Maximum allowed: {MAX_EXAMPLES}")
print()
if count > MAX_EXAMPLES:
print(f"⚠️ Library exceeds limit by {count - MAX_EXAMPLES} files")
print(f"Recommend removing the {PRUNE_COUNT} oldest examples:")
print()
for i, (date, name) in enumerate(examples[:PRUNE_COUNT]):
print(f" {i+1}. {name} ({date.strftime('%B %d, %Y')})")
else:
print(f"✓ Library is within limits ({MAX_EXAMPLES - count} slots available)")
print()
print("All examples (oldest first):")
print("-" * 40)
for date, name in examples:
print(f" {name}")
def prune_oldest(dry_run=True):
"""Remove the oldest examples to bring library under limit."""
examples = list_examples()
count = len(examples)
if count <= MAX_EXAMPLES:
print("Library is within limits. No pruning needed.")
return
to_remove = examples[:PRUNE_COUNT]
if dry_run:
print(f"DRY RUN - Would remove {len(to_remove)} files:")
else:
print(f"Removing {len(to_remove)} oldest files:")
for date, name in to_remove:
filepath = EXAMPLES_DIR / name
if dry_run:
print(f" Would remove: {name}")
else:
filepath.unlink()
print(f" Removed: {name}")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "prune":
dry_run = "--execute" not in sys.argv
prune_oldest(dry_run=dry_run)
else:
check_library()
+160
View File
@@ -0,0 +1,160 @@
# Tom Panos Writing Style Guide
## Quick Reference
### Opening Lines
Start with a strong thesis or personal statement. Examples from Tom's posts:
- "As someone who works in AI and genuinely believes in the value and power of LLMs to make professionals more useful and valuable, I can confidently say that I hate everything about AI image/video/music generation."
- "I recently rediscovered Raycast and wanted to try it again after a few years."
- "About a year ago I decided I wanted to become a faster typer..."
- "Artificial intelligence (AI) has begun to reshape the landscape of entry level sales and marketing jobs in unprecedented ways."
- "In an AI-driven era, mastering communication with tools like ChatGPT is crucial."
- "Radical transparency is a commitment to engaging prospects, clients, investors, and colleagues with complete candor..."
- "Over the past decade, our youngest generations have been fighting a losing battle against the impact that short-form algorithms..."
### Transition Phrases
- "That said..."
- "The fundamental difference here is..."
- "Another important factor to understand here is..."
- "This matters because..."
- "For example..."
- "The real kicker is..."
- "To be a bit more precise..."
- "Now let's talk about..."
- "The key difference here is..."
### Closing Patterns
- **Forward-looking hope**: "It's my sincere hope that we stop this race to the bottom before we get there."
- **Call to action**: "So, how about giving it a shot and seeing where it takes you?"
- **Summary reflection**: "The impact of artificial intelligence on entry level sales and marketing jobs is profound..."
- **Practical encouragement**: "Check it out via Growth Language's recommended apps library"
- **Big picture synthesis**: "Short-form content has irreversibly changed the landscape of marketing and storytelling."
### Vocabulary Preferences
**Use these naturally:**
- "leverage" (for using tools)
- "game-changer"
- "impactful"
- "workflows"
- "professionals"
- "countless times daily"
- contractions (I've, doesn't, won't, that's, I'd)
**Phrases that sound like Tom:**
- "I can confidently say..."
- "Boy was I wrong!"
- "I decided to..."
- "I've spent the last..."
- "My [wife/experience/journey]..."
- "It's still hard to believe, but..."
- "This is incredibly dangerous."
- "This just doesn't work when..."
**Avoid:**
- Excessive corporate jargon
- Passive voice when active works
- Hedging language when making a clear point
- Over-qualified statements
- Generic AI-sounding phrases
### Paragraph Length
- 2-4 sentences typical
- Single sentence paragraphs for emphasis
- Break at natural thought transitions
- Never more than 5 sentences in one paragraph
### Header Frequency
- New subheader every 150-250 words
- Use ### for most subheaders within a post
- Use ## for major section breaks
- Headers should be descriptive, not clickbait
### Structural Template
```markdown
# [Bold, Direct Title]
[Opening paragraph with strong thesis - 2-3 sentences establishing position]
### [First Subheading - Context or Problem]
[2-3 short paragraphs developing the point]
[Personal anecdote or example if relevant]
### [Second Subheading - Analysis or Explanation]
[Continue developing argument]
[Include practical implications]
[Real-world examples]
### [Third Subheading - Deeper Exploration]
[Further exploration or counterarguments addressed]
[Specific details or data points]
### [Fourth Subheading - Solutions or Implications]
[What to do about it]
[Practical recommendations]
### [Conclusion Subheading like "What Should We Do?" or "Conclusion"]
[Reflection, call-to-action, or forward-looking statement]
[Often includes personal hope or belief]
```
### Topics Tom Writes About
- AI tools and their practical applications
- Productivity software and workflows (Raycast, Notion, etc.)
- Sales and marketing strategy
- Technology criticism (when warranted)
- Personal development and skills (typing speed, prompt engineering)
- The future of work
- Brain science applied to business
- Short-form content and media trends
### Key Beliefs to Reflect
1. **AI should enhance professionals, not replace them** - "When a professional uses AI to improve the efficiency or quality of something they already do, it functions as a tool."
2. **Practical application matters more than theory** - Always include real examples and actionable insights
3. **Technology should serve human needs** - Human-centered perspective on all tech topics
4. **Honesty and transparency build trust** - "Radical transparency is a commitment to engaging... with complete candor"
5. **Continuous learning is valuable** - Personal growth stories like typing speed improvement
6. **Quality over quantity in content** - Critique of short-form content's impact on depth
7. **Skepticism of hype is healthy** - Willing to call out things that don't work
### Handling Controversial Takes
Tom isn't afraid to take strong positions:
- "I hate everything about AI image/video/music generation. It is useless."
- "AI art isn't producing the worst work or the best work. It's producing the *median*."
- Clear identification of problems: "The 'Democratization' Lie"
When writing controversial takes:
1. Establish credibility first ("As someone who works in AI...")
2. Be precise about the scope of criticism
3. Acknowledge what DOES work
4. Provide concrete reasoning, not just opinion
5. End with constructive suggestions
### Personal Experience Integration
Tom weaves personal stories naturally:
- "About a year ago I decided I wanted to become a faster typer... I started at around 80 WPM... A year in and I've hit 150."
- "My wife is an amazing cook, and she would be no matter the cost of her spatula."
- "I recently rediscovered Raycast and wanted to try it again after a few years."
When including personal experience:
1. Keep it relevant to the main point
2. Include specific details (numbers, timeframes)
3. Connect back to broader implications
4. Don't overdo it—one or two per post is enough
### Formatting Notes
- Use `*italics*` for emphasis on key terms
- Use `**bold**` sparingly, mainly for key takeaways
- Lists only when actually listing items (not for general prose)
- Include images/screenshots where they add value
- End with "More posts like this" section linking to related content
+13
View File
@@ -0,0 +1,13 @@
Copyright (c) 2026 Z.ai All rights reserved.
Permission is granted for personal, educational, and non-commercial use only.
Commercial use is strictly prohibited without prior written permission from the author.
Unauthorized copying, modification, or distribution of the software for commercial purposes is prohibited.
The author reserves the right to make the final determination of what constitutes "commercial use".
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
+427
View File
@@ -0,0 +1,427 @@
---
name: charts
metadata:
author: Z.AI
version: "1.0"
description: >
Professional chart and diagram creation skill. Covers all types of visual data
representation and structural diagrams:
- **Data charts**: bar charts, line charts, pie charts, scatter plots, heatmaps,
radar charts, candlestick charts, boxplots, histograms, area charts, waterfall charts,
regression plots, distribution plots, and statistical visualizations.
- **Structural diagrams**: flowcharts, mind maps, tree diagrams, org charts,
architecture diagrams, network/relationship graphs, ER diagrams, class diagrams,
Gantt charts, swimlane diagrams, and sequence diagrams.
- **Dashboards**: data dashboards, KPI panels, multi-chart compositions,
and interactive visualizations.
- **Design quality**: professional color systems, anti-overlap rules, layout optimization,
scene-based framework routing (matplotlib, seaborn, ECharts, D3.js, Mermaid, Playwright+CSS),
and publication-ready output.
Applies when the user wants to create, generate, draw, plot, visualize, or improve
any chart, graph, diagram, or dashboard. Also applies when the user asks for something
more polished, cleaner, or publication-ready.
NOT for: PDF document layout (use pdf skill), slide decks (use slides skill),
spreadsheets with embedded charts (use xlsx skill), AI image generation (use image_gen),
posters / infographics / creative cards (use pdf skill Creative pipeline).
FORBIDDEN: Using matplotlib/seaborn to draw mind maps, tree diagrams, org charts,
flowcharts, or any structural diagram. These MUST use Playwright+CSS.
license: Proprietary. LICENSE.txt has complete terms
---
# Beautiful Charts
## Quick Setup
```bash
bash "$SKILL_DIR/setup.sh" # Interactive environment check + install
```
Make every chart and diagram look professionally designed, not AI-generated.
## Architecture
| Module | File | When to Load |
|--------|------|-------------|
| **Routing + Core Rules** | This file | Always read first |
| **Framework Templates** | `references/` by framework | After choosing framework, read the corresponding file |
**Loading order: Read this file → choose framework → read template file → start coding.**
Each template file contains its own framework-specific rules (spacing, connectors, color details). This file contains only routing decisions and universal rules that apply to ALL charts.
---
# Part 1: Routing
## ⚠️ Format Constraint Rule (HIGHEST PRIORITY)
**When the user specifies an output format/tool, you MUST comply. Never substitute.**
| User Says | You Must Do | Forbidden |
|-----------|------------|-----------|
| "use mermaid code" / "用Mermaid格式输出" / "转化为mermaid" / "mermaid流程" | ① Output Mermaid code block (```mermaid ... ```) ② Also provide a rendered image preview | ❌ Cannot only give image without code; ❌ Cannot screenshot raw code text as image |
| "use markdown code" | Output markdown-formatted hierarchy | ❌ Cannot switch to HTML/CSS |
| "via mermaid or markdown code" | Choose one of the two, output code text | ❌ Cannot switch to any non-specified format |
| "flowchart" / "mind map" (no format specified) | Free to choose the best approach | - |
| "use echarts/d3" | Must use the specified framework | ❌ Cannot switch |
### 🚫 FORBIDDEN: Mermaid Code Screenshot
**NEVER take a screenshot of raw Mermaid source code and deliver it as the "diagram image".** This is the worst possible outcome — the user gets neither usable code nor a visual diagram. When the user requests Mermaid format:
1. **MUST** output the Mermaid code in a fenced code block (````mermaid`)
2. **SHOULD** also render the code into a visual diagram image (via mermaid-cli or Playwright + mermaid.js)
3. If rendering fails, deliver the code block and tell the user to paste it into mermaid.live
### Format Specified vs Auto-Upgrade Conflict
When the user specifies Mermaid but content triggers auto-upgrade conditions (>8 nodes, CJK-heavy, etc.):
1. **User choice wins** — still use Mermaid, deliver code block + rendered image
2. **Proactively guide** — after delivery, suggest the user try without specifying Mermaid for better layout quality
3. **Never silently switch** to Playwright+CSS when user explicitly asked for Mermaid
When a specified tool hits rendering difficulties (e.g., mermaid CDN fails):
- ✅ Output raw mermaid code text, tell user to view at mermaid.live
- ❌ Secretly switch to another framework
- ❌ Screenshot the code text as an "image"
---
## Routing Decision Tree
### 1. Structural Diagrams
#### 🔴 Flowchart Default: Phased Vertical (HIGHEST PRIORITY)
**When the user asks to "generate/create a XXX flowchart/流程图" without specifying format, the DEFAULT layout is Phased Vertical (Layout C in `references/playwright-css.md`).**
This is because nearly all real-world processes (manufacturing, legal proceedings, project management, business operations, cooking recipes, etc.) have natural phases/stages. Layout C produces the most professional, readable result.
**Flowchart routing priority:**
1. **User specified Mermaid/markdown** → follow user choice (Format Constraint Rule)
2. **≤6 nodes AND no phases AND short text** → Mermaid (simple flowchart)
3. **Everything else** → **Playwright + CSS, Layout C (Phased Vertical)** → `references/playwright-css.md`
**Phase detection — treat as "has phases" when ANY is true:**
- Content has numbered sections (一、二、三 or 1. 2. 3. or Phase 1/Stage 1)
- Process can be grouped by time/stage/role (e.g., "preparation → execution → review")
- Total steps ≥ 5 (almost always groupable into 2+ phases)
- Process involves multiple roles/departments
- Process has clear start/end with intermediate stages
**⚠️ When in doubt, default to Layout C.** A phased layout with only 1 phase still looks professional. A Grid layout with phases looks like a mess.
#### Other Structural Diagrams
- Simple flowchart (≤6 nodes, truly flat, no phases): **Mermaid**
- Complex flowchart (>6 nodes / CJK-heavy / branches / phases): **Playwright + CSS Layout C** → `references/playwright-css.md`
- Mind map / tree / org chart: **Playwright + CSS** → `references/mindmap-css.md`
- Relationship / network diagram: **ECharts graph**
- Center-radial analysis (SWOT / BSC / Porter's Five Forces / PEST): **Playwright + CSS** → `references/radial-grid.md`
### 2. Data Charts (matplotlib / seaborn)
- Standard bar/line/scatter/heatmap/radar/pie: **matplotlib**
- Regression/distribution/boxplot: **Seaborn**
### 3. Interactive Charts / Dashboards
- Data dashboard / candlestick / real-time: **ECharts**
- Fully custom interactive: **D3.js**
### Default Strategy
**One scene, one tool — don't hesitate:**
| Scene | Tool | Template |
|-------|------|----------|
| Data chart (bar/line/scatter/pie/radar) | matplotlib | `references/matplotlib.md` |
| Statistical (regression/box/dist) | Seaborn | `references/seaborn.md` |
| Mind map / tree / org chart | Playwright + CSS | `references/mindmap-css.md` |
| Center-radial (SWOT/BSC/PEST/Five Forces) | Playwright + CSS | `references/radial-grid.md` |
| **Any flowchart (default)** | **Playwright + CSS Layout C** | **`references/playwright-css.md`** |
| Simple flowchart (≤6 nodes, truly flat) | Mermaid | `references/mermaid.md` |
| Relationship / force-directed | ECharts graph | `references/echarts.md` |
| Data dashboard | ECharts | `references/echarts.md` |
| Academic paper figures | matplotlib | `references/matplotlib.md` |
---
## Mermaid Auto-Upgrade Rules
Mermaid's dagre/elk layout estimates CJK widths incorrectly. **Auto-switch to Playwright+CSS when ANY condition is met:**
| Trigger | Action |
|---------|--------|
| Total nodes > **6** | → CSS flowchart (Layout C) |
| Any node text > **12 Chinese characters** | → CSS flowchart |
| More than **3 parallel branches** | → CSS flowchart |
| Nested subgraphs > **2 levels** | → CSS flowchart |
| Connector crossings > **2** | → CSS flowchart |
| **Side annotations / dashed note boxes** | → CSS flowchart |
| **Loop-back / cycle arrows** | → CSS flowchart |
| **Process has identifiable phases/stages** | → CSS flowchart (Layout C) |
**If staying with Mermaid**: `padding: 32`, `nodeSpacing: 80`, `rankSpacing: 80`. Node text ≤ 10 CJK chars/line, wrap with `<br>`, quote all text `A["text"]`.
---
## Large Dataset Rendering
| Data Size | Approach |
|-----------|----------|
| < 1,000 points | matplotlib / any |
| 1,000 - 10,000 | matplotlib (no markers) or ECharts |
| 10,000 - 100,000 | ECharts (Canvas mode) |
| > 100,000 | ECharts (`large: true`) or WebGL |
---
# Part 2: Universal Rules
These rules apply to ALL charts regardless of framework. Framework-specific rules live in each template file.
## 7 Core Rules
1. **Zero overlap.** No element may cover another's text. Overlap = information loss = task failure. Post-generation: verify every element has clear separation.
2. **Hierarchy over uniformity.** Primary nodes larger/bolder than secondary. Annotation nodes smaller/muted. Spacing between groups > within groups. If every box looks identical, the layout has failed.
3. **Low-saturation palette.** 70% background/neutral, 20% secondary, 10% accent (one highlight only). No high-saturation large fills. Saturated colors only on borders (2px), text, and small elements.
4. **Insight first.** Titles express conclusions, not field names. Remove non-essential elements: top/right borders, grid lines, tick marks, legend box borders. If removing it doesn't reduce understanding, it shouldn't exist.
5. **Label clarity over label method.** The goal is zero overlap — choose the method that achieves it for each chart type. Direct labels, legends, and leader lines are all valid; what matters is that nothing overlaps.
### 🚫 FORBIDDEN: Any Text Overlapping Any Other Element
**No label, legend, annotation, or title may overlap any other visual element.** This is the single most common matplotlib defect. Both direct labels AND legends can cause overlap — neither is inherently safe.
**Anti-overlap decision tree:**
1. **Check if direct labels fit** — if all labels have enough space (bar tops, line endpoints, large pie slices), label directly. No legend needed.
2. **If some labels would collide** (small pie slices, dense scatter points, clustered bars) → use legend outside plot area instead of forcing labels into tight spaces.
3. **Mixed approach** — label the major items directly, group small items into "其他" or use leader lines + legend for the small ones.
**Pie chart specific (the worst offender):**
- Slices < 5%: MUST use leader lines (`wedgeprops + texts` manual repositioning, or `matplotlib.patches.ConnectionPatch`) to pull labels outside. Do NOT rely on `autopct` alone — it places text inside/near the slice.
- Multiple small adjacent slices: use `bbox_to_anchor` legend outside, NOT direct labels
- `labeldistance=1.25` minimum to keep labels outside the pie
- When >2 slices are < 5%, consider grouping all < 3% into "其他(X项)"
- Use `adjustText` library to auto-resolve label collisions when available
**Legend placement (when legend is needed):**
- Place legend **outside** the plot area using `bbox_to_anchor`
- Suggested starting positions:
- Bar/line/scatter: right side outside (`bbox_to_anchor=(1.02, 1), loc='upper left'`)
- Pie: right side outside (`bbox_to_anchor=(1.1, 0.5), loc='center left'`)
- Radar: below chart (`bbox_to_anchor=(0.5, -0.15), loc='upper center'`)
- Heatmap: no legend needed (colorbar suffices)
**🔧 Mandatory: auto-adjust legend to prevent overlap.** Copy this snippet after placing any legend:
```python
# ── Auto-adjust legend position to prevent overlap ──
fig.canvas.draw() # must render first to get bboxes
legend = ax.get_legend()
if legend:
renderer = fig.canvas.get_renderer()
# Try shifting up to 5 times to resolve overlap
for _ in range(5):
leg_bb = legend.get_window_extent(renderer).transformed(ax.transAxes.inverted())
has_overlap = False
for text in ax.texts + [ax.title] + ax.get_xticklabels() + ax.get_yticklabels():
if not text.get_text():
continue
txt_bb = text.get_window_extent(renderer).transformed(ax.transAxes.inverted())
if leg_bb.overlaps(txt_bb):
has_overlap = True
break
if not has_overlap:
break
# Move legend further outside (direction depends on current loc)
bbox = legend.get_bbox_to_anchor().transformed(ax.transAxes.inverted())
x0, y0 = bbox.x0, bbox.y0
# Heuristic: if legend is below center, move down; if right of center, move right
if y0 < 0.5:
legend.set_bbox_to_anchor((x0, y0 - 0.08), transform=ax.transAxes)
else:
legend.set_bbox_to_anchor((x0 + 0.08, y0), transform=ax.transAxes)
fig.canvas.draw()
```
- **After placing legend**: always call `plt.tight_layout()` or `fig.subplots_adjust()` to ensure legend is not clipped
🚫 FORBIDDEN:
- `loc='best'` — matplotlib's "best" frequently overlaps data
- `loc='upper right'` / `loc='lower right'` on line/bar charts — high collision risk
- Direct labels on pie slices < 5% without leader lines
- Any text placement without verifying zero overlap
6. **Font discipline.** Max 2 fonts. Chinese: SimHei/PingFang SC. Always explicitly set fonts in code. Font size follows hierarchy (title 18-24px → body 13-15px → annotation 11-13px). Never go below 10px floor. When text overflows: condense text → enlarge canvas → last resort: shrink font (but never below floor).
7. **Whitespace is design.** Chart area 60-70% of canvas, margins 15-20%. At least 16pt between title and chart. Crowded ≠ information-rich.
---
## Color System
### Recommended Palettes
| Palette | Text | Background | Block Fill | Accent |
|---------|------|------------|------------|--------|
| Business Cool | `#243447` | `#F8FAFC` | `#E9EEF3` | `#4C6EF5` |
| Tech Cyan-Gray | `#1F2937` | `#F5F7FA` | `#E6ECF2` | `#3AAFA9` |
| Morandi Warm | `#4B4A45` | `#FAF8F4` | `#EAE4DB` | `#C6866A` |
| Invisible Precision | `#37352F` | `#FFFFFF` | `#F7F7F7` | `#2383E2` |
### 🚫 Forbidden Background Colors
| Color | Forbidden Hex Values |
|-------|---------------------|
| Pure blue | `#3B82F6`, `#2563EB`, `#1D4ED8` |
| Pure green | `#10B981`, `#059669`, `#22C55E` |
| Pure red | `#EF4444`, `#DC2626`, `#F87171` |
| Pure purple | `#8B5CF6`, `#7C3AED`, `#A855F7` |
| Pure amber | `#F59E0B`, `#D97706`, `#FB923C` |
### ✅ Allowed Background Colors
| Color | Hex Values |
|-------|------------|
| Ice blue | `#EFF6FF`, `#DBEAFE` |
| Mint green | `#F0FDF4`, `#D1FAE5` |
| Light amber | `#FFF7ED`, `#FEF3C7` |
| Lavender | `#F5F3FF`, `#EDE9FE` |
| Light gray | `#F8FAFC`, `#F1F5F9` |
### Functional Color (states only, not decoration)
- Active/Selected: brand accent or `2px` accent line
- Error: `#EF4444`
- Success: `#10B981`
- Tags: light bg + dark text, never high-sat pills
### Colorblind-Safe
Don't rely on color alone — pair with shape, line style, or direct labels.
Paul Tol palette: `['#0077BB', '#33BBEE', '#009988', '#EE7733', '#CC3311', '#EE3377']`
### Dark Theme
- Background: `#0F172A` (not pure black)
- Text: `#F1F5F9` (not pure white)
- Grid: `#1E293B`, low alpha
- Export: `savefig(facecolor='#0F172A')`
---
## Export Rules
- Static charts: minimum 200 DPI, recommended 300 DPI
- Pie/radar: **square `figsize=(8, 8)`** — non-square = elliptical
- No more than 6 colors per chart (split if more)
- Bar chart Y-axis starts at 0 (line charts may truncate)
- Never use 3D (distorts proportions)
### Playwright Screenshot
Default `device_scale_factor=2`. Large mind maps (3000px+): 1.5. PDF embed: 1-1.5. Print: 3.
After render, read `bounding_box()` and resize viewport to fit. Min viewport: 800px single-col, 1200px multi-col.
### 🚫 FORBIDDEN: `max-width` on Mermaid/SVG Containers
Mermaid's dagre engine produces SVGs with unpredictable width (especially with subgraphs, CJK text, or parallel branches). **NEVER set `max-width` on the Mermaid container element.** Use `width: fit-content; min-width: 800px;` instead.
**Root cause**: Mermaid SVGs overflow their CSS container silently. `bounding_box()` (Playwright) returns the CSS box model size, NOT the SVG's actual rendered size. So auto-resize viewport based on `bounding_box()` alone will still produce clipped screenshots.
**Fix**: Always read the **SVG element's own `getBoundingClientRect()`** via `page.evaluate()`, then use `max(css_size, svg_size) + padding` for viewport dimensions. See `references/mermaid.md` for the corrected screenshot script.
### Aspect Ratio Preservation (embedding)
**MUST read actual image dimensions and calculate height proportionally. NEVER hardcode both width and height.**
---
## matplotlib-Specific Rules
These apply when routing to matplotlib/seaborn:
### Layout & Overlap
- Prefer `constrained_layout=True` over `tight_layout()`
- Use `adjustText` library for automatic label repositioning — **this is the most reliable anti-overlap tool for matplotlib.** Install: `pip install adjustText`. Usage: `from adjustText import adjust_text; adjust_text(texts)`
- Max 4 subplots per canvas. More → split images or `figsize=(20, 16)` minimum
- Multi-subplot: `GridSpec` with `wspace/hspace` ≥ 0.3
- Colorbar: `shrink=0.8` + `pad=0.08`
- Data labels: Y-axis upper limit with 15-20% headroom (`ylim(0, max_val * 1.18)`)
- Long X labels → horizontal bar chart or show every N-th label
### Radar / Spider Charts
- **Every `fill()` MUST have `alpha=0.25`** (max 0.3). Omitting alpha = opaque = hides underlying series.
- Legend: place outside chart with `bbox_to_anchor`, start with `(0.5, -0.15), loc='upper center'`. If dimension labels are long or dimensions > 8, increase offset (e.g., `-0.25` or `-0.3`). Also FORBIDDEN: `loc='lower right'` (collides with radar dimension labels).
- Dimension label padding: `set_rlim(0, max_value * 1.2)`
- Labels with >4 CJK chars: rotate to follow angle or abbreviate
- `figsize=(8, 8)` mandatory (square)
### One Color, Gray the Rest
5 lines → color only the key one, others `#D1D5DB`. 8 bars → accent only the highlight, rest `#E5E7EB`.
---
## Connector Rules (structural diagrams)
- Attach to node edges, not through centers
- Prefer orthogonal polylines or clean curves
- Main paths avoid crossing
- Never pass through text areas
- Start/end points at same level must align (no staggering)
- Same-level connectors follow same direction
- Bend angles consistent (all right-angles or all curves, no mixing)
- Label positions uniform (all above line or all centered)
---
## Pre-Output Checklist
Before delivery, verify:
- [ ] Zero overlap (nodes, connectors, labels, legends — **especially check legend vs data, and adjacent pie/bar labels**)
- [ ] No connectors pass through text boxes
- [ ] Clear hierarchy (primary/secondary/annotation visually distinct)
- [ ] Low-saturation palette (no forbidden background colors)
- [ ] Text readable at final size (standalone: ≥12px body, ≥10px annotation; PDF embed: ≥10pt/8pt/7pt)
- [ ] Legend fully visible, not clipped, not overlapping any chart element
- [ ] Canvas wide/tall enough (check bounding box before screenshot)
- [ ] **If mind map**: each level distinct (≥3 property changes), connectors visible (≥ `#94A3B8`), left-right balanced
- [ ] **If flowchart**: phase titles distinct from steps, arrows only between phases, **using Layout C by default**
- [ ] **If flowchart**: phase colors are same-hue family (blue-gray progression), **NOT rainbow** (blue→green→amber→purple)
- [ ] **If flowchart looks scattered**: STOP — you're using the wrong layout, switch to Layout C
- [ ] **If Mermaid looked rigid**: already switched to Playwright+CSS
---
## Anti-Pattern Quick Reference
| ❌ Don't | ✅ Do This Instead |
|----------|-------------------|
| matplotlib default blue `#1f77b4` | Use this skill's palette |
| 3D bar/pie | Always 2D |
| Rainbow colormap (jet/rainbow) | Single-hue gradient or diverging |
| Thick black grid lines | `alpha=0.08` or remove |
| Different color per bar | Same series same color, highlight only key |
| 45° tilted X labels | Horizontal bar chart or shorten |
| 8+ subplots in one canvas | Split to 2-3 images, max 4 each |
| `tight_layout()` alone | `constrained_layout=True` or `GridSpec` |
| Labels overflowing chart | `ylim` with 18-25% headroom |
| Mind map: all levels same style | Root+L1 get boxes, leaves plain text |
| Mind map: image too tall | Left-right layout for ≥5 branches |
| Mind map: invisible connectors | Lines ≥ `#94A3B8`, root→L1 `#64748B` 2.5px |
| Mind map: unbalanced sides | Alternate large/small branches across sides |
| Flowchart: high-sat node fills | Low-sat bg (`#EFF6FF`) + sat border (`#3B82F6`) |
| Flowchart: dark bg + dark text | Dark bg → white text. Light bg → dark text |
| Flowchart: arrows between every step | Arrows ONLY between phases, steps use indent |
| Flowchart: cross-layer lines through nodes | Connect adjacent layers only |
| Flowchart: Grid layout for phased process | **Always use Layout C (Phased Vertical)** |
| Flowchart: phase titles as floating labels | Phase titles MUST be inside group cards |
| Flowchart: nodes scattered without grouping | Group nodes into phase cards with `.phase-group` |
| Flowchart: rainbow phase colors (blue→green→amber→purple) | Same-hue blue-gray progression for all phases |
| Multiple arrows to same entry point | Merge-then-enter pattern |
| Legend inside plot obscuring data | `bbox_to_anchor` outside plot area |
| Radar fill without alpha | `alpha=0.25` mandatory |
| Decorative icons/emoji | Let the data speak |
| Grid lines where whitespace suffices | Background contrast or spacing instead |
---
## UI Aesthetics (dashboards / card layouts)
When building UI-style outputs (dashboards, panels), apply "Invisible Precision":
- **Boundaries**: Subtle bg shifts (`#F7F7F7` on `#FFFFFF`), not border lines. Reserve `1px` dividers for absolute logical breaks only.
- **Actions**: Primary CTA in dark neutral (`#1A1A1B`). Secondary: ghost/gray. Hover: 5% darker, no size change.
- **Quiet UI**: Action buttons `opacity: 0` by default, `1` on hover. Only active elements get visual indicators.
- **Numbers**: `font-variant-numeric: tabular-nums` for strict vertical alignment.
- **Spacing**: `line-height: 1.625`, generous paragraph spacing.
+49
View File
@@ -0,0 +1,49 @@
# ⚠️ STRUCTURAL DIAGRAM IRON LAWS
These rules apply to Playwright+CSS structural diagrams (flowcharts, mind maps, radial grids, org charts). They are enforced by the template files. Violating any = task failure.
## 1. ZERO OVERLAP
No element may overlap another:
- Arrows/connectors must not cross over text boxes
- Connectors must not pass through node bodies
- Text boxes / nodes must not overlap each other
- Labels must not obscure any graphic element
**Post-generation**: verify every element has clear separation. If overlap exists, fix before delivery — enlarge canvas, increase spacing, or reduce content.
## 2. LAYOUT MUST HAVE HIERARCHY
Forbidden: all nodes same size, same level, mechanically tiled in a flat grid.
Required:
- Primary nodes visually larger/bolder than secondary nodes
- Annotation nodes clearly subordinate (smaller, muted color)
- Spacing between groups > spacing within groups
- Clear reading path (top→bottom, left→right, or center→outward)
**Squint test**: if every box looks identical, the layout has failed.
## 3. NODE BACKGROUND COLORS
**🚫 Forbidden as background (too saturated for large fills):**
| Color | Forbidden Hex |
|-------|--------------|
| Pure blue | `#3B82F6`, `#2563EB`, `#1D4ED8` |
| Pure green | `#10B981`, `#059669`, `#22C55E` |
| Pure red | `#EF4444`, `#DC2626`, `#F87171` |
| Pure purple | `#8B5CF6`, `#7C3AED`, `#A855F7` |
| Pure amber | `#F59E0B`, `#D97706`, `#FB923C` |
| Any color: R/G/B > 0xCC and saturation > 50% | — |
**✅ Allowed as background:**
| Color | Hex | Usage |
|-------|-----|-------|
| Ice blue | `#EFF6FF`, `#DBEAFE` | Normal step nodes |
| Mint green | `#F0FDF4`, `#D1FAE5` | Success/pass nodes |
| Light amber | `#FFF7ED`, `#FEF3C7` | Decision/warning nodes |
| Lavender | `#F5F3FF`, `#EDE9FE` | End/terminal nodes |
| Light gray | `#F8FAFC`, `#F1F5F9` | Group containers |
| White | `#FFFFFF` | Default canvas |
**Rule: Saturated colors go on BORDERS (2px) and TEXT only. Backgrounds stay pale.**
+199
View File
@@ -0,0 +1,199 @@
# D3.js Template Library
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
D3.js is the "ultimate weapon" in visualization — highest freedom, but steepest learning curve.
Suitable for: visualizations requiring fully custom interactions, data journalism, art-grade infographics.
**If your needs can be met by ECharts/matplotlib, don't use D3.** D3's value lies in "charts others can't make".
## HTML Universal Shell
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>{{TITLE}}</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #FFFFFF; font-family: system-ui, 'SimHei', sans-serif; }
svg { display: block; margin: 40px auto; }
/* Design tokens */
:root {
--text: #111827;
--text-sub: #6B7280;
--text-muted: #9CA3AF;
--axis: #E5E7EB;
--grid: #F3F4F6;
--blue: #3B82F6;
--cyan: #06B6D4;
--purple: #8B5CF6;
--amber: #F59E0B;
--red: #EF4444;
--green: #10B981;
}
</style>
</head>
<body>
<svg id="chart"></svg>
<script>
// D3 code
</script>
</body>
</html>
```
## Colors and Constants
```javascript
const COLORS = ['#3B82F6', '#06B6D4', '#8B5CF6', '#F59E0B', '#EF4444', '#10B981'];
const GRAY = { 200: '#E5E7EB', 300: '#D1D5DB', 400: '#9CA3AF', 500: '#6B7280', 900: '#111827' };
const margin = { top: 60, right: 40, bottom: 50, left: 60 };
const width = 900 - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
```
---
## Template 1: Insight Bar Chart
```javascript
const svg = d3.select('#chart')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Title (insight-driven)
svg.append('text')
.attr('x', 0).attr('y', -30)
.attr('fill', GRAY[900])
.attr('font-size', 16).attr('font-weight', 'bold')
.text('产品C销售额领先,达到89万');
// Scales
const x = d3.scaleBand().domain(data.map(d => d.name)).range([0, width]).padding(0.35);
const y = d3.scaleLinear().domain([0, d3.max(data, d => d.value) * 1.15]).range([height, 0]);
// Y axis (minimal: no tick marks, light gray)
svg.append('g')
.call(d3.axisLeft(y).ticks(5).tickSize(0).tickFormat(d3.format(',')))
.call(g => g.select('.domain').attr('stroke', GRAY[200]).attr('stroke-width', 0.8))
.call(g => g.selectAll('.tick text').attr('fill', GRAY[500]).attr('font-size', 9));
// X axis
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x).tickSize(0))
.call(g => g.select('.domain').attr('stroke', GRAY[200]).attr('stroke-width', 0.8))
.call(g => g.selectAll('.tick text').attr('fill', GRAY[500]).attr('font-size', 9));
// Bars (gray + highlight)
svg.selectAll('.bar')
.data(data)
.join('rect')
.attr('x', d => x(d.name))
.attr('width', x.bandwidth())
.attr('y', d => y(d.value))
.attr('height', d => height - y(d.value))
.attr('fill', d => d.highlight ? '#3B82F6' : GRAY[200])
.attr('rx', 3);
// Value labels
svg.selectAll('.label')
.data(data)
.join('text')
.attr('x', d => x(d.name) + x.bandwidth()/2)
.attr('y', d => y(d.value) - 6)
.attr('text-anchor', 'middle')
.attr('fill', d => d.highlight ? GRAY[900] : GRAY[400])
.attr('font-size', d => d.highlight ? 12 : 10)
.attr('font-weight', d => d.highlight ? 'bold' : 'normal')
.text(d => d3.format(',')(d.value));
```
---
## Template 2: Force-Directed Graph
This is D3's killer feature — other frameworks can hardly achieve the same effect.
```javascript
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width/2, height/2))
.force('collision', d3.forceCollide(20));
const link = svg.selectAll('.link')
.data(links).join('line')
.attr('stroke', GRAY[200]).attr('stroke-width', 1);
const node = svg.selectAll('.node')
.data(nodes).join('circle')
.attr('r', d => Math.sqrt(d.value) * 3)
.attr('fill', (d, i) => COLORS[d.group % COLORS.length])
.attr('stroke', '#fff').attr('stroke-width', 1.5)
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })
);
// Labels
const labels = svg.selectAll('.label')
.data(nodes).join('text')
.text(d => d.name)
.attr('font-size', 9).attr('fill', GRAY[500])
.attr('text-anchor', 'middle').attr('dy', -15);
simulation.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('cx', d => d.x).attr('cy', d => d.y);
labels.attr('x', d => d.x).attr('y', d => d.y);
});
```
---
## Template 3: Treemap
```javascript
const root = d3.hierarchy(treeData).sum(d => d.value);
d3.treemap().size([width, height]).padding(2)(root);
svg.selectAll('.cell')
.data(root.leaves())
.join('rect')
.attr('x', d => d.x0).attr('y', d => d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('fill', (d, i) => COLORS[i % COLORS.length])
.attr('rx', 2).attr('opacity', 0.85);
svg.selectAll('.cell-label')
.data(root.leaves().filter(d => (d.x1-d.x0) > 40 && (d.y1-d.y0) > 20))
.join('text')
.attr('x', d => d.x0 + 4).attr('y', d => d.y0 + 14)
.text(d => d.data.name)
.attr('font-size', 10).attr('fill', 'white').attr('font-weight', 'bold');
```
---
## D3 Use Cases (vs Overkill)
| ✅ D3 Best | ❌ Overkill |
|-----------|-------------|
| Force-directed relationship graph | Regular bar chart (use matplotlib) |
| Custom geographic visualization | Standard map (use ECharts) |
| Data-driven animation | Static report chart (use matplotlib) |
| Treemap / Sunburst | Standard pie chart (use matplotlib) |
| Complex interactions (brushing/linking/drill-down) | Simple tooltip (use ECharts) |
| Data journalism/narrative visualization | Dashboard (use ECharts) |
+651
View File
@@ -0,0 +1,651 @@
# ECharts Template Library
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
ECharts strengths: interactivity (tooltip/zoom/linking), big data (Canvas renders millions of points smoothly), strong Chinese community.
Output as HTML files — open directly in browser or export PNG via Playwright screenshot.
## HTML Universal Shell
Wrap all ECharts charts with this shell:
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>{{TITLE}}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: {{BG_COLOR}}; }
#chart { width: {{WIDTH}}px; height: {{HEIGHT}}px; margin: 40px auto; }
</style>
</head>
<body>
<div id="chart"></div>
<script>
const chart = echarts.init(document.getElementById('chart'));
const option = { /* see templates below */ };
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
</script>
</body>
</html>
```
Default dimensions: `width=900, height=520`, white background `#FFFFFF`.
---
## Theme Configuration
### Light Theme (Default)
```javascript
const THEME = {
bg: '#FFFFFF',
text: '#111827',
textSub: '#6B7280',
textMuted: '#9CA3AF',
axis: '#E5E7EB',
grid: '#F3F4F6',
tooltip: { bg: '#1E293B', border: '#334155', text: '#F1F5F9' },
colors: ['#3B82F6', '#06B6D4', '#8B5CF6', '#F59E0B', '#EF4444', '#10B981'],
};
```
### Dark Theme (Finance / Tech Dashboard)
```javascript
const DARK = {
bg: '#0F172A',
text: '#F1F5F9',
textSub: '#94A3B8',
textMuted: '#64748B',
axis: '#334155',
grid: '#1E293B',
tooltip: { bg: '#1E293B', border: '#475569', text: '#F1F5F9' },
colors: ['#3B82F6', '#06B6D4', '#8B5CF6', '#F59E0B', '#22C55E', '#EC4899'],
};
```
### Base Option Configuration
```javascript
function baseOption(theme, title, subtitle) {
return {
backgroundColor: theme.bg,
textStyle: { fontFamily: 'system-ui, SimHei, sans-serif', color: theme.text },
title: {
text: title, subtext: subtitle || '',
left: 24, top: 16,
textStyle: { fontSize: 16, fontWeight: 'bold', color: theme.text },
subtextStyle: { fontSize: 12, color: theme.textSub },
},
grid: { left: 60, right: 40, top: 80, bottom: 50, containLabel: true },
color: theme.colors,
tooltip: {
trigger: 'axis',
backgroundColor: theme.tooltip.bg,
borderColor: theme.tooltip.border,
borderWidth: 1,
textStyle: { color: theme.tooltip.text, fontSize: 12 },
},
animationDuration: 600,
animationEasing: 'cubicOut',
};
}
function cleanAxis(theme) {
return {
axisLine: { lineStyle: { color: theme.axis, width: 0.8 } },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid, width: 0.5 } },
axisLabel: { color: theme.textSub, fontSize: 10 },
};
}
```
---
## Template 1: Insight Bar Chart
```javascript
const option = {
...baseOption(THEME, 'Q3 收入环比增长 47%', '各季度收入对比(万元)'),
xAxis: { type: 'category', data: ['Q1','Q2','Q3','Q4'], ...cleanAxis(THEME) },
yAxis: { type: 'value', ...cleanAxis(THEME) },
series: [{
type: 'bar', barWidth: '50%',
itemStyle: { borderRadius: [4, 4, 0, 0] },
data: [
{ value: 120, itemStyle: { color: '#E5E7EB' } },
{ value: 145, itemStyle: { color: '#E5E7EB' } },
{ value: 213, itemStyle: { color: '#3B82F6' } },
{ value: 180, itemStyle: { color: '#E5E7EB' } },
],
label: {
show: true, position: 'top', fontSize: 11, color: '#6B7280',
formatter: (p) => p.dataIndex === 2
? '{hl|' + p.value + '}'
: p.value,
rich: { hl: { fontWeight: 'bold', fontSize: 13, color: '#111827' } },
},
}],
};
```
---
## Template 2: Multi-Line Trend
```javascript
const option = {
...baseOption(THEME, '2024 年增长持续加速'),
legend: { right: 40, top: 20, textStyle: { color: '#6B7280', fontSize: 10 } },
xAxis: { type: 'category', data: months, boundaryGap: false, ...cleanAxis(THEME) },
yAxis: { type: 'value', ...cleanAxis(THEME) },
series: [
{
name: '2024', type: 'line', data: thisYear,
lineStyle: { width: 2.5 },
symbol: 'circle', symbolSize: 6,
itemStyle: { color: '#3B82F6' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59,130,246,0.12)' },
{ offset: 1, color: 'rgba(59,130,246,0)' },
]),
},
},
{
name: '2023', type: 'line', data: lastYear,
lineStyle: { width: 1.5, type: 'dashed', color: '#D1D5DB' },
symbol: 'none', itemStyle: { color: '#D1D5DB' },
},
],
};
```
---
## Template 3: Candlestick (Finance)
```javascript
// Dark theme
const option = {
...baseOption(DARK, 'BTC/USDT 日K线'),
xAxis: { type: 'category', data: dates, ...cleanAxis(DARK) },
yAxis: { type: 'value', scale: true, ...cleanAxis(DARK) },
dataZoom: [
{ type: 'inside', start: 70, end: 100 },
{ type: 'slider', start: 70, end: 100, height: 20, bottom: 10,
borderColor: DARK.axis, fillerColor: 'rgba(59,130,246,0.15)',
textStyle: { color: DARK.textSub } },
],
series: [{
type: 'candlestick',
data: ohlcData, // [[open,close,low,high], ...]
itemStyle: {
color: '#22C55E', // Bullish (close > open)
color0: '#EF4444', // Bearish
borderColor: '#16A34A',
borderColor0: '#DC2626',
},
}],
};
```
---
## Template 4: Dashboard (Multi-Chart Linking)
⚠️ **ECharts multi-chart dashboard anti-overlap rules** (highest priority):
1. Maximum 4 subplots per canvas; more must be split into multiple HTML files
2. Each subplot's `grid` area must not overlap; maintain ≥5% safety margin between adjacent grids
3. Pie chart `center` and `radius` must not intrude into other subplots' grid areas
4. Same applies to radar chart's `radar.center` and `radar.radius`
5. Place legend in top/bottom common area, not inside subplots
### Simple Dual Chart (Bar + Pie)
```javascript
const option = {
...baseOption(THEME, '产品线收入分布'),
grid: [{ left: 60, right: '55%', top: 80, bottom: 50 }],
xAxis: [{ type: 'value', gridIndex: 0, ...cleanAxis(THEME) }],
yAxis: [{ type: 'category', data: products, gridIndex: 0, ...cleanAxis(THEME) }],
series: [
{
type: 'bar', data: revenues,
itemStyle: { borderRadius: [0, 4, 4, 0] },
barWidth: '60%',
},
{
type: 'pie', center: ['78%', '50%'], radius: ['35%', '55%'],
data: products.map((name, i) => ({ name, value: revenues[i] })),
label: { formatter: '{b}\n{d}%', fontSize: 10 },
itemStyle: { borderColor: '#fff', borderWidth: 2 },
},
],
};
```
### Four-Chart Dashboard (Safe Layout Template)
```javascript
// ⚠️ Key: grid areas precisely defined, no overlap, maintain safety margins
const option = {
...baseOption(THEME, '数据全景仪表盘'),
grid: [
// Top-left: bar chart
{ left: '5%', right: '55%', top: '12%', bottom: '55%' },
// Top-right: line chart
{ left: '55%', right: '5%', top: '12%', bottom: '55%' },
// Bottom-left: scatter plot
{ left: '5%', right: '55%', top: '55%', bottom: '5%' },
// Bottom-right area reserved for pie chart (pie uses center + radius, not grid)
],
xAxis: [
{ type: 'category', gridIndex: 0, data: categories1, ...cleanAxis(THEME) },
{ type: 'category', gridIndex: 1, data: categories2, ...cleanAxis(THEME), boundaryGap: false },
{ type: 'value', gridIndex: 2, ...cleanAxis(THEME) },
],
yAxis: [
{ type: 'value', gridIndex: 0, ...cleanAxis(THEME) },
{ type: 'value', gridIndex: 1, ...cleanAxis(THEME) },
{ type: 'value', gridIndex: 2, ...cleanAxis(THEME) },
],
series: [
// Top-left: bar chart
{
type: 'bar', xAxisIndex: 0, yAxisIndex: 0,
data: barData,
itemStyle: { borderRadius: [4, 4, 0, 0] },
},
// Top-right: line chart
{
type: 'line', xAxisIndex: 1, yAxisIndex: 1,
data: lineData,
smooth: true,
areaStyle: { opacity: 0.08 },
},
// Bottom-left: scatter plot
{
type: 'scatter', xAxisIndex: 2, yAxisIndex: 2,
data: scatterData,
symbolSize: 8,
},
// Bottom-right: pie chart (positioned via center in bottom-right quadrant)
{
type: 'pie',
center: ['77%', '72%'], // Positioned at bottom-right area center
radius: ['15%', '25%'], // Radius stays within bottom-right quadrant
data: pieData,
label: { formatter: '{b}\n{d}%', fontSize: 10 },
itemStyle: { borderColor: '#fff', borderWidth: 2 },
},
],
};
```
### Grid Safety Margin Quick Reference
| Layout | Grid Config | Safety Margin |
|------|----------|---------|
| Left-right dual | Left `right:'55%'` Right `left:'55%'` | 10% center gap |
| Top-bottom dual | Top `bottom:'55%'` Bottom `top:'55%'` | 10% center gap |
| 2x2 quad | Each quadrant 45%, 10% center gap | 5% margin on all sides |
| With pie/radar | Pie center+radius must not intrude grid | Pie radius ≤ 40% of available area |
### What If More Than 4 Subplots?
```javascript
// ❌ Wrong: 8 charts crammed into one canvas — all labels will inevitably overlap
// ✅ Correct: split into 2 HTML files
// dashboard_overview.html — 4 overview charts
// dashboard_detail.html — 4 detailed analysis charts
// Or use tab switching (ECharts toolbox doesn't support this, need custom HTML tabs)
```
---
## Template 5: Radar Chart
```javascript
const option = {
...baseOption(THEME, '团队能力评估'),
radar: {
indicator: dims.map(d => ({ name: d, max: 100 })),
axisName: { color: '#6B7280', fontSize: 10 },
splitArea: { areaStyle: { color: ['#FAFAFA', '#F5F5F5'] } },
splitLine: { lineStyle: { color: '#E5E7EB' } },
axisLine: { lineStyle: { color: '#E5E7EB' } },
},
series: [{
type: 'radar',
data: teams.map((t, i) => ({
name: t.name, value: t.scores,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.08 },
itemStyle: { color: THEME.colors[i] },
})),
}],
legend: { bottom: 10, textStyle: { color: '#6B7280' } },
};
```
---
## Export to PNG (Playwright)
```python
import asyncio
from playwright.async_api import async_playwright
async def echarts_to_png(html_path, png_path, width=900, height=520):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(viewport={'width': width, 'height': height})
await page.goto(f'file://{html_path}', wait_until='networkidle')
await page.wait_for_timeout(800) # Wait for animation to complete
await page.locator('#chart').screenshot(path=png_path)
await browser.close()
print(f'✅ {png_path}')
# asyncio.run(echarts_to_png('./output/chart.html', './output/chart.png'))
```
---
## Template 5: Tree (Interactive Only)
**⚠️ For static PNG export, use Playwright+CSS (see `mindmap-css.md`). ECharts tree connector length and node spacing cannot be finely controlled — static output is not aesthetically satisfying.**
**ECharts tree is suitable for interactive scenarios** (click expand/collapse, hover tooltip, zoom/drag). For PNG/PDF static output, the CSS approach looks better.
### Basic Usage
```javascript
const option = {
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
series: [{
type: 'tree',
data: [treeData], // Tree-structured JSON data
layout: 'orthogonal', // Orthogonal layout (right-angle connectors)
orient: 'LR', // Direction: LR(left→right) / RL / TB(top→bottom) / BT
// Node spacing control (key params to prevent crowding)
initialTreeDepth: -1, // -1=expand all, positive=initial expand depth
// Label style
label: {
position: 'left', // Leaf node label position
verticalAlign: 'middle',
fontSize: 13,
fontFamily: 'PingFang SC, SimHei, sans-serif',
},
leaves: {
label: { position: 'right' } // Leaf labels on right
},
// Connector style
lineStyle: {
color: '#94A3B8',
width: 1.5,
curveness: 0.5, // Curvature, 0=straight, 0.5=natural curve
},
// Node style
itemStyle: {
borderWidth: 1.5,
},
// Animation
animationDuration: 550,
animationDurationUpdate: 750,
}]
};
```
### Tree Data Format
```javascript
const treeData = {
name: '中心主题',
children: [
{
name: '分支A',
children: [
{ name: '叶子1' },
{ name: '叶子2' },
{ name: '叶子3', children: [{ name: '更深叶子' }] }
]
},
{
name: '分支B',
children: [
{ name: '叶子4' },
{ name: '叶子5' }
]
}
]
};
```
### Node Style Customization (by Level)
```javascript
// Root node highlight
function styleTreeData(node, depth) {
const styles = [
{ // Root node
itemStyle: { color: '#3B82F6', borderColor: '#2563EB', borderWidth: 2 },
label: { fontSize: 18, fontWeight: 'bold', color: '#fff',
backgroundColor: '#3B82F6', borderRadius: 6, padding: [8, 16] }
},
{ // Level 1 branches
itemStyle: { color: '#60A5FA', borderColor: '#3B82F6' },
label: { fontSize: 15, fontWeight: 600, color: '#1E40AF',
backgroundColor: '#EFF6FF', borderColor: '#3B82F6',
borderWidth: 1.5, borderRadius: 6, padding: [6, 14] }
},
{ // Level 2
itemStyle: { color: '#93C5FD', borderColor: '#60A5FA' },
label: { fontSize: 13, color: '#1E40AF',
backgroundColor: '#F0F7FF', borderColor: '#93C5FD',
borderWidth: 1, borderRadius: 4, padding: [4, 10] }
},
{ // Level 3+ leaves
itemStyle: { color: '#BFDBFE', borderColor: '#93C5FD' },
label: { fontSize: 12, color: '#475569', padding: [3, 8] }
}
];
const style = styles[Math.min(depth, styles.length - 1)];
Object.assign(node, style);
if (node.children) {
node.children.forEach(child => styleTreeData(child, depth + 1));
}
}
styleTreeData(treeData, 0);
```
### Left-Right Distribution (Large Tree Mode)
When branches ≥ 5, use two trees for left-right expansion:
```javascript
function splitTree(data) {
const children = data.children || [];
// Alternate assignment by subtree size
const sorted = children.map((c, i) => ({ c, w: countNodes(c), i }))
.sort((a, b) => b.w - a.w);
const left = [], right = [];
let lw = 0, rw = 0;
sorted.forEach(({ c }) => {
if (lw <= rw) { left.push(c); lw += countNodes(c); }
else { right.push(c); rw += countNodes(c); }
});
return {
left: { name: data.name, children: left },
right: { name: data.name, children: right }
};
}
function countNodes(node) {
if (!node.children) return 1;
return 1 + node.children.reduce((s, c) => s + countNodes(c), 0);
}
// Dual tree series
const { left, right } = splitTree(treeData);
const option = {
series: [
{ type: 'tree', data: [right], orient: 'LR', left: '50%', width: '45%', /* ... */ },
{ type: 'tree', data: [left], orient: 'RL', right: '50%', width: '45%', /* ... */ },
]
};
```
### Recommended Canvas Size
| Node Count | Width | Height |
|--------|------|------|
| ≤ 15 | 900px | 500px |
| 16-30 | 1200px | 600px |
| 31-60 | 1600px | 800px |
| 60+ | 2000px | 1000px |
---
## Template 6: Relationship / Force-Directed Graph
**ECharts graph suits process relationships, org charts, knowledge graphs** — nodes auto-repel to avoid overlap, connectors auto-bind, supports categorical coloring.
### Basic Usage
```javascript
const option = {
tooltip: {},
legend: [{ data: categories.map(c => c.name) }],
series: [{
type: 'graph',
layout: 'force', // Force-directed auto layout
// Force model params (controls repulsion and attraction)
force: {
repulsion: 300, // Repulsion force (higher = more spread out, recommended 200-500)
gravity: 0.1, // Gravity (prevents nodes from flying too far)
edgeLength: [100, 200], // Edge length range
layoutAnimation: true,
},
roam: true, // Allow drag and zoom
draggable: true, // Allow dragging nodes
// Nodes
data: nodes,
// Edges
links: links,
// Categories (for coloring)
categories: categories,
// Labels
label: {
show: true,
position: 'right',
fontSize: 12,
fontFamily: 'PingFang SC, SimHei, sans-serif',
},
// Connector style
lineStyle: {
color: 'source', // Edge color follows source node
curveness: 0.3, // Curvature
width: 1.5,
},
// Highlight effect
emphasis: {
focus: 'adjacency', // Highlight adjacent nodes on hover
lineStyle: { width: 3 },
},
}]
};
```
### Data Format
```javascript
const categories = [
{ name: '核心系统', itemStyle: { color: '#3B82F6' } },
{ name: '数据层', itemStyle: { color: '#10B981' } },
{ name: '应用层', itemStyle: { color: '#F59E0B' } },
];
const nodes = [
{ name: 'API Gateway', category: 0, symbolSize: 40 },
{ name: 'User Service', category: 0, symbolSize: 30 },
{ name: 'MySQL', category: 1, symbolSize: 35 },
{ name: 'Redis', category: 1, symbolSize: 28 },
{ name: 'Web App', category: 2, symbolSize: 32 },
];
const links = [
{ source: 'API Gateway', target: 'User Service' },
{ source: 'User Service', target: 'MySQL' },
{ source: 'User Service', target: 'Redis' },
{ source: 'Web App', target: 'API Gateway' },
];
```
### Flowchart Mode (Fixed Layout)
When you don't want force-directed auto-layout, fix node positions:
```javascript
const option = {
series: [{
type: 'graph',
layout: 'none', // Fixed layout, positions determined by x/y
data: [
{ name: '开始', x: 300, y: 50, symbolSize: 40,
itemStyle: { color: '#EFF6FF', borderColor: '#3B82F6', borderWidth: 2 } },
{ name: '处理', x: 300, y: 200, symbolSize: 35 },
{ name: '判断', x: 300, y: 350, symbolSize: 35,
symbol: 'diamond',
itemStyle: { color: '#FFF7ED', borderColor: '#F59E0B', borderWidth: 2 } },
{ name: '结束', x: 300, y: 500, symbolSize: 40 },
],
links: [
{ source: '开始', target: '处理' },
{ source: '处理', target: '判断' },
{ source: '判断', target: '结束', label: { show: true, formatter: '通过' } },
],
lineStyle: { color: '#94A3B8', width: 2, curveness: 0 },
edgeSymbol: ['', 'arrow'],
edgeSymbolSize: [0, 10],
}]
};
```
---
## ECharts vs Other Frameworks
| Capability | ECharts | Plotly | Chart.js |
|------|---------|--------|----------|
| Canvas rendering (big data) | ✅ Millions | ❌ SVG-based | ✅ But limited |
| Chinese docs | ✅ Official | ❌ English | ❌ English |
| Candlestick | ✅ Built-in | ❌ Plugin needed | ❌ None |
| Maps | ✅ Built-in China map | ✅ mapbox | ❌ None |
| 3D Charts | ✅ echarts-gl | ✅ Built-in | ❌ None |
| No Node.js needed | ✅ CDN import | ❌ Needs plotly.js | ✅ CDN |
| Server-side rendering | ✅ node-echarts | ✅ orca | ✅ chartjs-node |
+617
View File
@@ -0,0 +1,617 @@
# matplotlib Template Library
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
## Environment Initialization (Must Execute Before Each Plot)
```python
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import numpy as np
# ═══ Chinese Font Setup ═══
# SimHei is the default; other fonts available:
# SimSun.ttf (Songti, formal docs), SimKai.ttf (Kaiti, artistic),
# SarasaMonoSC-*.ttf (monospace CJK, code scenes)
# Run `fc-list :lang=zh` for system fonts (PingFang SC, Heiti TC, etc.)
# Font path: adjust for your system. Common locations:
# macOS: '/System/Library/Fonts/Supplemental/SimHei.ttf'
# Linux: '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc'
# Custom: './fonts/SimHei.ttf'
import os
SIMHEI_PATH = os.environ.get('SIMHEI_FONT', '/System/Library/Fonts/Supplemental/SimHei.ttf')
matplotlib.font_manager.fontManager.addfont(SIMHEI_PATH)
# ═══ Global Style ═══
plt.rcParams.update({
# Font
'font.sans-serif': ['SimHei'],
'axes.unicode_minus': False,
# Background
'figure.facecolor': '#FFFFFF',
'axes.facecolor': '#FFFFFF',
# Border: only keep left and bottom
'axes.edgecolor': '#E5E7EB',
'axes.linewidth': 0.8,
'axes.spines.top': False,
'axes.spines.right': False,
# Grid: off by default
'axes.grid': False,
# Ticks: hide tick marks
'xtick.major.size': 0,
'ytick.major.size': 0,
'xtick.labelsize': 9,
'ytick.labelsize': 9,
# Title
'axes.labelsize': 10,
'axes.titlesize': 16,
'axes.titleweight': 'bold',
'axes.titlepad': 16,
# Legend: no frame
'legend.frameon': False,
'legend.fontsize': 9,
# Export
'figure.dpi': 200,
'savefig.dpi': 200,
'savefig.bbox': 'tight',
'savefig.facecolor': '#FFFFFF',
'savefig.pad_inches': 0.3,
})
```
## Color Constants
```python
# ─── Cool Colors (Business/Tech) ───
C_BLUE = '#3B82F6'
C_CYAN = '#06B6D4'
C_PURPLE = '#8B5CF6'
C_AMBER = '#F59E0B'
C_RED = '#EF4444'
C_GREEN = '#10B981'
COOL = [C_BLUE, C_CYAN, C_PURPLE, C_AMBER, C_RED, C_GREEN]
# ─── Warm Colors (Warmth/Creative) ───
WARM = ['#F59E0B', '#EF4444', '#8B5CF6', '#3B82F6', '#10B981', '#EC4899']
# ─── Academic Grayscale ───
ACADEMIC = ['#111827', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB', '#F3F4F6']
# ─── Colorblind-Safe (Paper Preferred) ───
CB_SAFE = ['#0077BB', '#33BBEE', '#009988', '#EE7733', '#CC3311', '#EE3377']
# ─── Grayscale ───
G900, G700, G500, G400, G300, G200, G100, G50 = \
'#111827', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB', '#F3F4F6', '#F9FAFB'
# ─── Gain/Loss ───
POS = '#22C55E'
NEG = '#EF4444'
```
## Helper Functions
```python
def clean_axis(ax, grid=True):
"""Clean axis: remove top and right borders, add faint grid"""
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
if grid:
ax.yaxis.grid(True, alpha=0.08, color=G300)
ax.set_axisbelow(True)
def add_value_labels(ax, bars, values, highlight_idx=None, fmt='{:,.0f}',
offset_ratio=0.02):
"""Add value labels on top of bars, highlight bar in bold.
⚠️ Automatically extends Y-axis upper limit to ensure labels don't overflow chart area."""
max_val = max(values)
for i, (bar, val) in enumerate(zip(bars, values)):
is_hl = (i == highlight_idx) if highlight_idx is not None else False
ax.text(bar.get_x() + bar.get_width()/2,
bar.get_height() + max_val * offset_ratio,
fmt.format(val), ha='center', va='bottom',
fontsize=10 if is_hl else 9,
color=G900 if is_hl else G400,
fontweight='bold' if is_hl else 'normal')
# ⚠️ Critical: extend Y-axis upper limit to leave enough space for labels (at least 15%)
ax.set_ylim(0, max_val * 1.18)
def save(fig, path, dpi=200):
"""Unified save — prefer constrained_layout, fallback to tight_layout"""
if not fig.get_constrained_layout():
try:
fig.tight_layout()
except Exception:
pass
fig.savefig(path, dpi=dpi, facecolor='white', bbox_inches='tight')
plt.close(fig)
import os
size_kb = os.path.getsize(path) / 1024
print(f'✅ {path} ({size_kb:.0f}KB)')
```
### Label Text Avoidance (adjustText)
When charts have multiple label annotations (e.g., scatter plot labels, box plot annotations), **must use adjustText library** to prevent text overlap:
```python
# pip install adjustText
from adjustText import adjust_text
# Call after adding all annotations
texts = []
for i, (x, y, label) in enumerate(zip(x_data, y_data, labels)):
texts.append(ax.text(x, y, label, fontsize=9, color='#374151'))
# Auto-avoidance — pass all text objects, adjustText will move them to avoid overlap
adjust_text(texts, ax=ax,
arrowprops=dict(arrowstyle='->', color='#9CA3AF', lw=0.8),
force_text=(0.5, 0.8), # Force to push text away
force_points=(0.3, 0.5), # Repulsion from data points
expand=(1.2, 1.4)) # Expansion factor for text bbox
```
---
## Template 1: Insight Bar Chart
**Scenario**: Emphasize outstanding performance of one data item. Gray out others, highlight the focus.
```python
def insight_bar(labels, values, highlight_idx, title,
highlight_color=C_BLUE, save_path='insight_bar.png'):
fig, ax = plt.subplots(figsize=(10, 6))
colors = [G200] * len(labels)
colors[highlight_idx] = highlight_color
bars = ax.bar(labels, values, color=colors, width=0.6,
zorder=3, edgecolor='white', linewidth=0.5)
add_value_labels(ax, bars, values, highlight_idx)
ax.set_title(title, loc='left')
# ⚠️ add_value_labels already sets ylim automatically, no need to repeat here
clean_axis(ax)
save(fig, save_path)
```
---
## Template 2: Trend Comparison Line Chart
**Scenario**: This year vs last year, actual vs target. Main line in colored solid, comparison line in gray dashed.
```python
def trend_compare(x, y_main, y_ref, label_main, label_ref, title,
color=C_BLUE, save_path='trend_compare.png'):
fig, ax = plt.subplots(figsize=(12, 6))
# Comparison line (gray dashed, at bottom layer)
ax.plot(x, y_ref, color=G300, linewidth=1.5, linestyle='--', zorder=2)
ax.text(len(x)-0.5, y_ref[-1], label_ref, color=G400, fontsize=9, va='center')
# Main line (colored solid + white-center dots)
ax.plot(x, y_main, color=color, linewidth=2.5, marker='o', markersize=5,
markerfacecolor='white', markeredgewidth=2, markeredgecolor=color, zorder=3)
ax.text(len(x)-0.5, y_main[-1], f'{label_main} {y_main[-1]:,.0f}',
color=color, fontsize=10, fontweight='bold', va='center')
# Difference area
ax.fill_between(range(len(x)), y_ref, y_main, alpha=0.06, color=color)
ax.set_title(title, loc='left')
ax.set_xticks(range(len(x)))
ax.set_xticklabels(x)
clean_axis(ax)
save(fig, save_path)
```
---
## Template 3: Grouped Bar Chart
**Scenario**: Comparison of multiple categories across multiple dimensions.
```python
def grouped_bar(labels, datasets, series_names, title,
colors=None, save_path='grouped_bar.png'):
if colors is None:
colors = COOL[:len(datasets)]
fig, ax = plt.subplots(figsize=(12, 6))
n = len(datasets)
width = 0.7 / n
x = np.arange(len(labels))
for i, (data, name, color) in enumerate(zip(datasets, series_names, colors)):
offset = (i - n/2 + 0.5) * width
ax.bar(x + offset, data, width=width*0.85, color=color,
label=name, zorder=3, edgecolor='white', linewidth=0.3)
ax.set_title(title, loc='left')
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylim(0, max(max(d) for d in datasets) * 1.20) # Leave 20% space for labels
ax.legend(loc='upper right', ncol=n)
clean_axis(ax)
save(fig, save_path)
```
---
## Template 4: Horizontal Ranking Chart
**Scenario**: Rankings / Top N. Progressive highlight for top items.
```python
def ranking_bar(labels, values, title, top_n=3,
color=C_BLUE, save_path='ranking.png'):
from matplotlib.colors import to_rgba
sorted_pairs = sorted(zip(labels, values), key=lambda x: x[1])
labels_s, values_s = zip(*sorted_pairs)
fig, ax = plt.subplots(figsize=(10, max(6, len(labels)*0.45)))
bar_colors = [G200] * len(labels_s)
for i in range(len(labels_s) - top_n, len(labels_s)):
progress = (i - (len(labels_s) - top_n)) / max(top_n - 1, 1)
bar_colors[i] = to_rgba(color, 0.35 + 0.65 * progress)
bars = ax.barh(range(len(labels_s)), values_s, color=bar_colors,
height=0.6, zorder=3, edgecolor='white', linewidth=0.3)
for i, (bar, val) in enumerate(zip(bars, values_s)):
is_top = i >= len(labels_s) - top_n
ax.text(bar.get_width() + max(values_s)*0.01,
bar.get_y() + bar.get_height()/2,
f'{val:,.0f}', va='center', fontsize=9,
color=G900 if is_top else G400)
ax.set_yticks(range(len(labels_s)))
ax.set_yticklabels(labels_s)
ax.set_title(title, loc='left')
ax.spines['bottom'].set_visible(False)
ax.xaxis.set_visible(False)
save(fig, save_path)
```
---
## Template 5: Donut Chart
**Scenario**: Proportion distribution (max 5 slices, avoid if possible — bar charts are usually better).
```python
def donut(labels, values, title, center_text=None,
colors=None, save_path='donut.png'):
if colors is None:
colors = COOL[:len(labels)]
fig, ax = plt.subplots(figsize=(8, 8))
wedges, _, autotexts = ax.pie(
values, labels=None, colors=colors, autopct='%1.0f%%',
startangle=90, pctdistance=0.78,
wedgeprops=dict(width=0.35, edgecolor='white', linewidth=2))
for t in autotexts:
t.set_fontsize(10)
t.set_fontweight('bold')
if center_text:
ax.text(0, 0.06, str(center_text), ha='center', va='center',
fontsize=28, fontweight='bold', color=G900)
ax.text(0, -0.1, '总计', ha='center', va='center', fontsize=11, color=G500)
ax.legend(wedges, labels, loc='center left',
bbox_to_anchor=(1, 0.5), fontsize=10)
ax.set_title(title, loc='center', pad=20)
save(fig, save_path)
```
---
## Template 6: Scatter Plot + Trend Line
**Scenario**: Two-variable correlation analysis.
```python
def scatter_trend(x, y, title, xlabel, ylabel,
color=C_BLUE, save_path='scatter.png'):
fig, ax = plt.subplots(figsize=(10, 7))
ax.scatter(x, y, c=color, s=50, alpha=0.6,
edgecolors='white', linewidth=1, zorder=3)
# Trend line
z = np.polyfit(x, y, 1)
p = np.poly1d(z)
x_line = np.linspace(min(x), max(x), 100)
ax.plot(x_line, p(x_line), color=G400, linewidth=1.5,
linestyle='--', zorder=2, alpha=0.7)
ax.set_title(title, loc='left')
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
clean_axis(ax, grid=True)
ax.xaxis.grid(True, alpha=0.08, color=G300)
save(fig, save_path)
```
---
## Template 7: Heatmap
**Scenario**: Matrix data, correlations, time × category.
```python
def heatmap(data, row_labels, col_labels, title,
cmap_color=C_BLUE, save_path='heatmap.png'):
from matplotlib.colors import LinearSegmentedColormap
cmap = LinearSegmentedColormap.from_list('bc', ['#FFFFFF', cmap_color])
fig, ax = plt.subplots(
figsize=(max(8, len(col_labels)*1.2), max(6, len(row_labels)*0.6)))
arr = np.array(data)
im = ax.imshow(arr, cmap=cmap, aspect='auto')
vmax = arr.max()
for i in range(len(row_labels)):
for j in range(len(col_labels)):
val = arr[i][j]
color = 'white' if val > vmax * 0.6 else G700
ax.text(j, i, f'{val:.1f}', ha='center', va='center',
fontsize=9, color=color)
ax.set_xticks(range(len(col_labels)))
ax.set_yticks(range(len(row_labels)))
ax.set_xticklabels(col_labels)
ax.set_yticklabels(row_labels)
ax.set_title(title, loc='left', pad=16)
cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.08, shrink=0.8)
cbar.outline.set_visible(False)
cbar.ax.tick_params(labelsize=8) # colorbar ticks should not be too large
for spine in ax.spines.values():
spine.set_visible(True)
spine.set_color(G200)
save(fig, save_path)
```
---
## Template 8: KPI Metric Cards
**Scenario**: Dashboard top key number display.
```python
def kpi_cards(metrics, save_path='kpi.png'):
"""
metrics: [{'label': '总收入', 'value': '12.8M',
'change': '+23%', 'positive': True}, ...]
"""
from matplotlib.patches import FancyBboxPatch
n = len(metrics)
fig, axes = plt.subplots(1, n, figsize=(3.8*n, 2.8))
if n == 1: axes = [axes]
for ax, m in zip(axes, metrics):
ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
bg = FancyBboxPatch((0.05, 0.05), 0.9, 0.9,
boxstyle='round,pad=0.05', facecolor=G50, edgecolor=G200, linewidth=0.8)
ax.add_patch(bg)
ax.text(0.5, 0.75, m['label'], ha='center', va='center',
fontsize=10, color=G500)
ax.text(0.5, 0.45, m['value'], ha='center', va='center',
fontsize=24, fontweight='bold', color=G900)
if 'change' in m:
is_pos = m.get('positive', True)
ax.text(0.5, 0.18,
f'{"↑" if is_pos else "↓"} {m["change"]}',
ha='center', va='center', fontsize=11,
color=POS if is_pos else NEG, fontweight='bold')
save(fig, save_path)
```
---
## Template 9: Radar Chart
**Scenario**: Multi-dimensional capability comparison (max 8 dimensions, max 3 groups).
```python
def radar(categories, datasets, series_names, title,
colors=None, save_path='radar.png'):
if colors is None:
colors = COOL[:len(datasets)]
N = len(categories)
angles = np.linspace(0, 2*np.pi, N, endpoint=False).tolist()
angles += angles[:1] # Close the polygon
fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
ax.set_theta_offset(np.pi / 2)
ax.set_theta_direction(-1)
ax.set_xticks(angles[:-1])
ax.set_xticklabels(categories, fontsize=10)
ax.yaxis.set_visible(False)
# Grid beautification
ax.spines['polar'].set_color(G200)
ax.grid(color=G200, linewidth=0.5, alpha=0.5)
for data, name, color in zip(datasets, series_names, colors):
vals = data + data[:1] # Close the polygon
ax.plot(angles, vals, color=color, linewidth=2, label=name)
ax.fill(angles, vals, color=color, alpha=0.08)
ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1))
ax.set_title(title, pad=30, fontsize=16, fontweight='bold')
save(fig, save_path)
```
---
## Template 10: Waterfall Chart
**Scenario**: Show incremental changes from start to end value.
```python
def waterfall(labels, values, title, save_path='waterfall.png'):
"""labels and values correspond, positive=increase negative=decrease, last item auto-treated as total"""
fig, ax = plt.subplots(figsize=(12, 6))
cumulative = [0]
for v in values[:-1]:
cumulative.append(cumulative[-1] + v)
bar_colors = []
for i, v in enumerate(values):
if i == len(values) - 1:
bar_colors.append(C_BLUE) # Total
elif v >= 0:
bar_colors.append(POS) # Increase
else:
bar_colors.append(NEG) # Decrease
bottoms = []
for i, v in enumerate(values):
if i == len(values) - 1:
bottoms.append(0) # Total starts from 0
elif v >= 0:
bottoms.append(cumulative[i])
else:
bottoms.append(cumulative[i] + v)
bars = ax.bar(labels, [abs(v) for v in values], bottom=bottoms,
color=bar_colors, width=0.6, edgecolor='white', linewidth=0.5, zorder=3)
# Connecting lines
for i in range(len(values) - 2):
y = cumulative[i+1]
ax.plot([i+0.3, i+0.7], [y, y], color=G300, linewidth=0.8, zorder=2)
# Value labels
for i, (bar, val) in enumerate(zip(bars, values)):
y_pos = bar.get_y() + bar.get_height() + max(abs(v) for v in values)*0.01
prefix = '+' if val > 0 and i < len(values)-1 else ''
ax.text(bar.get_x()+bar.get_width()/2, y_pos,
f'{prefix}{val:,.0f}', ha='center', va='bottom',
fontsize=9, color=G700)
ax.set_title(title, loc='left')
clean_axis(ax)
save(fig, save_path)
```
---
## Template 11: Multi-Subplot Dashboard (GridSpec Precision Layout)
**Scenario**: Combine multiple subplots into a dashboard. ⚠️ Max 4 subplots per canvas, split if exceeded.
**Core Principle**: Use `GridSpec` for precise control of each subplot's position and spacing, don't rely on `tight_layout()`.
```python
import matplotlib.gridspec as gridspec
def dashboard(data_dict, title, save_path='dashboard.png'):
"""
2x2 dashboard layout example.
data_dict contains data needed for each subplot.
"""
# ⚠️ Use constrained_layout instead of tight_layout
fig = plt.figure(figsize=(16, 12), constrained_layout=True)
fig.suptitle(title, fontsize=20, fontweight='bold', y=0.98)
# GridSpec: precise spacing control
gs = gridspec.GridSpec(2, 2, figure=fig,
wspace=0.35, # Column spacing (at least 0.3)
hspace=0.35, # Row spacing (at least 0.3)
left=0.08, right=0.92,
top=0.92, bottom=0.08)
# ─── Top-left: bar chart ───
ax1 = fig.add_subplot(gs[0, 0])
ax1.set_title('Quarterly Revenue', loc='left', fontsize=13, fontweight='bold')
# ... binddata ...
clean_axis(ax1)
# ─── Top-right: line chart ───
ax2 = fig.add_subplot(gs[0, 1])
ax2.set_title('Monthly Trend', loc='left', fontsize=13, fontweight='bold')
# ... bind data ...
clean_axis(ax2)
# ─── Bottom-left: pie chart ───
ax3 = fig.add_subplot(gs[1, 0])
ax3.set_title('Category Share', loc='left', fontsize=13, fontweight='bold')
# ... bind data ...
# ─── Bottom-right: scatter plot ───
ax4 = fig.add_subplot(gs[1, 1])
ax4.set_title('Conversion Analysis', loc='left', fontsize=13, fontweight='bold')
# ... bind data ...
clean_axis(ax4)
fig.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close(fig)
```
### Dashboard Layout Golden Rules
| Rule | Description |
|------|-------------|
| **Max 4 subplots** | Split into multiple charts if exceeded |
| **Use `constrained_layout=True`** | Smarter than `tight_layout()`, auto-avoids labels |
| **`wspace/hspace ≥ 0.3`** | Subplot spacing too small causes overlap |
| **Independent title per subplot** | Use `ax.set_title()` instead of `fig.suptitle()` |
| **Consistent font sizes** | Subtitle 13px, axis labels 10px, data labels 9px |
| **Colorbar in separate column** | If a subplot needs colorbar, allocate `gs[0, 2]` separately |
| **Don't mix legend and direct labels** | Use either all legends or all direct labels in a dashboard |
### Safe Layout with Colorbar
```python
# When a subplot needs a colorbar, use 3-column layout, rightmost column for colorbar
gs = gridspec.GridSpec(2, 3, figure=fig,
width_ratios=[1, 1, 0.05], # Third column very narrow, dedicated to colorbar
wspace=0.4, hspace=0.35)
ax_heat = fig.add_subplot(gs[0, 1])
im = ax_heat.imshow(data, cmap='Blues')
# Colorbar placed in its own subplot position, won't obscure any content
cbar_ax = fig.add_subplot(gs[0, 2])
fig.colorbar(im, cax=cbar_ax)
cbar_ax.set_ylabel('Value', fontsize=10)
```
### Split Strategy for More Than 4 Subplots
```python
# ❌ Wrong: 8 subplots crammed into one canvas
fig, axes = plt.subplots(2, 4, figsize=(20, 10)) # Everything becomes unreadable
# ✅ Correct: split into 2 figures, 4 subplots each
# Figure 1: overview metrics
fig1 = plt.figure(figsize=(16, 12), constrained_layout=True)
# ... 4 subplots ...
fig1.savefig('dashboard_overview.png', dpi=200)
# Figure 2: detailed analysis
fig2 = plt.figure(figsize=(16, 12), constrained_layout=True)
# ... 4 subplots ...
fig2.savefig('dashboard_detail.png', dpi=200)
```
+797
View File
@@ -0,0 +1,797 @@
# Mermaid Template Library
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
Mermaid is the best "text-as-diagram" solution — write structural diagrams with Markdown-like syntax, zero design skills needed.
Best for: flowcharts, sequence diagrams, architecture diagrams, Gantt charts, class diagrams, ER diagrams, mind maps, state diagrams, pie charts, Git branch graphs.
**Core advantages**: text is version-controllable, minimal maintenance cost, high rendering consistency, CJK support.
## ⚠️ Flowchart Quality Rules (Highest Priority)
### Font Size Control
Mermaid font sizes are controlled via `themeVariables` and CSS:
- `fontSize`: Global font size, recommended `14px`-`16px`, **no less than 12px**
- Node text is controlled via `fontSize` or `%%{init:}%%` directive
- Annotations/footnotes use subgraph titles or separate nodes, font size no less than `11px`
### Connectors & Spacing
```javascript
flowchart: {
padding: 32, // Node padding (CJK needs more space)
nodeSpacing: 80, // Horizontal spacing between nodes (default 50 too tight, 60 still not enough)
rankSpacing: 80, // Vertical spacing between ranks
curve: 'basis', // Connection curve style, consistent across the chart
}
```
**Connector style must be consistent throughout the chart**: do not mix straight, curved, and polylines in the same diagram. Mermaid controls this globally via the `curve` parameter.
### ⚠️ Mermaid Flowchart Hard Constraints (MANDATORY)
The following constraints are enforced **when generating Mermaid flowchart code**, not as post-checks:
1. **Node text must be wrapped in quotes**: `A["用户登录"]` ✅ / `A[用户登录]` ❌ — quotes prevent CJK special characters from causing parse errors
2. **Max 10 CJK characters per line in node text**: exceed → use `<br>` to break → `A["用户身份<br>验证模块"]`
3. **Max 5 nodes per subgraph**: exceed → split into multiple subgraphs or switch to CSS approach
4. **Max 10 total nodes**: exceed → switch to CSS flowchart template in `references/playwright-css.md`
5. **Max 6 CJK characters in connector labels**: `-->|验证通过|` ✅ / `-->|用户身份验证通过后跳转|` ❌
6. **Config params must use enlarged values**: `padding: 32, nodeSpacing: 80, rankSpacing: 80`
## Rendering Methods
### Method 1: Playwright + HTML (Recommended, export PNG/SVG/PDF)
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #FFFFFF;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
display: flex;
justify-content: center;
padding: 48px;
}
#diagram { width: fit-content; min-width: 800px; }
</style>
</head>
<body>
<div id="diagram">
<pre class="mermaid">
<!-- Mermaid code goes here -->
</pre>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'base',
themeVariables: {
// See "Theme configuration" below
},
flowchart: {
curve: 'basis',
padding: 32, // Node padding (CJK chars 50% wider than Latin, need more space)
nodeSpacing: 80, // Horizontal spacing (prevents CJK node overlap)
rankSpacing: 80, // Vertical spacing between ranks (prevents overlap between levels)
htmlLabels: true, // Enable HTML label rendering, supports line breaks
wrappingWidth: 160, // Max text width before auto-wrap (160 wraps earlier than 200, prevents overly wide nodes)
},
sequence: { mirrorActors: false, messageAlign: 'center' },
gantt: { titleTopMargin: 25, barHeight: 24, barGap: 6 },
});
</script>
</body>
</html>
```
**Python screenshot script**:
```python
import asyncio
from playwright.async_api import async_playwright
async def mermaid_to_png(html_path, png_path, width=1400, scale=2):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(
viewport={'width': width, 'height': 800},
device_scale_factor=scale
)
await page.goto(f'file://{html_path}', wait_until='load', timeout=30000)
# Wait for Mermaid SVG to render
await page.wait_for_selector('#diagram svg', timeout=15000)
await page.wait_for_timeout(1000)
# ⚠️ Read SVG's ACTUAL rendered size (not CSS box model!)
# Mermaid SVGs often overflow their CSS container — getBBox/clientRect
# returns the true size, while CSS bounding_box() returns the clipped box.
svg_size = await page.evaluate('''() => {
const svg = document.querySelector('#diagram svg');
if (!svg) return null;
const r = svg.getBoundingClientRect();
return { width: r.width, height: r.height };
}''')
el = page.locator('#diagram')
css_bbox = await el.bounding_box()
svg_w = svg_size['width'] if svg_size else width
svg_h = svg_size['height'] if svg_size else 800
css_w = css_bbox['width'] if css_bbox else width
css_h = css_bbox['height'] if css_bbox else 800
# Use the LARGER of CSS box and SVG actual size
fit_w = max(width, int(max(svg_w, css_w) + 200))
fit_h = int(max(svg_h, css_h) + 200)
await page.set_viewport_size({'width': fit_w, 'height': fit_h})
await page.wait_for_timeout(500)
await el.screenshot(path=png_path)
await browser.close()
import os
print(f'✅ {png_path} ({os.path.getsize(png_path)/1024:.0f}KB)')
# asyncio.run(mermaid_to_png('./output/diagram.html', './output/diagram.png'))
```
> **⚠️ CRITICAL: CSS `bounding_box()` vs SVG actual size**
>
> Mermaid generates SVGs that can be wider/taller than their CSS container. `bounding_box()` (Playwright) and `getBoundingClientRect()` on the container return **CSS box model size**, which may be smaller than the SVG's viewBox.
>
> **Always read the SVG element's own `getBoundingClientRect()`** via `page.evaluate()` and use `max(css_size, svg_size)` for viewport dimensions. This is the root cause of the "right side clipped" bug.
>
> Also: `wait_until='load'` is preferred over `'networkidle'` because Mermaid initializes on DOM load. `'networkidle'` can timeout if CDN is slow.
### Method 2: Mermaid CLI (mmdc, command line)
```bash
# Installation
npm install -g @mermaid-js/mermaid-cli
# Usage: .mmd file → PNG/SVG/PDF
mmdc -i diagram.mmd -o diagram.png -w 1200 -b transparent
mmdc -i diagram.mmd -o diagram.svg
mmdc -i diagram.mmd -o diagram.pdf
# Specify theme configuration
mmdc -i diagram.mmd -o diagram.png --configFile mermaid-config.json
```
`mermaid-config.json` example:
```json
{
"theme": "base",
"themeVariables": {
"primaryColor": "#EFF6FF",
"primaryBorderColor": "#3B82F6",
"primaryTextColor": "#1E293B",
"lineColor": "#94A3B8",
"secondaryColor": "#F0FDF4",
"tertiaryColor": "#FFF7ED"
}
}
```
### Method 3: Online Preview
Paste code at [mermaid.live](https://mermaid.live) for instant preview and export.
---
## Theme Configuration (Design System Integration)
Mermaid uses `theme: 'base'` + `themeVariables` for fully custom colors.
The following themes align with the charts skill mood color system:
### Business Professional (Default)
```javascript
themeVariables: {
primaryColor: '#EFF6FF', // Node background (very light blue)
primaryBorderColor: '#3B82F6', // Node border (blue)
primaryTextColor: '#1E293B', // Node text (dark gray-blue)
lineColor: '#94A3B8', // Connectors (gray)
secondaryColor: '#F0FDF4', // Secondary nodes (very light green)
secondaryBorderColor: '#10B981',
secondaryTextColor: '#1E293B',
tertiaryColor: '#FFF7ED', // Tertiary nodes (very light amber)
tertiaryBorderColor: '#F59E0B',
tertiaryTextColor: '#1E293B',
noteBkgColor: '#F8FAFC', // Note background
noteTextColor: '#6B7280',
noteBorderColor: '#E2E8F0',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, PingFang SC, SimHei, sans-serif',
}
```
### Tech Dark
```javascript
themeVariables: {
primaryColor: '#1E293B',
primaryBorderColor: '#3B82F6',
primaryTextColor: '#F1F5F9',
lineColor: '#475569',
secondaryColor: '#0F2E1F',
secondaryBorderColor: '#10B981',
secondaryTextColor: '#F1F5F9',
tertiaryColor: '#1A1625',
tertiaryBorderColor: '#8B5CF6',
tertiaryTextColor: '#F1F5F9',
noteBkgColor: '#0F172A',
noteTextColor: '#94A3B8',
noteBorderColor: '#334155',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, PingFang SC, SimHei, sans-serif',
background: '#0F172A',
}
```
---
## Template 1: Flowchart
The most common chart type. Supports directions: `TB` (top→bottom), `LR` (left→right), `BT`, `RL`.
```mermaid
flowchart TB
A[开始] --> B{条件判断}
B -->|是| C[执行操作A]
B -->|否| D[执行操作B]
C --> E[结束]
D --> E
style A fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px,color:#1E293B
style B fill:#FFF7ED,stroke:#F59E0B,stroke-width:2px,color:#1E293B
style C fill:#F0FDF4,stroke:#10B981,stroke-width:2px,color:#1E293B
style D fill:#F0FDF4,stroke:#10B981,stroke-width:2px,color:#1E293B
style E fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px,color:#1E293B
```
### Node Shape Quick Reference
| Syntax | Shape | Use For |
|------|------|--------|
| `A[text]` | Rectangle | Steps/Actions |
| `A(text)` | Rounded rect | General nodes |
| `A([text])` | Stadium | Start/End |
| `A{text}` | Diamond | Decision |
| `A{{text}}` | Hexagon | Preparation |
| `A[/text/]` | Parallelogram | Input/Output |
| `A((text))` | Circle | Connector |
| `A>text]` | Flag | Event/Signal |
### Subgraphs (Grouping)
```mermaid
flowchart LR
subgraph 前端["🖥️ 前端"]
A[React App] --> B[API 调用]
end
subgraph 后端["⚙️ 后端"]
C[FastAPI] --> D[(PostgreSQL)]
end
B --> C
style 前端 fill:#EFF6FF,stroke:#3B82F6,stroke-width:1px
style 后端 fill:#F0FDF4,stroke:#10B981,stroke-width:1px
```
---
## Template 2: Sequence Diagram
Shows interaction sequence between systems/actors.
```mermaid
sequenceDiagram
actor 用户
participant 前端 as 🖥️ 前端
participant API as ⚙️ API 网关
participant DB as 🗄️ 数据库
用户->>前端: 点击登录
前端->>API: POST /auth/login
API->>DB: 查询用户
DB-->>API: 用户信息
alt 验证成功
API-->>前端: 200 + JWT Token
前端-->>用户: 跳转首页
else 验证失败
API-->>前端: 401 未授权
前端-->>用户: 显示错误提示
end
Note over 前端,API: Token 有效期 24 小时
```
### Arrow Types
| Syntax | Meaning |
|------|------|
| `->>` | Solid arrow (synchronous call) |
| `-->>` | Dashed arrow (return/response) |
| `-x` | Solid with x (failure/rejection) |
| `-)` | Async message |
---
## Template 3: Architecture Diagram (C4 Style via Subgraphs)
```mermaid
flowchart TB
subgraph 用户层["👤 用户层"]
U1[Web 浏览器]
U2[移动 App]
end
subgraph 接入层["🌐 接入层"]
GW[API Gateway<br><small>Nginx + Rate Limit</small>]
LB[负载均衡<br><small>Round Robin</small>]
end
subgraph 服务层["⚙️ 微服务"]
S1[用户服务<br><small>FastAPI</small>]
S2[内容服务<br><small>FastAPI</small>]
S3[推荐服务<br><small>PyTorch</small>]
end
subgraph 数据层["🗄️ 数据层"]
DB[(PostgreSQL)]
RD[(Redis Cache)]
ES[(Elasticsearch)]
end
U1 & U2 --> GW
GW --> LB
LB --> S1 & S2 & S3
S1 --> DB & RD
S2 --> DB & ES
S3 --> RD & ES
style 用户层 fill:#EFF6FF,stroke:#3B82F6,stroke-width:1.5px
style 接入层 fill:#FFF7ED,stroke:#F59E0B,stroke-width:1.5px
style 服务层 fill:#F0FDF4,stroke:#10B981,stroke-width:1.5px
style 数据层 fill:#F5F3FF,stroke:#8B5CF6,stroke-width:1.5px
```
---
## Template 4: Gantt Chart
```mermaid
gantt
title 项目里程碑计划
dateFormat YYYY-MM-DD
axisFormat %m/%d
section 需求阶段
需求调研 :done, req1, 2024-01-01, 14d
需求评审 :done, req2, after req1, 3d
section 开发阶段
后端开发 :active, dev1, after req2, 21d
前端开发 :active, dev2, after req2, 18d
联调测试 : dev3, after dev1, 7d
section 上线阶段
灰度发布 : rel1, after dev3, 3d
全量上线 :milestone, rel2, after rel1, 0d
```
---
## Template 5: Class Diagram
```mermaid
classDiagram
class User {
+String name
+String email
+login()
+logout()
}
class Order {
+int id
+Date created_at
+float total
+submit()
+cancel()
}
class Product {
+String name
+float price
+int stock
}
User "1" --> "*" Order : 下单
Order "*" --> "*" Product : 包含
```
---
## Template 6: ER Diagram
```mermaid
erDiagram
USER {
int id PK
string name
string email UK
datetime created_at
}
ORDER {
int id PK
int user_id FK
float total
string status
datetime created_at
}
ORDER_ITEM {
int id PK
int order_id FK
int product_id FK
int quantity
float price
}
PRODUCT {
int id PK
string name
float price
int stock
}
USER ||--o{ ORDER : "下单"
ORDER ||--|{ ORDER_ITEM : "包含"
PRODUCT ||--o{ ORDER_ITEM : "被购买"
```
---
## Template 7: Mind Map
> ⚠️ Mermaid mindmap has limited layout capabilities. **For high-quality mind maps**, prefer `references/mindmap-css.md`.
> The following approach is for **quick drafts** or embedding in Markdown documents, with CSS injection to optimize visual quality.
### Optimized HTML Shell (Important! Use this, not the default template)
Mermaid mindmap doesn't support `style`/`classDef`, but you can greatly improve results with **CSS overriding SVG styles** + **themeVariables**:
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #FFFFFF;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
display: flex;
justify-content: center;
padding: 48px;
}
#diagram { min-width: 900px; }
/* ─── CSS injection to optimize Mermaid mindmap rendering ─── */
/*
* Actual SVG class names in Mermaid v11 mindmap:
* - .section-root = root node
* - .section-0 ~ .section-N = first-level branches (in order)
* - .section-edge-0 ~ .section-edge-N = connectors for corresponding branches
* - .node-bkg = node background path
* - .node-line- = node bottom decoration line
* - .nodeLabel = text label
* - .edge = connector
* - .edge-depth-1/5 = connector depth level
*/
/* 1. All connectors: rounded, soft */
.edge { stroke-width: 2px !important; stroke-linecap: round !important; }
/* 2. Remove node bottom decoration line (ugly by default) */
.node-line- { stroke: transparent !important; }
/* 3. Root node: deep blue circle + shadow */
.section-root circle,
.section-root ellipse {
fill: #1E40AF !important;
stroke: #1E3A8A !important;
stroke-width: 3px !important;
filter: drop-shadow(0 4px 12px rgba(30,64,175,0.35));
}
.section-root .nodeLabel { color: #FFFFFF !important; font-size: 17px !important; font-weight: 700 !important; }
/* 4. First-level branches colored in order (supports up to 8-color cycle) */
/* Blue */
.section-0 .node-bkg { fill: #DBEAFE !important; stroke: #3B82F6 !important; stroke-width: 2px !important; }
.section-0 .nodeLabel { color: #1E40AF !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-0 { stroke: #93C5FD !important; }
/* Green */
.section-1 .node-bkg { fill: #D1FAE5 !important; stroke: #10B981 !important; stroke-width: 2px !important; }
.section-1 .nodeLabel { color: #065F46 !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-1 { stroke: #6EE7B7 !important; }
/* Amber */
.section-2 .node-bkg { fill: #FEF3C7 !important; stroke: #F59E0B !important; stroke-width: 2px !important; }
.section-2 .nodeLabel { color: #92400E !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-2 { stroke: #FCD34D !important; }
/* Purple */
.section-3 .node-bkg { fill: #EDE9FE !important; stroke: #8B5CF6 !important; stroke-width: 2px !important; }
.section-3 .nodeLabel { color: #5B21B6 !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-3 { stroke: #C4B5FD !important; }
/* Red */
.section-4 .node-bkg { fill: #FEE2E2 !important; stroke: #EF4444 !important; stroke-width: 2px !important; }
.section-4 .nodeLabel { color: #991B1B !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-4 { stroke: #FCA5A5 !important; }
/* Cyan */
.section-5 .node-bkg { fill: #CFFAFE !important; stroke: #06B6D4 !important; stroke-width: 2px !important; }
.section-5 .nodeLabel { color: #155E75 !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-5 { stroke: #67E8F9 !important; }
/* Pink */
.section-6 .node-bkg { fill: #FCE7F3 !important; stroke: #EC4899 !important; stroke-width: 2px !important; }
.section-6 .nodeLabel { color: #9D174D !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-6 { stroke: #F9A8D4 !important; }
/* Gray-green */
.section-7 .node-bkg { fill: #D1FAE5 !important; stroke: #059669 !important; stroke-width: 2px !important; }
.section-7 .nodeLabel { color: #064E3B !important; font-weight: 600 !important; font-size: 14px !important; }
.section-edge-7 { stroke: #6EE7B7 !important; }
/* 5. Deeper connectors are lighter */
.edge-depth-5 { stroke-width: 1.5px !important; opacity: 0.6; }
/* 6. Light gray background */
body { background: #FAFBFE; }
</style>
</head>
<body>
<div id="diagram">
<pre class="mermaid">
mindmap
root((你的主题))
一级分支1
二级内容A
二级内容B
一级分支2
二级内容C
</pre>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'base',
themeVariables: {
primaryColor: '#EFF6FF',
primaryBorderColor: '#3B82F6',
primaryTextColor: '#1E293B',
lineColor: '#CBD5E1',
fontSize: '13px',
fontFamily: '-apple-system, BlinkMacSystemFont, PingFang SC, SimHei, sans-serif',
},
mindmap: {
padding: 20,
useMaxWidth: false,
}
});
</script>
</body>
</html>
```
### Auto-Upgrade Rules (Important!)
> ⚠️ **Never trim user content just to fit Mermaid!**
> Content comes first; tools serve content, not the other way around.
When content complexity exceeds Mermaid mindmap's comfort zone, **auto-switch to CSS approach** (`references/mindmap-css.md`):
| Trigger Condition (any one met) | Action |
|----------------------|------|
| More than 7 L1 branches | → Switch to CSS |
| Any branch has >8 child nodes | → Switch to CSS |
| Nesting exceeds 3 levels | → Switch to CSS |
| Single node text >15 chars | → Switch to CSS |
| Total nodes >40 | → Switch to CSS |
**When none of the above triggers**, Mermaid mindmap is adequate. The following suggestions help optimize rendering (recommendations, not hard limits):
| Suggestion | Notes |
|------|------|
| Keep node text concise | Use spaces to segment long text, avoid punctuation |
| Use emoji prefixes per branch | Higher visual distinctiveness |
| Use CSS injection for coloring | Use the optimized HTML shell above |
### Example (Optimized)
```mermaid
mindmap
root((AI 内容运营))
选题策划
热点扫描
竞品分析
用户调研
内容生产
长文
短文
视频
渠道分发
微信生态
小红书
B站
数据运营
数据分析
评论互动
持续优化
```
### Known Limitations of Mermaid Mindmap
- ❌ No `style` / `classDef` support for direct node coloring (CSS injection of SVG styles only)
- ❌ Line thickness/curvature cannot be controlled from Mermaid syntax (CSS override `.mindmap-edge`)
- ❌ Node spacing calculated by algorithm, cannot be manually specified
- ❌ Long CJK text easily overlaps with connectors (strict character count control needed)
- ⚠️ CSS injection depends on Mermaid internal class naming, may break on version upgrades
**Conclusion**: For quick drafts use Mermaid + the CSS-optimized shell above; for production output use CSS mind map → `references/mindmap-css.md`
---
## Template 8: State Diagram
```mermaid
stateDiagram-v2
[*] --> 草稿
草稿 --> 审核中 : 提交审核
审核中 --> 已发布 : 审核通过
审核中 --> 草稿 : 退回修改
已发布 --> 已下架 : 违规/过期
已下架 --> 草稿 : 重新编辑
已发布 --> [*] : 永久删除
```
---
## Template 9: Git Branch Graph
```mermaid
gitGraph
commit id: "init"
branch develop
checkout develop
commit id: "feat: 用户模块"
commit id: "feat: 订单模块"
branch feature/payment
checkout feature/payment
commit id: "feat: 支付接入"
commit id: "fix: 金额精度"
checkout develop
merge feature/payment id: "merge: 支付"
checkout main
merge develop id: "release: v1.0"
commit id: "hotfix: 安全补丁" type: REVERSE
```
---
## Styling Tips
### Single Node Style
```mermaid
style 节点ID fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px,color:#1E293B
```
### Batch Styles (classDef)
```mermaid
flowchart LR
classDef blue fill:#EFF6FF,stroke:#3B82F6,stroke-width:1.5px,color:#1E293B
classDef green fill:#F0FDF4,stroke:#10B981,stroke-width:1.5px,color:#1E293B
classDef amber fill:#FFF7ED,stroke:#F59E0B,stroke-width:1.5px,color:#1E293B
A[步骤1]:::blue --> B{判断}:::amber
B -->|是| C[结果A]:::green
B -->|否| D[结果B]:::green
```
### Connector Styles
```mermaid
%% Style for the N-th connector (0-indexed)
linkStyle 0 stroke:#3B82F6,stroke-width:2px
linkStyle 1 stroke:#10B981,stroke-width:2px,stroke-dasharray: 5 5
```
---
## Mermaid vs Other Approaches
| Capability | Mermaid | Playwright+CSS | draw.io |
|------|---------|---------------|---------|
| Learning curve | ✅ Very low (Markdown-like) | Medium (HTML/CSS) | ✅ Very low (drag&drop) |
| Version control friendly | ✅ Plain text | ✅ Plain text | ❌ XML binary |
| Flowcharts | ✅ Built-in | ⚠️ Manual layout | ✅ Drag&drop |
| Sequence diagrams | ✅ Built-in | ❌ Very complex | ✅ Templates |
| Gantt charts | ✅ Built-in | ❌ Build from scratch | ⚠️ Limited |
| Class/ER diagrams | ✅ Built-in | ❌ Not suited | ✅ Templates |
| Visual freedom | ⚠️ Limited | ✅ Full freedom | ✅ Free |
| PNG export | ✅ mmdc/Playwright | ✅ Playwright | ✅ Built-in |
| CJK support | ✅ Native | ✅ Font config | ✅ Native |
| Auto layout | ✅ Automatic | ❌ Manual | ⚠️ Semi-auto |
**Principle: Use Mermaid for structural/relationship diagrams, Playwright+CSS for creative design diagrams.**
---
## FAQ
### Q: CJK node names cause layout issues?
Ensure `fontFamily` includes a CJK font:
```javascript
themeVariables: {
fontFamily: '-apple-system, PingFang SC, SimHei, sans-serif'
}
```
### Q: How to control node spacing?
Mermaid auto-layouts; spacing is adjusted via config:
```javascript
flowchart: { padding: 16, nodeSpacing: 50, rankSpacing: 60 }
```
### Q: Chart too large?
- Split into subgraphs
- Change direction (`TB` too tall → switch to `LR`)
- Use `mmdc -w 1600` to increase canvas width
### Q: How to add line breaks in nodes?
Use `<br>` tags:
```mermaid
A[第一行<br>第二行<br><small>小字注释</small>]
```
### Q: Flowchart node text truncated or overlapping?
**Common causes and fixes**:
1. **Insufficient node padding**: ensure `flowchart.padding` is at least `24` (CJK chars are ~50% wider than Latin)
2. **Text too long**: use `<br>` for manual line breaks, or shorten text
3. **Canvas too narrow**: use `width: fit-content` on `#diagram` container (🚫 NEVER use `max-width` — Mermaid SVG width is unpredictable)
4. **Node spacing too small**: increase `nodeSpacing` and `rankSpacing` (recommended 60+)
5. **When using classDef**: ensure `font-size` isn't too large, 12-14px is ideal
**Correct approach for long-text nodes**:
```mermaid
flowchart LR
A["这是一段比较长的<br>需要换行的文字"]
B["用引号包裹节点文字<br>可以使用 HTML 标签"]
```
**Key configuration**:
```javascript
mermaid.initialize({
flowchart: {
padding: 32,
nodeSpacing: 80,
rankSpacing: 80,
htmlLabels: true,
wrappingWidth: 160,
}
});
```
+911
View File
@@ -0,0 +1,911 @@
# CSS Mind Map Rendering Engine
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
**Core principle: Content-driven, not template-driven. First analyze the content structure, then decide on the layout, and finally render.**
---
## Step 1: Content Analysis
After receiving a mind map requirement, **don't write any HTML/CSS yet**. First parse the content into a tree structure, then calculate key metrics.
### 1.1 Build Tree JSON
```
Input: "How to work efficiently from home", branches include "Environment Setup", "Time Management", "Tool Selection"...
↓
Output:
{
"root": "在家如何高效办公",
"branches": [
{
"label": "环境准备",
"children": ["独立工作区", "降噪耳机", "人体工学椅"]
},
{
"label": "时间管理",
"children": [
{ "label": "番茄工作法", "children": ["25分钟专注", "5分钟休息"] },
"日程规划",
"避免多任务"
]
},
...
]
}
```
### 1.2 Key Metrics
| Metric | Calculation Method | What It Affects |
|------|---------|---------|
| `branchCount` | Number of first-level branches | Choose single-sided/dual-sided expansion |
| `maxDepth` | Maximum nesting depth | Canvas width, whether sub-branch is needed |
| `maxChildren` | Maximum children per node | Vertical height of that branch |
| `totalNodes` | Total number of all nodes | Overall canvas size |
| `maxTextLen` | Longest text character count | Node width, whether line breaks are needed |
| `branchWeights[]` | Total descendants per first-level branch | Left-right balance allocation |
### 1.3 Example Analysis
```
Content: "产品经理核心技能", 6 L1 branches, max depth 3, total 35 nodes, max text length 12 chars
→ branchCount=6, maxDepth=3, totalNodes=35, maxTextLen=12
→ branchWeights=[8, 5, 7, 4, 6, 5]
```
---
## Step 2: Layout Decision
Based on the metrics from Step 1, choose a layout scheme. The following are reference suggestions, not hard rules—adjust flexibly according to actual content.
### 2.1 Layout Selection Guide
**Core principle: Use simple layouts for less content, dual-sided layouts for more content, avoid one side becoming too long.**
```
Few branches (roughly ≤4), simple content?
→ Style A: Right-expanding tree (single-sided, compact)
Many branches (roughly ≥5), or single side would be too long?
→ Style B: Left-right expanding tree (dual-sided balanced, most common)
User explicitly requests a specific form?
→ "cards"/"modules" → Style C: Card grid
→ "fishbone"/"root cause analysis" → Style D: Fishbone diagram
```
**Why not use radial layout?** Radial layout (spreading from center outward) almost inevitably causes node overlap when there are more than 3-4 branches, and is extremely difficult to debug. Not recommended.
### 2.2 Canvas Size Calculation
Don't use fixed sizes. Calculate based on content:
```python
def calc_canvas(branch_count, max_depth, total_nodes, max_text_len, layout):
# Width: depends on depth and text length
node_width = max(120, max_text_len * 14) # ~14px per CJK char (including padding)
if layout == 'left-right':
width = node_width * max_depth * 2 + 200 # Dual-sided, 200px in center for root node
else:
width = node_width * max_depth + 200 # Single-sided
# Height: depends on max branch vertical expansion
if layout == 'left-right':
max_side_nodes = total_nodes // 2 + 2 # rough estimate of single-side nodes
else:
max_side_nodes = total_nodes
height = max_side_nodes * 32 + 200 # ~32px per leaf (including gap)
# Lower bounds
width = max(width, 1200)
height = max(height, 600)
return width, height
```
### 2.3 Left-Right Branch Allocation (Style B)
Goal: Achieve similar visual weight on both sides.
```python
def balance_branches(branch_weights):
"""Greedy bin-packing: sort by weight descending, alternate left/right"""
indexed = sorted(enumerate(branch_weights), key=lambda x: -x[1])
left, right = [], []
left_sum, right_sum = 0, 0
for idx, weight in indexed:
if left_sum <= right_sum:
left.append(idx)
left_sum += weight
else:
right.append(idx)
right_sum += weight
return left, right
# e.g.: weights=[8,5,7,4,6,5] → left=[0,3,5] right=[2,4,1] → 17 vs 18
```
### 2.4 Node Styling Decision
**Principle: The deeper the level, the lighter the visual weight.** This way readers can distinguish main branches from details at a glance.
```
Root node → most prominent: dark solid background, large font (~20px)
L1 branches → next prominent: light fill + colored border, medium font (~15px)
L2 nodes → lighter still: paler fill + thin border, medium-small font (~13px)
Leaf nodes → lightest: capsule frame or plain text, small font (~12-13px)
```
**Key: Leaves should not have the same visual weight as first-level branches.** Leaves' padding, gap, and border thickness should all be significantly smaller than their parent. Otherwise the chart will be vertically too long and lack hierarchy.
---
## Step 3: Rendering
Based on the decisions from Step 2, generate HTML + CSS + JS.
### 3.1 Playwright Screenshot (Universal)
```python
import asyncio
from playwright.async_api import async_playwright
async def mindmap_to_png(html_path, png_path, width=1600):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(viewport={'width': width, 'height': 1200}, device_scale_factor=2)
await page.goto(f'file://{html_path}', wait_until='networkidle')
await page.wait_for_timeout(500)
el = page.locator('#mindmap')
bbox = await el.bounding_box()
# First expansion: ensure content is not clipped
expand_w = max(width, int(bbox['width'] + 100))
expand_h = int(bbox['height'] + 100)
await page.set_viewport_size({'width': expand_w, 'height': expand_h})
await page.wait_for_timeout(200)
# Call connector script
await page.evaluate('if(typeof drawAllLines==="function") drawAllLines()')
await page.wait_for_timeout(200)
# Second contraction: measure actual content right edge, trim right-side blank space
trim = await page.evaluate('''() => {
const map = document.getElementById('mindmap');
const nodes = map.querySelectorAll('.root-node,.branch-node,.sub-node,.leaf,.deep-node');
const mapRect = map.getBoundingClientRect();
let maxR = 0, maxB = 0;
nodes.forEach(n => {
const r = n.getBoundingClientRect();
maxR = Math.max(maxR, r.right - mapRect.left);
maxB = Math.max(maxB, r.bottom - mapRect.top);
});
return { contentW: Math.ceil(maxR) + 80, contentH: Math.ceil(maxB) + 80 };
}''')
await page.set_viewport_size({'width': trim['contentW'], 'height': trim['contentH']})
await page.wait_for_timeout(200)
# Redraw connectors (viewport changed so coordinates change)
await page.evaluate('if(typeof drawAllLines==="function") drawAllLines()')
await page.wait_for_timeout(200)
await el.screenshot(path=png_path)
await browser.close()
import os
print(f'✅ {png_path} ({os.path.getsize(png_path)/1024:.0f}KB)')
```
### 3.2 Universal Recursive Connector Script v4-fix (All Styles)
This script automatically handles tree connectors of **any depth** (3, 4, 5 levels... all work). HTML for styles A and B should include this script at the end.
**⚠️ DOM Structure Convention (connector script depends on this structure):**
```
#mindmap
.tree-layout ← flex container (for left-right tree)
.left-side ← left branch area
.branch.c-{color} ← L1 branch (must have color class)
.branch-node ← L1 node
.children ← L2 container
div ← child wrapper
.sub-node ← L2 node
.leaf-group ← L3 container
div / .leaf ← leaf or wrapper with deeper levels
.leaf ← L3 leaf
.deep-group ← L4 container (recursive, same as above)
.deep-node ← L4/L5 node
.center-root
.root-node ← root node
.right-side ← right branches (same structure as .left-side)
```
**Also supports legacy structure (.branches/.right-branches/.left-branches), backward compatible.**
**⚠️ CSS Indentation Rules (connectors depend on child nodes having offset relative to parent, no indentation = broken connectors):**
```css
/* .tree-layout structure (new version) must include these paddings */
.left-side .children, .left-side .leaf-group, .left-side .deep-group {
align-items: flex-end; padding-right: 16px;
}
.right-side .children, .right-side .leaf-group, .right-side .deep-group {
align-items: flex-start; padding-left: 16px;
}
```
**Common causes of missing connectors/layout errors:**
- **⚠️ `.sub-branch` missing `display: flex`** (most critical! Without flex, `flex-direction: row-reverse` doesn't work, left-side leaves won't expand left, instead all pile up on the right)
- **⚠️ Child node containers missing padding-left/padding-right** (without indentation, child nodes align with parent, midX is outside child nodes, connectors break)
- **⚠️ `.lr-tree` / `.tree` should not have `z-index`** (creates stacking context, covers SVG connectors)
- **⚠️ Leaf nodes must NOT stretch to equal width** — each leaf should size to its own text content (`white-space: nowrap` or `width: fit-content`). Never add `width: 100%`, `flex-grow: 1`, or `align-items: stretch` to leaf containers. Leaves with shorter text should be narrower than leaves with longer text.
- Leaf container not named `.leaf-group` (using `.child-list`, `.sub-items`, etc.)
- Deep-level container not named `.deep-group`
- `#mindmap` missing `position: relative`
**Key improvements (v4-fix vs v2):**
- **No transparency**—use solid color blend for fading (`color + '80'` is almost invisible on white background)
- **Recursively process `.children`, `.leaf-group`, `.deep-group`**—no longer limited to 3 levels
- **Vertical line draws complete range**—one line from `min(Y)` to `max(Y)`, instead of drawing per child node
```javascript
/**
* Universal recursive connector script v4-fix
* - Supports any nesting depth (recursively processes .children + .leaf-group + .deep-group)
* - Unified gray-tone connectors (#64748B → #94A3B8 → #A8B4C2), visually clean
* - Direction logic:
* dir='left': startX=parent.left → midX(left-biased) → endX=child.right
* dir='right': startX=parent.right → midX(right-biased) → endX=child.left
*/
function drawAllLines() {
const map = document.getElementById('mindmap');
if (!map) { console.error('❌ #mindmap not found'); return; }
const cRect = map.getBoundingClientRect();
const old = map.querySelector('svg.lines');
if (old) old.remove();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('lines');
svg.setAttribute('width', cRect.width);
svg.setAttribute('height', cRect.height);
svg.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
let lineCount = 0;
function rel(el) {
const r = el.getBoundingClientRect();
return {
cx: r.left - cRect.left + r.width/2,
cy: r.top - cRect.top + r.height/2,
left: r.left - cRect.left,
right: r.right - cRect.left,
};
}
function drawLine(x1, y1, x2, y2, color, width) {
const l = document.createElementNS('http://www.w3.org/2000/svg', 'line');
// Round to nearest pixel to prevent sub-pixel misalignment (visual kinks)
l.setAttribute('x1', Math.round(x1)); l.setAttribute('y1', Math.round(y1));
l.setAttribute('x2', Math.round(x2)); l.setAttribute('y2', Math.round(y2));
l.setAttribute('stroke', color); l.setAttribute('stroke-width', width);
l.setAttribute('stroke-linecap', 'round');
svg.appendChild(l);
lineCount++;
}
// ─── Unified gray connectors (decreasing by depth, visually clean) ───
const lineStyles = [
{ color: '#64748B', width: 2.5 }, // root → L1
{ color: '#94A3B8', width: 2 }, // L1 → L2
{ color: '#A8B4C2', width: 1.5 }, // L2 → L3
{ color: '#B8C2CC', width: 1.2 }, // L3 → L4
{ color: '#CBD5E1', width: 1 }, // L4 → L5
];
function getLineStyle(branchColor, depth) {
const s = lineStyles[Math.min(depth, lineStyles.length - 1)];
return { color: s.color, width: s.width };
}
// ─── Connector direction ───
// dir='left': parent.left → midX → child.right (each child gets its own polyline)
// dir='right': parent.right → midX → child.left
//
// [Principle] midX is the X coordinate of the vertical line; it must be in the gap between parent and children.
// Use a fraction of the parent-to-nearest-child distance as offset, so:
// - Large node spacing → large offset, lines spread out
// - Small node spacing → small offset, lines compact without crossing text
// All children in the same connect() call share one midX, ensuring vertical line alignment.
function connect(parentEl, childEls, color, width, dir) {
if (!childEls.length) return;
const p = rel(parentEl);
const startX = dir === 'left' ? p.left : p.right;
const startY = p.cy;
// Special case: single child — draw one straight horizontal line, no vertical spine
if (childEls.length === 1) {
const c = rel(childEls[0]);
const endX = dir === 'left' ? c.right : c.left;
const midY = Math.round((startY + c.cy) / 2);
drawLine(startX, midY, endX, midY, color, width);
return;
}
// Find the closest child edge to calculate available space
let closestEdge;
if (dir === 'left') {
closestEdge = Math.max(...childEls.map(ch => rel(ch).right));
} else {
closestEdge = Math.min(...childEls.map(ch => rel(ch).left));
}
// Place midX at the midpoint between parent edge and closest child edge,
// but guarantee at least 16px clearance from child nodes
const childClearance = 16;
const midpoint = startX + (closestEdge - startX) / 2;
const midFromChild = dir === 'left' ? closestEdge + childClearance : closestEdge - childClearance;
// Use the position that's further from children (safer)
const midX = dir === 'left'
? Math.min(midpoint, midFromChild)
: Math.max(midpoint, midFromChild);
drawLine(startX, startY, midX, startY, color, width);
// Draw ONE continuous vertical line spanning from parent to the last child
const allCYs = childEls.map(ch => rel(ch).cy);
const minY = Math.min(startY, ...allCYs);
const maxY = Math.max(startY, ...allCYs);
drawLine(midX, minY, midX, maxY, color, width);
// Then draw horizontal lines from the vertical spine to each child
childEls.forEach(ch => {
const c = rel(ch);
const endX = dir === 'left' ? c.right : c.left;
const endY = c.cy;
drawLine(midX, endY, endX, endY, color, width);
});
}
// ─── Recursively process subtree (supports .children + .leaf-group + .deep-group) ───
const NODE_SEL = '.branch-node, .sub-node, .leaf, .deep-node';
const CONTAINER_SEL = ':scope > .children, :scope > .leaf-group, :scope > .deep-group';
function processChildren(parentNodeEl, containerEl, branchColor, depth, dir) {
if (!containerEl) return;
const childNodeEls = [];
for (const wrapper of containerEl.children) {
const nodeEl = wrapper.matches?.(NODE_SEL) ? wrapper : wrapper.querySelector(NODE_SEL);
if (nodeEl) childNodeEls.push(nodeEl);
}
if (!childNodeEls.length) return;
const style = getLineStyle(branchColor, depth);
connect(parentNodeEl, childNodeEls, style.color, style.width, dir);
for (const wrapper of containerEl.children) {
const nodeEl = wrapper.matches?.(NODE_SEL) ? wrapper : wrapper.querySelector(NODE_SEL);
if (!nodeEl) continue;
wrapper.querySelectorAll(CONTAINER_SEL).forEach(nc =>
processChildren(nodeEl, nc, branchColor, depth + 1, dir)
);
}
}
// ─── Main flow ───
const rootNode = map.querySelector('.root-node');
if (!rootNode) { console.error('❌ .root-node not found'); return; }
const rp = rel(rootNode);
// Left side: collect all L1 branch-nodes, draw polylines with connect() (with vertical spine)
const leftSel = '.left-side > .branch, .left-branches > .left-branch, .left-branches > div';
const leftBranches = map.querySelectorAll(leftSel);
const leftBranchNodes = [];
leftBranches.forEach(branch => {
const bNode = branch.querySelector('.branch-node');
if (bNode) leftBranchNodes.push(bNode);
});
if (leftBranchNodes.length) {
connect(rootNode, leftBranchNodes, lineStyles[0].color, lineStyles[0].width, 'left');
}
leftBranches.forEach(branch => {
const bNode = branch.querySelector('.branch-node');
if (!bNode) return;
processChildren(bNode, branch.querySelector(':scope > .children'), null, 1, 'left');
});
// Right side: same as above
const rightSel = '.right-side > .branch, .branches > .branch, .right-branches > .right-branch';
const rightBranches = map.querySelectorAll(rightSel);
const rightBranchNodes = [];
rightBranches.forEach(branch => {
const bNode = branch.querySelector('.branch-node');
if (bNode) rightBranchNodes.push(bNode);
});
if (rightBranchNodes.length) {
connect(rootNode, rightBranchNodes, lineStyles[0].color, lineStyles[0].width, 'right');
}
rightBranches.forEach(branch => {
const bNode = branch.querySelector('.branch-node');
if (!bNode) return;
processChildren(bNode, branch.querySelector(':scope > .children'), null, 1, 'right');
});
map.insertBefore(svg, map.firstChild);
console.log(`✅ Drew ${lineCount} lines`);
}
```
**How to call (at end of HTML):**
```html
<script>
/* ← paste the drawAllLines function above */
window.addEventListener('load', () => setTimeout(drawAllLines, 300));
</script>
```
### 3.3 Universal CSS Base (Shared by All Styles)
```css
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #FFFFFF;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
}
#mindmap { padding: 60px; display: inline-block; min-width: 100%; position: relative; }
#mindmap > svg.lines {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 0;
}
/* ─── Root Node ─── */
/* ─── Topic Intent Color System ─── */
/* Model picks ONE intent based on content semantics. Add data-intent="xxx" to #mindmap.
DO NOT manually write hex colors for root node — use intent system only. */
/* Intent → Root Node + Branch Palette mapping */
#mindmap[data-intent="professional"] .root-node { background: linear-gradient(135deg, #1E3A5F, #2D4A6F); box-shadow: 0 4px 12px rgba(30,58,95,0.25); }
#mindmap[data-intent="technical"] .root-node { background: linear-gradient(135deg, #334155, #475569); box-shadow: 0 4px 12px rgba(51,65,85,0.25); }
#mindmap[data-intent="medical"] .root-node { background: linear-gradient(135deg, #0F766E, #0D9488); box-shadow: 0 4px 12px rgba(15,118,110,0.25); }
#mindmap[data-intent="education"] .root-node { background: linear-gradient(135deg, #9A3412, #B45309); box-shadow: 0 4px 12px rgba(154,52,18,0.25); }
#mindmap[data-intent="creative"] .root-node { background: linear-gradient(135deg, #7C3AED, #8B5CF6); box-shadow: 0 4px 12px rgba(124,58,237,0.25); }
#mindmap[data-intent="finance"] .root-node { background: linear-gradient(135deg, #1E3A5F, #1E40AF); box-shadow: 0 4px 12px rgba(30,58,95,0.25); }
#mindmap[data-intent="nature"] .root-node { background: linear-gradient(135deg, #166534, #15803D); box-shadow: 0 4px 12px rgba(22,101,52,0.25); }
#mindmap[data-intent="warning"] .root-node { background: linear-gradient(135deg, #991B1B, #B91C1C); box-shadow: 0 4px 12px rgba(153,27,27,0.25); }
#mindmap[data-intent="neutral"] .root-node { background: linear-gradient(135deg, #334155, #475569); box-shadow: 0 4px 12px rgba(51,65,85,0.25); }
/*
Intent Selection Guide (for model):
professional → corporate reports, strategy, management, business plans
technical → software, engineering, architecture, systems, AI/ML
medical → healthcare, clinical, pharmaceutical, nursing, anatomy
education → teaching, learning, curriculum, training, academic
creative → design, art, marketing, branding, media
finance → banking, investment, accounting, economics, trading
nature → environment, ecology, agriculture, biology, geography
warning → risk analysis, safety, incident review, compliance, audit
neutral → general topics, mixed content, unclear domain
Recommended branch color combos per intent:
professional → [blue, teal, cyan]
technical → [blue, purple, cyan]
medical → [teal, green, blue]
education → [amber, green, blue]
creative → [purple, amber, cyan]
finance → [blue, green, cyan]
nature → [green, teal, amber]
warning → [red, amber, cyan]
neutral → [blue, green, purple]
*/
.root-node {
color: white; font-size: 20px; font-weight: 700;
padding: 18px 28px; border-radius: 12px;
white-space: nowrap; flex-shrink: 0; align-self: center;
}
/* Fallback if no intent specified — defaults to professional blue */
#mindmap:not([data-intent]) .root-node {
background: linear-gradient(135deg, #1E3A5F, #2D4A6F);
box-shadow: 0 4px 12px rgba(30,58,95,0.25);
}
/* ─── Container Layout ─── */
.tree { display: flex; align-items: flex-start; gap: 0; position: relative; }
.branches { display: flex; flex-direction: column; gap: 16px; margin-left: 60px; }
.branch, .sub-branch { display: flex; align-items: flex-start; gap: 0; }
/* ─── First-Level Branch Node ─── */
.branch-node {
font-size: 15px; font-weight: 600;
padding: 10px 20px; border-radius: 8px;
white-space: nowrap; flex-shrink: 0; border: 2px solid;
}
/* ─── Second-Level Sub-Node (when has children) ─── */
.sub-node {
font-size: 13px; font-weight: 500;
padding: 7px 14px; border-radius: 6px;
white-space: nowrap; flex-shrink: 0; border: 1.5px solid;
background: #F8FAFC;
}
/* ─── Leaf Node ─── */
/* Default: lightweight capsule frame */
.leaf {
font-size: 13px; font-weight: 400; color: #475569;
padding: 4px 10px; background: #FAFAFA;
border-radius: 12px; border: 1px solid #E5E7EB;
white-space: nowrap; line-height: 1.5;
}
/* Text-only mode (more compact, add class="leaf text-only") */
.leaf.text-only {
background: none; border: none; padding: 2px 0; border-radius: 0;
}
/* Leaf supplementary description */
.leaf-desc {
font-size: 12px; color: #94A3B8; font-weight: 400; margin-left: 8px;
}
.leaf-desc::before { content: '— '; color: #CBD5E1; }
/* ─── Child Node Container ─── */
/* Principle: The deeper the level, the smaller the gap, but not so small that connectors and text overlap */
.children { display: flex; flex-direction: column; gap: 6px; margin-left: 48px; align-items: flex-start; }
.children:has(.sub-branch) { gap: 10px; } /* sub-branch is larger than leaf, needs more spacing */
.sub-branch .children { gap: 5px; margin-left: 40px; align-items: flex-start; }
.sub-branch .sub-branch .children { gap: 4px; margin-left: 36px; align-items: flex-start; }
/* Tip: If leaf text and connectors overlap, prioritize increasing gap */
/* ─── Color System (for distinguishing different branches) ─── */
.c-blue { background: #EFF6FF; border-color: #3B82F6; color: #1E40AF; }
.c-green { background: #F0FDF4; border-color: #10B981; color: #065F46; }
.c-amber { background: #FFF7ED; border-color: #F59E0B; color: #92400E; }
.c-purple { background: #F5F3FF; border-color: #8B5CF6; color: #5B21B6; }
.c-red { background: #FEF2F2; border-color: #EF4444; color: #991B1B; }
.c-cyan { background: #ECFEFF; border-color: #06B6D4; color: #155E75; }
.c-teal { background: #F0FDFA; border-color: #14B8A6; color: #134E4A; }
/* Second-level sub-node inherits branch color scheme (lighter) */
.c-blue .sub-node { background: #F0F7FF; border-color: #93C5FD; color: #1E40AF; }
.c-green .sub-node { background: #F2FDF6; border-color: #6EE7B7; color: #065F46; }
.c-amber .sub-node { background: #FFF9F0; border-color: #FCD34D; color: #92400E; }
.c-purple .sub-node { background: #F8F6FF; border-color: #C4B5FD; color: #5B21B6; }
.c-red .sub-node { background: #FFF5F5; border-color: #FCA5A5; color: #991B1B; }
.c-cyan .sub-node { background: #F0FEFF; border-color: #67E8F9; color: #155E75; }
.c-teal .sub-node { background: #F2FDFA; border-color: #5EEAD4; color: #134E4A; }
```
---
## Style A: Right-Expanding Tree (Simple scenarios with branchCount ≤ 4 or maxDepth ≤ 2)
### HTML Structure
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
/* ← paste the "Universal CSS Base" above */
</style>
</head>
<body>
<div id="mindmap" data-intent="professional">
<div class="tree">
<div class="root-node">中心主题</div>
<div class="branches">
<div class="branch">
<div class="branch-node c-blue">一级分支A</div>
<div class="children">
<div class="leaf">叶子1</div>
<div class="leaf">叶子2</div>
</div>
</div>
<div class="branch">
<div class="branch-node c-green">一级分支B</div>
<div class="children">
<div class="sub-branch">
<div class="sub-node c-green">二级有下级</div>
<div class="children">
<div class="leaf">三级叶子1</div>
<div class="leaf">三级叶子2</div>
</div>
</div>
<div class="leaf">二级叶子</div>
</div>
</div>
</div>
</div>
</div>
<script>
/* ← paste the "Universal Recursive Connector Script" */
drawAllLines();
</script>
</body>
</html>
```
---
## Style B: Left-Right Expanding Tree (Standard scenario with branchCount ≥ 5, most common)
### Additional CSS (append after universal base)
```css
/* ─── Left-Right Expanding Layout ─── */
.lr-tree { display: flex; align-items: center; gap: 0; position: relative; }
.lr-tree .root-node {
padding: 20px 32px; border-radius: 14px;
margin: 0 60px; text-align: center;
}
/* ⚠️ sub-branch must be flex, otherwise flex-direction: row-reverse won't work, leaves won't expand left */
.sub-branch { display: flex; align-items: flex-start; gap: 0; }
.left-branches { display: flex; flex-direction: column; gap: 16px; }
.left-branch { display: flex; align-items: flex-start; flex-direction: row-reverse; gap: 0; }
.left-branch .children {
display: flex; flex-direction: column; gap: 6px;
margin-right: 48px; align-items: flex-end;
}
.left-branch .children:has(.sub-branch) { gap: 10px; }
.left-branch .sub-branch .children { gap: 5px; margin-right: 40px; margin-left: 0; align-items: flex-end; }
.left-branch .sub-branch { flex-direction: row-reverse; }
.right-branches { display: flex; flex-direction: column; gap: 16px; }
.right-branch { display: flex; align-items: flex-start; gap: 0; }
.right-branch .children {
display: flex; flex-direction: column; gap: 6px; margin-left: 48px; align-items: flex-start;
}
.right-branch .children:has(.sub-branch) { gap: 10px; }
.right-branch .sub-branch .children { gap: 5px; margin-left: 40px; align-items: flex-start; }
```
### HTML Structure
```html
<div id="mindmap" data-intent="professional">
<div class="lr-tree">
<!-- Left branches (allocated by balance_branches) -->
<div class="left-branches">
<div class="left-branch">
<div class="branch-node c-purple">分支D(放左边)</div>
<div class="children">
<div class="leaf">叶子1</div>
<div class="leaf">叶子2</div>
</div>
</div>
<!-- More left branches... -->
</div>
<div class="root-node">中心主题</div>
<!-- Right branches -->
<div class="right-branches">
<div class="right-branch">
<div class="branch-node c-blue">分支A(放右边)</div>
<div class="children">
<div class="leaf">叶子1</div>
<div class="leaf">叶子2</div>
</div>
</div>
<!-- More right branches... -->
</div>
</div>
</div>
<script>
/* ← paste the "Universal Recursive Connector Script" */
drawAllLines(); // v4: auto-handles both left and right sides
</script>
```
---
## Style C: Card Grid (when user explicitly requests "cards" / "modules")
> ⚠️ This is not a mind map — it's a modular display. No connectors; use cards + color coding to show relationships.
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #FFFFFF;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
}
#mindmap { padding: 48px 64px; }
.map-title {
font-size: 24px; font-weight: 700; color: #1E293B; text-align: center;
margin-bottom: 8px;
}
.map-subtitle {
font-size: 14px; color: #64748B; text-align: center;
margin-bottom: 36px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px; max-width: 1000px; margin: 0 auto;
}
.card {
background: #FFFFFF; border-radius: 12px;
padding: 20px; border: 1px solid #E2E8F0;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 2px solid; }
.card-title { font-size: 15px; font-weight: 600; }
.card-icon {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
font-size: 16px; border-radius: 50%; flex-shrink: 0;
}
.card-items { list-style: none; display: flex; flex-direction: column; gap: 6px; }
.card-items li {
font-size: 13px; color: #475569; padding-left: 16px; position: relative;
}
.card-items li::before {
content: ''; position: absolute; left: 0; top: 8px;
width: 6px; height: 6px; border-radius: 50%;
}
/* Color variants */
.card.blue .card-header { border-color: #3B82F6; }
.card.blue .card-icon { background: #EFF6FF; }
.card.blue .card-title { color: #1E40AF; }
.card.blue li::before { background: #3B82F6; }
.card.green .card-header { border-color: #10B981; }
.card.green .card-icon { background: #F0FDF4; }
.card.green .card-title { color: #065F46; }
.card.green li::before { background: #10B981; }
.card.amber .card-header { border-color: #F59E0B; }
.card.amber .card-icon { background: #FFF7ED; }
.card.amber .card-title { color: #92400E; }
.card.amber li::before { background: #F59E0B; }
.card.purple .card-header { border-color: #8B5CF6; }
.card.purple .card-icon { background: #F5F3FF; }
.card.purple .card-title { color: #5B21B6; }
.card.purple li::before { background: #8B5CF6; }
</style>
</head>
<body>
<div id="mindmap" data-intent="professional">
<div class="map-title">标题</div>
<div class="map-subtitle">副标题</div>
<div class="card-grid">
<div class="card blue">
<div class="card-header">
<div class="card-icon">📋</div>
<div class="card-title">模块A</div>
</div>
<ul class="card-items">
<li>条目1</li>
<li>条目2</li>
</ul>
</div>
<!-- More cards... -->
</div>
</div>
</body>
</html>
```
---
## Style D: Fishbone Diagram (Problem Analysis / Root Cause)
Best for problem analysis, root cause tracing, quality management (Ishikawa diagram).
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #FFFFFF;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
}
#mindmap { padding: 48px 64px; }
.fishbone { position: relative; }
.spine { display: flex; align-items: center; gap: 0; }
.spine-line { flex: 1; height: 3px; background: #1E293B; }
.spine-head {
width: 0; height: 0;
border-left: 16px solid #1E293B;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
}
.result-node {
background: #1E293B; color: white;
font-size: 16px; font-weight: 700;
padding: 12px 24px; border-radius: 8px;
white-space: nowrap; margin-left: 4px;
}
.bone-branches {
position: absolute; left: 80px; right: 200px; top: 50%;
display: flex; justify-content: space-around;
}
.bone { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.bone.up { transform: translateY(calc(-100% - 20px)); }
.bone.down { transform: translateY(20px); }
.bone-title {
font-size: 14px; font-weight: 600;
padding: 8px 16px; border-radius: 8px; white-space: nowrap;
}
.bone-items { display: flex; flex-direction: column; gap: 4px; align-items: center; }
.bone-item {
font-size: 12px; color: #64748B;
padding: 3px 10px; background: #F8FAFC;
border-radius: 4px; border: 1px solid #E2E8F0; white-space: nowrap;
}
.bone-line { width: 2px; height: 24px; background: #CBD5E1; }
</style>
</head>
<body>
<div id="mindmap" data-intent="professional">
<div class="fishbone">
<div class="spine">
<div class="spine-line"></div>
<div class="spine-head"></div>
<div class="result-node">问题/结果</div>
</div>
<!-- Upper causes -->
<div class="bone-branches" style="transform: translateY(calc(-50% - 20px));">
<div class="bone">
<div class="bone-items">
<div class="bone-item">子原因1</div>
<div class="bone-item">子原因2</div>
</div>
<div class="bone-line"></div>
<div class="bone-title" style="background:#EFF6FF;color:#1E40AF;border:1.5px solid #3B82F6;">原因类别A</div>
</div>
</div>
<!-- Lower causes -->
<div class="bone-branches" style="transform: translateY(calc(-50% + 20px));">
<div class="bone">
<div class="bone-title" style="background:#F0FDF4;color:#065F46;border:1.5px solid #10B981;">原因类别B</div>
<div class="bone-line"></div>
<div class="bone-items">
<div class="bone-item">子原因1</div>
<div class="bone-item">子原因2</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
```
---
## Quality Checklist
After rendering, verify against this checklist:
1. **Content complete** — Every node from the original requirement is in the map, nothing missing
2. **Clear hierarchy** — L1 branches and leaves are instantly distinguishable (different bg/border/font-weight)
3. **No overlap** — No boxes covering boxes, no lines through text
4. **Connectors visible** — Every parent-child pair has a connector, no orphan leaves. Connector color ≥ `#94A3B8` (too light = invisible)
5. **Connector direction correct** — Left-side leaves extend left, right-side extends right
6. **Proportions reasonable** — Map is not extremely narrow/tall (target aspect ratio 1:1 to 3:1), visually comfortable
7. **Text readable** — At final output size, smallest text is legible. Reference: root 18px+, L1 15px+, L2 13px+, leaves 12px+
8. **Roughly balanced** (Style B) — Visual weight approximately equal on both sides, doesn't need to be symmetric. Target ≤30% difference
9. **Canvas large enough** — No nodes clipped, sufficient padding on all sides (reference 60px+)
10. **No large blank areas on right** — Screenshot trimmed to content edge
---
## ⛔ Radial Layout Warning
**Radial layout is strongly discouraged.** Using `position: absolute` for fixed branch positions leads to overlap when branches increase, and deep levels cannot be handled.
If content is very simple (reference: ≤3 branches, ≤3 children per branch, text ≤6 chars, depth ≤2, total ≤12 nodes), it's technically possible, but tree layout is always the safer choice.
**No code template provided.** If conditions are met, manually adjust based on Style A.
+801
View File
@@ -0,0 +1,801 @@
# Playwright + CSS Rendering Engine
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
**Core principle: Content-driven, not template-driven. Analyze content structure first, then decide layout, then render.**
Applicable to: flowcharts, infographics, KPI cards, data posters — any visualization requiring full CSS power (gradients, shadows, rounded corners, Grid/Flexbox layout).
---
## Step 1: Content Analysis
When you receive a flowchart/infographic requirement, **don't write HTML/CSS first**. Analyze the content structure.
### 1.1 Flowchart Content Analysis
```
Input: "Honey production process", 8 steps, linear without branches
↓
{
"type": "flowchart",
"nodes": [
{ "id": "1", "label": "花粉采集", "desc": "蜜蜂从花朵采集花蜜" },
{ "id": "2", "label": "酿造", "desc": "蜜蜂在蜂巢中反复吞吐" },
...
],
"edges": [["1","2"], ["2","3"], ...],
"nodeTypes": { "start": ["1"], "end": ["8"], "decision": [], "normal": ["2","3","4","5","6","7"] }
}
```
### 1.2 Key Metrics
| Metric | Calculation | Affects |
|------|---------|---------|
| `nodeCount` | Total node count | Mermaid vs CSS flowchart |
| `maxTextLen` | Longest text char count | Node width |
| `hasDecision` | Has decision branches | Layout complexity |
| `hasBranch` | Has parallel/branches | Column count |
| `parallelCount` | Max parallel branches | Column count |
| `phaseCount` | Phase/group count | Need for phased containers |
| `hasRoles` | Has roles/swimlanes | Need for dual-panel |
| `isLinear` | Linear without branches | Can use snake layout |
### 1.3 Infographic Content Analysis
Infographics (KPI cards, data posters) are simpler to analyze:
- How many data metrics? → Determines grid columns
- Any trend data? → Need for sparklines
- Title area? → Need for hero header
- Comparison data? → Need for bar chart
---
## Step 2: Layout Decision
### 2.1 Flowchart Layout Decision Tree
```
⚠️ DEFAULT RULE: When user asks "generate/create XXX 流程图" without specifying format,
DEFAULT to Layout C (Phased Vertical). Almost all real-world processes have phases.
User specified Mermaid/markdown?
└─ Yes → Follow user choice (Format Constraint Rule)
└─ No →
nodeCount ≤ 6 AND no phases AND maxTextLen ≤ 8 (CJK)?
└─ Yes → Mermaid (minimal flowchart)
└─ No →
phaseCount > 0 OR nodeCount ≥ 5?
└─ Yes → ⭐ Layout C: CSS Phased Vertical flowchart (DEFAULT)
└─ No →
hasRoles?
└─ Yes → Layout D: CSS Dual-panel/Swimlane flowchart
└─ No → Layout C: CSS Phased Vertical flowchart (fallback also uses C)
```
**⚠️ Layout A (Grid) and Layout B (Snake) are only for very special scenarios (e.g., flat comparison, unordered parallel items). Flowcharts default to Layout C.**
### 🚫 Flowchart Anti-Patterns (FORBIDDEN)
| ❌ Bad Pattern | ✅ Correct Pattern |
|---|---|
| Phase titles (一、二、三...) as isolated left-side text labels | Phase titles as colored title bars, wrapped inside group cards |
| All nodes flat-laid in Grid without group containers | Each phase wrapped in a `.phase-group` card containing its steps |
| Role labels scattered above nodes | Role info displayed uniformly at the top of the flowchart, or as phase card labels |
| Nodes connected with loose diverging lines | Phases connected with arrows (↓), steps within phases use numbering |
| Inconsistent node sizes, uneven spacing, misaligned | Same-phase nodes share uniform style, overall alignment consistent |
| Using Layout A Grid for a phased flowchart | Has phases → must use Layout C |
### 2.2 Canvas Size Calculation
```python
def calc_flowchart_canvas(node_count, max_text_len, parallel_count, has_roles, layout):
node_width = max(180, max_text_len * 16) # ~16px per CJK char (including padding)
if layout == 'snake':
cols = min(4, node_count)
rows = (node_count + cols - 1) // cols
width = cols * (node_width + 60) + 120 # 60=gap, 120=padding
height = rows * 120 + 200 # 120=row height
elif layout == 'dual-panel':
width = max(1600, node_width * 2 + 400) # left panel + right flow
height = node_count * 100 + 200
elif layout == 'phased-vertical':
width = max(800, node_width + 200)
height = node_count * 80 + 200
else: # grid
cols = max(parallel_count, 2)
width = cols * (node_width + 60) + 120
rows = (node_count + cols - 1) // cols
height = rows * 120 + 200
return max(width, 800), max(height, 600)
```
### 2.3 Color Decision
**Iron rule: Node background = low-saturation light color, border = high-saturation color. Large high-saturation areas = children's drawing.**
**⚠️ Text contrast iron rule: Dark/accent background nodes must use light text (white or near-white) for title and description.**
Light background → dark text (`#1F2937`), dark background → light text (`#FFFFFF` or `#FFF7ED`).
Common mistake: Endpoint/highlight node switched to dark background, but description text remains dark gray, making it completely unreadable.
```
Node type → color:
Start/end → bg: #EFF6FF, border: #3B82F6 (blue), text: #1E40AF
Normal step → bg: #F8FAFC, border: #94A3B8 (gray-blue), text: #374151 or colored by phase
Decision node → bg: #FFF7ED, border: #F59E0B (amber), text: #92400E
Success/pass → bg: #F0FDF4, border: #10B981 (green), text: #065F46
End/failure → bg: #F5F3FF, border: #8B5CF6 (purple), text: #5B21B6
Emphasis/endpoint (dark bg) → bg: #92400E, border: #F59E0B, text: #FFFFFF, desc: #FFF7ED
Max 3-4 background colors for nodes in the same flowchart.
Overall chart background = white #FFFFFF.
```
**Phase bar colors**: Same-hue gradient (blue-gray family), **never use different hues per phase**.
```css
/* ✅ Same-hue blue-gray progression */
.phase-1 { background: #F0F4F8; border-left: 4px solid #64748B; }
.phase-2 { background: #E8EDF2; border-left: 4px solid #5B7A99; }
/* ❌ Different hue per phase (rainbow effect) */
.phase-1 { background: #EFF6FF; border-left: 4px solid #3B82F6; } /* blue */
.phase-2 { background: #F0FDF4; border-left: 4px solid #10B981; } /* green */
.phase-3 { background: #FFF7ED; border-left: 4px solid #F59E0B; } /* amber */
```
---
## Step 3: Rendering
### 3.1 Playwright Screenshot (Universal)
```python
import asyncio
from playwright.async_api import async_playwright
async def html_to_image(html_path, output_path, selector='#root',
width=1200, height=None, scale=2):
"""HTML → PNG/PDF
scale: 2 (default crisp), 1.5 (large canvas 3000px+), 3 (print).
Width must accommodate ALL content. After first render, auto-resize viewport to fit.
"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(
viewport={'width': width, 'height': height or 800},
device_scale_factor=scale
)
await page.goto(f'file://{html_path}', wait_until='networkidle')
await page.wait_for_timeout(500)
if output_path.endswith('.pdf'):
await page.pdf(path=output_path, print_background=True)
else:
el = page.locator(selector)
bbox = await el.bounding_box()
if bbox:
fit_w = max(width, int(bbox['width'] + 100))
fit_h = int(bbox['height'] + 100)
await page.set_viewport_size({'width': fit_w, 'height': fit_h})
await page.wait_for_timeout(200)
await el.screenshot(path=output_path)
await browser.close()
import os
print(f'✅ {output_path} ({os.path.getsize(output_path)/1024:.0f}KB)')
```
### 3.2 HTML Universal Shell
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--text: #111827;
--text-sub: #6B7280;
--text-muted: #9CA3AF;
--bg: #FFFFFF;
--surface: #F9FAFB;
--border: #E5E7EB;
--blue: #3B82F6;
--cyan: #06B6D4;
--purple: #8B5CF6;
--amber: #F59E0B;
--red: #EF4444;
--green: #10B981;
--positive: #22C55E;
--negative: #EF4444;
--connector: #94A3B8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
#root { width: fit-content; min-width: 800px; margin: 0 auto; padding: 48px 40px; }
</style>
</head>
<body>
<div id="root">
<!-- Content -->
</div>
</body>
</html>
```
### 3.3 CSS Variables: Node Color System
```css
:root {
/* Node types — low-saturation background + high-saturation border */
--node-bg: #EFF6FF; --node-border: #3B82F6; /* Normal step (blue) */
--node-decision-bg: #FFF7ED; --node-decision-border: #F59E0B; /* Decision (amber) */
--node-success-bg: #F0FDF4; --node-success-border: #10B981; /* Success (green) */
--node-end-bg: #F5F3FF; --node-end-border: #8B5CF6; /* End (purple) */
--group-bg: #F8FAFC; --group-border: #E2E8F0; /* Group container */
}
```
---
## Layout A: CSS Grid Flowchart (Universal, Most Common)
For: Flowcharts with branches/decisions, >10 nodes or long CJK text.
### Core CSS
```css
.flow-title {
font-size: 22px; font-weight: 700; color: var(--text);
text-align: center; margin-bottom: 40px;
}
.flow-subtitle {
font-size: 14px; color: var(--text-sub);
text-align: center; margin-top: 8px;
}
/* Grid container */
.flow-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 220px));
gap: 40px 60px;
justify-content: center;
position: relative;
}
/* Node */
.flow-node {
background: var(--node-bg); border: 2px solid var(--node-border);
border-radius: 10px; padding: 16px 20px;
text-align: center; position: relative; z-index: 1;
min-height: 56px; display: flex; flex-direction: column;
justify-content: center; align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
max-width: 260px; /* Prevent single node from being too wide and crowding parallel nodes */
word-break: break-word; /* Force line break for overly long text */
white-space: normal; /* Allow line breaks (override possible nowrap) */
}
.flow-node .node-title { font-size: 15px; font-weight: 600; line-height: 1.4; }
.flow-node .node-desc { font-size: 12px; color: var(--text-sub); margin-top: 4px; }
/* Node variants */
.flow-node.start { border-radius: 24px; }
.flow-node.decision { background: var(--node-decision-bg); border-color: var(--node-decision-border); }
.flow-node.success { background: var(--node-success-bg); border-color: var(--node-success-border); }
.flow-node.end { background: var(--node-end-bg); border-color: var(--node-end-border); }
/* Group box */
.flow-group {
background: var(--group-bg); border: 1.5px dashed var(--group-border);
border-radius: 12px; padding: 24px 20px 20px; position: relative;
}
.flow-group .group-label {
position: absolute; top: -10px; left: 16px;
background: var(--bg); padding: 0 8px;
font-size: 12px; font-weight: 600; color: var(--text-sub);
}
/* ─── Parallel branch constraints (prevent overlap when multiple nodes in same row) ─── */
/*
⚠️ Parallel branch iron rules:
1. Gap between parallel nodes in same row ≥ 40px (guaranteed by .flow-grid gap)
2. Each node max-width: 260px + word-break: break-word (set in .flow-node)
3. If parallel nodes > 3 → switch to vertical branch layout (don't force-squeeze into one row)
4. Text over 15 CJK characters → must line-break, don't expand node width
5. When using flex instead of manual grid-column for parallel areas, add flex-wrap: wrap as fallback
*/
.parallel-group {
display: flex; gap: 40px; justify-content: center;
flex-wrap: wrap; /* Fallback: auto-wrap when exceeding width */
}
.parallel-group .flow-node {
flex: 0 1 220px; /* Max 220px, can shrink, won't grow infinitely */
}
/* SVG connector layer */
.flow-connectors {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 0;
}
.flow-connectors line, .flow-connectors path {
stroke: var(--connector); stroke-width: 2; fill: none;
marker-end: url(#arrowhead);
}
.connector-label {
font-size: 12px; fill: var(--text-sub); text-anchor: middle;
font-family: -apple-system, 'PingFang SC', 'SimHei', sans-serif;
}
/* Legend — must be independent container, not inside flow-grid */
.flow-legend {
display: flex; gap: 24px; justify-content: center;
margin-top: 40px; padding: 16px 24px;
background: #F9FAFB; border-radius: 8px; border: 1px solid #E5E7EB;
}
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #4B5563; }
.legend-dot { width: 12px; height: 12px; border-radius: 3px; border: 2px solid; }
```
### Auto Connector Script
```javascript
// connections = [['sourceID', 'targetID', 'label'], ...]
function drawConnectors(connections) {
const svg = document.getElementById('connectorSvg');
const container = svg.parentElement;
const cRect = container.getBoundingClientRect();
svg.setAttribute('width', cRect.width);
svg.setAttribute('height', cRect.height);
svg.setAttribute('viewBox', `0 0 ${cRect.width} ${cRect.height}`);
// Clear old connectors (keep defs)
svg.querySelectorAll('line, path, text.connector-label').forEach(el => el.remove());
connections.forEach(([fromId, toId, label]) => {
const fromEl = document.querySelector(`[data-id="${fromId}"]`);
const toEl = document.querySelector(`[data-id="${toId}"]`);
if (!fromEl || !toEl) return;
const f = fromEl.getBoundingClientRect();
const t = toEl.getBoundingClientRect();
const x1 = f.left + f.width/2 - cRect.left;
const y1 = f.bottom - cRect.top;
const x2 = t.left + t.width/2 - cRect.left;
const y2 = t.top - cRect.top;
if (Math.abs(x1 - x2) < 10) {
// Same column → straight line
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1); line.setAttribute('y1', y1);
line.setAttribute('x2', x2); line.setAttribute('y2', y2);
svg.appendChild(line);
} else {
// Different column → bent line
const midY = (y1 + y2) / 2;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`);
svg.appendChild(path);
}
if (label) {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', (x1 + x2) / 2);
text.setAttribute('y', (y1 + y2) / 2 - 6);
text.setAttribute('class', 'connector-label');
text.textContent = label;
svg.appendChild(text);
}
});
}
// SVG defs (arrow definitions)
function ensureArrowDef(svg) {
if (svg.querySelector('#arrowhead')) return;
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.innerHTML = '<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#94A3B8" /></marker>';
svg.appendChild(defs);
}
```
### HTML Structure Example
```html
<div id="root">
<div class="flow-title">流程图标题</div>
<div style="position: relative;">
<svg class="flow-connectors" id="connectorSvg"></svg>
<div class="flow-grid" id="flowGrid">
<div class="flow-node start" data-id="start" style="grid-column: 2;">
<div class="node-title">开始</div>
</div>
<div class="flow-node" data-id="step1" style="grid-column: 1;">
<div class="node-title">步骤一</div>
<div class="node-desc">详细说明</div>
</div>
<div class="flow-node decision" data-id="decide" style="grid-column: 2;">
<div class="node-title">判断条件?</div>
</div>
<div class="flow-node end" data-id="end" style="grid-column: 2;">
<div class="node-title">结束</div>
</div>
</div>
</div>
<div class="flow-legend">
<div class="legend-item"><div class="legend-dot" style="border-color:#3B82F6;background:#EFF6FF;"></div>步骤</div>
<div class="legend-item"><div class="legend-dot" style="border-color:#F59E0B;background:#FFF7ED;"></div>判断</div>
</div>
</div>
<script>
const connections = [
['start', 'step1', ''], ['step1', 'decide', ''],
['decide', 'end', '通过']
];
window.addEventListener('load', () => {
ensureArrowDef(document.getElementById('connectorSvg'));
setTimeout(() => drawConnectors(connections), 100);
});
</script>
```
---
## Layout B: Snake Flowchart (>4 Linear Steps)
For: Linear non-branching processes with many steps (5-20).
### Key Rules
- Max 4 nodes per row
- First row left→right, second row right→left (snake pattern)
- End-of-row to next-row turn connectors use bend lines, with ≥60px clearance at turns
- Node positions in grid are auto-calculated by JS (no manual grid-column)
### Snake Layout Generation Script
```javascript
function layoutSnake(nodeIds, cols) {
cols = cols || 4;
nodeIds.forEach((id, i) => {
const row = Math.floor(i / cols);
const colInRow = i % cols;
const col = row % 2 === 0 ? colInRow + 1 : cols - colInRow; // Even rows L→R, odd rows R→L
const el = document.querySelector(`[data-id="${id}"]`);
if (el) {
el.style.gridRow = row + 1;
el.style.gridColumn = col;
}
});
}
```
---
## Layout C: Phased Vertical Flowchart (⭐ DEFAULT for all flowcharts)
**This is the DEFAULT layout for flowcharts.** When the user asks for any flowchart without specifying format, use this layout.
For: Any process with phases/stages, which is nearly ALL real-world processes (manufacturing, legal, project management, business operations, etc.). Also the safe fallback when unsure.
**Why this is the default:** Layout C produces consistently professional, readable results. Even if the process has only 2 "phases", the card-based grouping still looks clean. In contrast, Layout A (Grid) without proper grouping produces scattered, unreadable results.
### Key Design
**Phase titles vs sub-steps must have clear visual distinction**:
- Phase titles: colored background, font-size ≥ 16px, font-weight: 700
- Sub-steps: white/light gray background, font-size 14-15px, font-weight: 400-500
**No arrows between sub-steps**. Arrows only connect phase-to-phase. Sub-steps use indent + numbering for sequence.
### Phase Connector Direction Rule
Phase-to-phase connector arrows MUST match the logical flow direction. If the flow goes top → bottom, arrows point ↓. If bottom → top, arrows point ↑. If left → right, arrows point →. **Never draw arrows opposing the flow direction.**
### Additional CSS
```css
.phase-group {
background: #F8FAFC; border-radius: 12px; padding: 20px 24px;
margin-bottom: 24px;
}
.phase-title {
font-size: 16px; font-weight: 700; padding: 10px 16px;
border-radius: 8px; margin-bottom: 16px;
}
.phase-steps { display: flex; flex-direction: column; gap: 8px; padding-left: 12px; }
.phase-step {
font-size: 14px; font-weight: 400; color: var(--text);
padding: 8px 14px; background: white; border-radius: 6px;
border: 1px solid var(--border);
}
.phase-step .step-num {
display: inline-block; width: 22px; height: 22px; line-height: 22px;
text-align: center; border-radius: 50%; font-size: 12px; font-weight: 600;
margin-right: 8px;
}
/* Phase colors — same-hue blue-gray gradient (low saturation, easy on the eyes)
All phases share the blue-gray color family, distinguished by brightness progression.
🚫 FORBIDDEN: Different hue per phase (blue→green→amber→purple) — becomes rainbow with many phases.
✅ CORRECT: Progress within same hue family (light→dark), or pure grayscale + single-color accent.
Two schemes provided below; model selects based on phase count:
- ≤4 phases: Scheme A (blue-gray progression)
- 5-7 phases: Scheme B (neutral gray base + blue accent progression)
- >7 phases: all use same gray base, distinguish only by numbering
*/
/* Scheme A: Blue-gray progression (≤4 phases) */
.phase-1 .phase-title { background: #F0F4F8; color: #334155; border-left: 4px solid #64748B; }
.phase-1 .step-num { background: #E2E8F0; color: #475569; }
.phase-2 .phase-title { background: #E8EDF2; color: #1E3A5F; border-left: 4px solid #5B7A99; }
.phase-2 .step-num { background: #DBEAFE; color: #1E3A5F; }
.phase-3 .phase-title { background: #E0E7EF; color: #1E3050; border-left: 4px solid #4A6B8A; }
.phase-3 .step-num { background: #D0D9E4; color: #1E3050; }
.phase-4 .phase-title { background: #D8E0EA; color: #172540; border-left: 4px solid #3A5C7A; }
.phase-4 .step-num { background: #C7D2E0; color: #172540; }
/* Scheme B: Neutral gray base + blue accent progression (5-7 phases) */
/*
.phase-1 .phase-title { background: #F8FAFC; color: #334155; border-left: 4px solid #94A3B8; }
.phase-1 .step-num { background: #F1F5F9; color: #475569; }
.phase-2 .phase-title { background: #F1F5F9; color: #334155; border-left: 4px solid #7B8FA3; }
.phase-2 .step-num { background: #E2E8F0; color: #475569; }
.phase-3 .phase-title { background: #E8EDF3; color: #2D4156; border-left: 4px solid #6B809A; }
.phase-3 .step-num { background: #DBEAFE; color: #2D4156; }
.phase-4 .phase-title { background: #E2E8F0; color: #283C52; border-left: 4px solid #5B7590; }
.phase-4 .step-num { background: #D0D9E4; color: #283C52; }
.phase-5 .phase-title { background: #DAE1EB; color: #23364D; border-left: 4px solid #4B6A87; }
.phase-5 .step-num { background: #C7D2E0; color: #23364D; }
.phase-6 .phase-title { background: #D2DAE5; color: #1E3048; border-left: 4px solid #3B5F7D; }
.phase-6 .step-num { background: #BFC9D8; color: #1E3048; }
.phase-7 .phase-title { background: #CAD3E0; color: #192A3E; border-left: 4px solid #2B5473; }
.phase-7 .step-num { background: #B7C2D2; color: #192A3E; }
*/
```
### HTML Structure
```html
<div id="root">
<div class="flow-title">项目流程</div>
<div class="phase-group phase-1">
<div class="phase-title">第一阶段:需求分析</div>
<div class="phase-steps">
<div class="phase-step"><span class="step-num">1</span>需求收集与整理</div>
<div class="phase-step"><span class="step-num">2</span>可行性评估</div>
<div class="phase-step"><span class="step-num">3</span>需求优先级排序</div>
</div>
</div>
<!-- Phase-to-phase arrow (use SVG or simple centered down arrow) -->
<div style="text-align:center; color:#94A3B8; font-size:24px; margin: 8px 0;">↓</div>
<div class="phase-group phase-2">
<div class="phase-title">第二阶段:设计开发</div>
<div class="phase-steps">
<div class="phase-step"><span class="step-num">4</span>UI/UX 设计</div>
<div class="phase-step"><span class="step-num">5</span>前后端开发</div>
</div>
</div>
</div>
```
---
## Layout D: Dual-Panel / Swimlane Flowchart
For: Processes involving multiple roles or departments.
### Key Rules
- Canvas width ≥ 1600px
- Left panel for role/swimlane labels, right panel for flow nodes
- Role labels font-size ≥ 12px, solid background, right edge ≥ 40px from canvas
- `overflow: hidden` is forbidden
### Additional CSS
```css
.dual-layout { display: flex; gap: 40px; }
.role-panel {
flex-shrink: 0; width: 160px;
display: flex; flex-direction: column; gap: 12px;
}
.role-tag {
font-size: 13px; font-weight: 600; padding: 8px 12px;
border-radius: 6px; text-align: center;
}
.flow-panel { flex: 1; min-width: 0; }
```
---
## Infographic Templates
The following templates are for non-flowchart information visualization.
### Template: KPI Dashboard Cards
```css
.kpi-grid {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 20px; margin-bottom: 32px;
}
.kpi-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 24px; text-align: center;
}
.kpi-label { font-size: 13px; color: var(--text-sub); margin-bottom: 8px; }
.kpi-value { font-size: 32px; font-weight: 700; }
.kpi-change { font-size: 14px; font-weight: 600; margin-top: 8px; }
.kpi-change.up { color: var(--positive); }
.kpi-change.down { color: var(--negative); }
```
### Template: CSS Bar Chart (Pure CSS, No JS)
```css
.bar-chart {
display: flex; align-items: flex-end; gap: 16px;
height: 300px; padding: 0 20px; border-bottom: 1px solid var(--border);
}
.bar-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.bar { width: 100%; max-width: 60px; border-radius: 6px 6px 0 0; background: var(--border); }
.bar.highlight { background: var(--blue); }
.bar-label { font-size: 12px; color: var(--text-sub); }
.bar-value { font-size: 11px; color: var(--text-muted); font-weight: 600; }
```
### Template: Gradient Background Infographic Header
```css
.hero-header {
background: linear-gradient(135deg, #1E293B 0%, #0F172A 100%);
border-radius: 16px; padding: 48px; color: white; margin-bottom: 32px;
}
.hero-header h1 { font-size: 28px; font-weight: 700; margin-bottom: 12px; }
.hero-header p { font-size: 15px; color: #94A3B8; max-width: 600px; line-height: 1.6; }
.hero-badge {
display: inline-block; background: rgba(59,130,246,0.15);
color: #60A5FA; font-size: 12px; font-weight: 600;
padding: 4px 12px; border-radius: 20px; margin-bottom: 16px;
}
```
### Template: Data Card + Mini Sparkline
```html
<div class="metric-card">
<div class="metric-info">
<div class="metric-title">月活用户</div>
<div class="metric-value">34,521</div>
<div class="metric-change" style="color: var(--positive)">↑ +18.2%</div>
</div>
<div class="metric-sparkline">
<svg width="120" height="40" viewBox="0 0 120 40">
<polyline fill="none" stroke="var(--blue)" stroke-width="2"
points="0,35 15,30 30,25 45,28 60,20 75,15 90,12 105,8 120,5" />
</svg>
</div>
</div>
```
```css
.metric-card {
background: white; border: 1px solid var(--border);
border-radius: 16px; padding: 28px;
display: flex; justify-content: space-between; align-items: center;
}
.metric-title { font-size: 13px; color: var(--text-sub); }
.metric-value { font-size: 36px; font-weight: 700; margin: 4px 0; }
.metric-change { font-size: 14px; font-weight: 600; }
```
---
## Advanced Connector Rules
### Many-to-One Convergence
When multiple lines converge into one node, use the "merge first, then enter" pattern:
```
[A] ──┐
[B] ──┤── → [目标]
[C] ──┘
```
Implementation: Source lines reach a relay x-coordinate, merge into one vertical line, then a single line enters the target.
### Cross-Layer Connector Avoidance
If other nodes block the path between two nodes, **do not draw a straight line through them**.
Priority:
1. **Redesign hierarchy** (best) — Most cross-layer lines indicate hierarchy design issues; connect adjacent layers only
2. **Detour line** — Route around middle nodes via canvas edge (offset 40px right)
3. **Thread through gap** — If middle-layer node gap ≥ 40px, route through the gap
### Connector Alignment
- Multiple lines from the same node start at a consistent position on the node border
- Use only one connector style per chart (right-angle/curved/straight), no mixing
- Connector label positions must be consistent (all above line or all centered)
---
## Font Size Rules
| Element | Recommended | Minimum |
|------|------|------|
| Flowchart main node title | 16-18px | 14px |
| Node description / subtext | 13-15px | 12px |
| Connector labels | 12-14px | 11px |
| Legend text | 13-14px | 12px |
| Footnotes / watermark | 11-13px | 10px |
| Phase titles | 16-18px | 16px |
| Role labels | 13-14px | 12px |
**Not enough space → Enlarge canvas, don't shrink fonts.**
---
## Overflow Protection
1. **`#root { width: fit-content; min-width: 800px; }`** — Expands with content automatically
2. **`overflow: hidden` / `overflow: clip` are forbidden**
3. **Auto-resize viewport before Playwright screenshot** (see 3.1 screenshot script)
4. **Canvas minimum width**:
| Layout Type | Minimum Width | Recommended |
|---------|---------|------|
| Single-column flowchart | 800px | 1000px |
| Dual-panel / swimlane | 1400px | 1600-1800px |
| Three-column / multi-panel | 1800px | 2000-2400px |
---
## Quality Checklist
1. **Layout C used by default** — If this is a flowchart, verify you're using Layout C (Phased Vertical) unless there's a specific reason not to
2. **Content complete** — Every node/step from the requirement is in the chart
3. **No overlap** — No boxes covering boxes, no lines through boxes
4. **Clear hierarchy** — Phase titles and sub-steps are instantly distinguishable
5. **Colors reasonable** — Node backgrounds low-saturation, no children's-drawing palette
6. **Connectors visible** — Connector color ≥ `#94A3B8`, arrow direction correct
7. **Font sizes meet standards** — Check against font size table, nothing below minimum
8. **Legend independent** — Not inside flow-grid, not obscured by any node
9. **No clipping** — Padding ≥ 40px on all sides, all nodes and labels fully visible
10. **Phase colors consistent** — Same hue family, no blue/brown/green/purple mix
11. **Connectors don't pass through nodes** — Cross-layer lines use detour or redesign hierarchy
12. **No scattered layout** — Phase titles MUST be inside group cards, NOT floating as isolated labels
---
## Playwright+CSS vs Other Approaches
| Capability | Playwright+CSS | matplotlib | ECharts |
|------|---------------|------------|---------|
| Gradients/shadows/rounded | ✅ Full CSS power | ❌ Limited | ⚠️ Partial |
| Responsive layout | ✅ Flexbox/Grid | ❌ Fixed size | ⚠️ resize |
| PNG/PDF export | ✅ Native | ✅ savefig | ⚠️ Needs Playwright |
| Precise data charts | ⚠️ Manual | ✅ Built-in | ✅ Built-in |
**Best practice: CSS for layout and visual design, embed ECharts/SVG for precise charts.**
+576
View File
@@ -0,0 +1,576 @@
# CSS Radial Grid Layout (Center-Outward Diagrams)
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
**For: SWOT analysis, Balanced Scorecard (BSC), Porter's Five Forces, PEST analysis, and any "center + 4-6 surrounding dimensions" diagram.**
**Core principle: Use flex rows to lock positions — model never calculates coordinates. Connectors are drawn by script reading bounding boxes.**
---
## When to Use This Template
| Diagram Type | Dimensions | Use This? |
|-------------|-----------|-----------|
| SWOT (Strengths/Weaknesses/Opportunities/Threats) | 4 quadrants | ✅ Layout B (2×2 Grid) |
| Balanced Scorecard (Financial/Customer/Internal/Learning) | 4 dimensions | ✅ Layout A (Cross) |
| Porter's Five Forces | 5 forces | ✅ Layout A (Cross + extra row) |
| PEST (Political/Economic/Social/Technological) | 4 dimensions | ✅ Layout B (2×2 Grid) |
| Competency wheel / capability map | 5-8 dimensions | ✅ Layout A with extra rows |
| Anything with center + surrounding elements | 3-8 | ✅ |
---
## Layout A: Cross Layout (4-6 Dimensions)
Best for: BSC, Porter's Five Forces, any "center with surrounding dimensions" structure.
### 🚫 FORBIDDEN: 3×3 CSS Grid Cross
**Do NOT use `grid-template-columns: Xpx Ypx Xpx` to place cards in a cross pattern.** The top/bottom cards in the center column will overflow into the side columns and overlap with left/right cards when content is longer than expected.
### ✅ REQUIRED: Three-Row Flex Layout
**Each row is an independent flex container. Rows cannot overlap each other — physically impossible.**
```
Row 1 (top): [top dimension card] ← independent flex row, centered
Row 2 (middle): [left card] [center] [right card] ← independent flex row, horizontal
Row 3 (bottom): [bottom dimension card] ← independent flex row, centered
```
### HTML + CSS
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
background: #FFFFFF;
}
#root { width: fit-content; min-width: 900px; padding: 48px 60px; }
.diagram-title {
font-size: 22px; font-weight: 700; color: #1F2937;
text-align: center; margin-bottom: 6px;
}
.diagram-subtitle {
font-size: 14px; color: #6B7280;
text-align: center; margin-bottom: 40px;
}
/* === Three-row flex layout: rows CANNOT overlap === */
.cross-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
position: relative;
}
/* Middle row: left card + center + right card */
.middle-row {
display: flex;
align-items: center;
gap: 40px;
}
/* Center node */
.center-node {
background: linear-gradient(135deg, #1E293B, #334155);
color: white; font-size: 18px; font-weight: 700;
padding: 24px 32px; border-radius: 14px;
text-align: center; z-index: 2;
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
flex-shrink: 0;
}
.center-sub {
font-size: 12px; font-weight: 400; color: #94A3B8;
margin-top: 4px;
}
/* Dimension cards */
.dim-card {
background: #FFFFFF; border-radius: 12px;
padding: 20px; border: 2px solid;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
z-index: 2;
width: 260px;
flex-shrink: 0;
}
.dim-card .dim-title {
font-size: 15px; font-weight: 700; margin-bottom: 10px;
padding-bottom: 8px; border-bottom: 2px solid;
display: flex; align-items: center; gap: 8px;
}
.dim-card .dim-icon {
width: 26px; height: 26px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.dim-card .dim-items { list-style: none; }
.dim-card .dim-items li {
font-size: 13px; color: #475569; padding: 4px 0 4px 18px;
position: relative; line-height: 1.5;
}
.dim-card .dim-items li::before {
content: ''; position: absolute; left: 0; top: 10px;
width: 6px; height: 6px; border-radius: 50%;
}
/* Color variants — border + title border + bullet */
.dim-blue { border-color: #3B82F6; }
.dim-blue .dim-title { color: #1E40AF; border-color: #3B82F6; }
.dim-blue .dim-icon { background: #DBEAFE; color: #1E40AF; }
.dim-blue li::before { background: #3B82F6; }
.dim-green { border-color: #10B981; }
.dim-green .dim-title { color: #065F46; border-color: #10B981; }
.dim-green .dim-icon { background: #D1FAE5; color: #065F46; }
.dim-green li::before { background: #10B981; }
.dim-amber { border-color: #F59E0B; }
.dim-amber .dim-title { color: #92400E; border-color: #F59E0B; }
.dim-amber .dim-icon { background: #FEF3C7; color: #92400E; }
.dim-amber li::before { background: #F59E0B; }
.dim-purple { border-color: #8B5CF6; }
.dim-purple .dim-title { color: #5B21B6; border-color: #8B5CF6; }
.dim-purple .dim-icon { background: #EDE9FE; color: #5B21B6; }
.dim-purple li::before { background: #8B5CF6; }
.dim-red { border-color: #EF4444; }
.dim-red .dim-title { color: #991B1B; border-color: #EF4444; }
.dim-red .dim-icon { background: #FEE2E2; color: #991B1B; }
.dim-red li::before { background: #EF4444; }
.dim-cyan { border-color: #06B6D4; }
.dim-cyan .dim-title { color: #155E75; border-color: #06B6D4; }
.dim-cyan .dim-icon { background: #CFFAFE; color: #155E75; }
.dim-cyan li::before { background: #06B6D4; }
/* SVG connector layer */
.cross-connectors {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 1;
}
</style>
</head>
<body>
<div id="root">
<div class="diagram-title">平衡计分卡四维评价体系</div>
<div class="diagram-subtitle">基于战略目标的绩效管理框架</div>
<div class="cross-layout" id="crossLayout">
<!-- SVG connectors drawn by script -->
<svg class="cross-connectors" id="connSvg"></svg>
<!-- Row 1: top dimension -->
<div class="dim-card dim-blue" data-pos="top">
<div class="dim-title"><div class="dim-icon">F</div> 财务维度</div>
<ul class="dim-items">
<li>营收增长率</li>
<li>利润率</li>
<li>ROI</li>
</ul>
</div>
<!-- Row 2: left + center + right -->
<div class="middle-row">
<div class="dim-card dim-green" data-pos="left">
<div class="dim-title"><div class="dim-icon">I</div> 内部流程</div>
<ul class="dim-items">
<li>流程效率</li>
<li>质量管控</li>
<li>创新能力</li>
</ul>
</div>
<div class="center-node">战略目标</div>
<div class="dim-card dim-amber" data-pos="right">
<div class="dim-title"><div class="dim-icon">C</div> 客户维度</div>
<ul class="dim-items">
<li>客户满意度</li>
<li>市场份额</li>
<li>客户留存率</li>
</ul>
</div>
</div>
<!-- Row 3: bottom dimension -->
<div class="dim-card dim-purple" data-pos="bottom">
<div class="dim-title"><div class="dim-icon">L</div> 学习与成长</div>
<ul class="dim-items">
<li>员工能力提升</li>
<li>信息系统建设</li>
<li>组织文化</li>
</ul>
</div>
</div>
</div>
<script>
function drawCrossConnectors() {
const layout = document.getElementById('crossLayout');
const svg = document.getElementById('connSvg');
const gRect = layout.getBoundingClientRect();
svg.setAttribute('width', gRect.width);
svg.setAttribute('height', gRect.height);
svg.setAttribute('viewBox', `0 0 ${gRect.width} ${gRect.height}`);
svg.innerHTML = '';
// Bidirectional arrow markers — eliminates direction ambiguity
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.innerHTML = `
<marker id="arrowEnd" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#94A3B8" />
</marker>
<marker id="arrowStart" markerWidth="8" markerHeight="6" refX="1" refY="3" orient="auto">
<polygon points="8 0, 0 3, 8 6" fill="#94A3B8" />
</marker>
`;
svg.appendChild(defs);
const center = layout.querySelector('.center-node');
const cR = center.getBoundingClientRect();
const cx = cR.left - gRect.left + cR.width / 2;
const cy = cR.top - gRect.top + cR.height / 2;
// Draw connector from center edge to each card edge
const cards = layout.querySelectorAll('.dim-card');
cards.forEach(card => {
const pos = card.dataset.pos;
const r = card.getBoundingClientRect();
const cardCx = r.left - gRect.left + r.width / 2;
const cardCy = r.top - gRect.top + r.height / 2;
let x1, y1, x2, y2;
switch (pos) {
case 'top':
x1 = cx; y1 = cR.top - gRect.top; // center top edge midpoint
x2 = cardCx; y2 = r.bottom - gRect.top; // card bottom edge midpoint
break;
case 'bottom':
x1 = cx; y1 = cR.bottom - gRect.top; // center bottom edge midpoint
x2 = cardCx; y2 = r.top - gRect.top; // card top edge midpoint
break;
case 'left':
x1 = cR.left - gRect.left; y1 = cy; // center left edge midpoint
x2 = r.right - gRect.left; y2 = cardCy; // card right edge midpoint
break;
case 'right':
x1 = cR.right - gRect.left; y1 = cy; // center right edge midpoint
x2 = r.left - gRect.left; y2 = cardCy; // card left edge midpoint
break;
// --- 5+ dimension: bottom row with two cards side by side ---
case 'bottom-left':
x1 = cx; y1 = cR.bottom - gRect.top; // center BOTTOM MIDPOINT (not corner!)
x2 = cardCx; y2 = r.top - gRect.top; // card TOP MIDPOINT
break;
case 'bottom-right':
x1 = cx; y1 = cR.bottom - gRect.top; // center BOTTOM MIDPOINT (not corner!)
x2 = cardCx; y2 = r.top - gRect.top; // card TOP MIDPOINT
break;
}
// 🚫 FORBIDDEN: drawing lines from center CORNERS (e.g. cR.left + cR.bottom)
// All lines MUST originate from center EDGE MIDPOINTS (cx or cy)
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1); line.setAttribute('y1', y1);
line.setAttribute('x2', x2); line.setAttribute('y2', y2);
line.setAttribute('stroke', '#94A3B8');
line.setAttribute('stroke-width', '2');
line.setAttribute('stroke-dasharray', '6,4');
line.setAttribute('marker-start', 'url(#arrowStart)');
line.setAttribute('marker-end', 'url(#arrowEnd)');
svg.appendChild(line);
});
}
window.addEventListener('load', () => setTimeout(drawCrossConnectors, 300));
</script>
</body>
</html>
```
**Key design decisions:**
- **Three-row flex** instead of 3×3 Grid — rows physically cannot overlap
- **Fixed card width (260px)** — prevents content-driven overflow
- **`gap: 24px`** between rows, **`gap: 40px`** within middle row — generous spacing
- **Center node uses `flex-shrink: 0`** — never collapses under pressure
### Adapting for 5+ Dimensions
For Porter's Five Forces (5 dimensions) or more:
```html
<!-- Row 1: top -->
<div class="dim-card dim-blue" data-pos="top">...</div>
<!-- Row 2: left + center + right -->
<div class="middle-row">
<div class="dim-card dim-green" data-pos="left">...</div>
<div class="center-node">...</div>
<div class="dim-card dim-amber" data-pos="right">...</div>
</div>
<!-- Row 3: bottom-left + bottom-right (two cards side by side) -->
<div class="middle-row" style="gap: 40px;">
<div class="dim-card dim-purple" data-pos="bottom-left">...</div>
<div class="dim-card dim-red" data-pos="bottom-right">...</div>
</div>
```
For the connector script, add cases for `bottom-left` and `bottom-right`:
```javascript
// 🚫 FORBIDDEN: using cR.left/cR.right as x1 — that draws from center CORNER, angle is ugly
// ✅ CORRECT: always use cx (center bottom midpoint) as x1
case 'bottom-left':
x1 = cx; y1 = cR.bottom - gRect.top; // center BOTTOM MIDPOINT
x2 = cardCx; y2 = r.top - gRect.top; // card TOP MIDPOINT
break;
case 'bottom-right':
x1 = cx; y1 = cR.bottom - gRect.top; // center BOTTOM MIDPOINT
x2 = cardCx; y2 = r.top - gRect.top; // card TOP MIDPOINT
break;
```
### Adapting for 3 Dimensions
Simply remove one side card from the middle row:
```html
<!-- Row 1: top -->
<div class="dim-card dim-blue" data-pos="top">...</div>
<!-- Row 2: center + right only -->
<div class="middle-row">
<div class="center-node">...</div>
<div class="dim-card dim-amber" data-pos="right">...</div>
</div>
<!-- Row 3: bottom -->
<div class="dim-card dim-purple" data-pos="bottom">...</div>
```
---
## Layout B: 2×2 Quadrant Grid (SWOT / PEST)
Best for: exactly 4 dimensions arranged as equal quadrants (no center node needed).
**This layout has NO center node — the four quadrants themselves tell the story.**
### HTML + CSS
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'SimHei', sans-serif;
background: #FFFFFF;
}
#root { width: fit-content; min-width: 900px; padding: 48px 60px; }
.diagram-title {
font-size: 22px; font-weight: 700; color: #1F2937;
text-align: center; margin-bottom: 12px;
}
.diagram-subtitle {
font-size: 14px; color: #6B7280;
text-align: center; margin-bottom: 36px;
}
.quadrant-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
max-width: 800px;
margin: 0 auto;
}
.quadrant {
border-radius: 12px; padding: 24px;
border: 1.5px solid;
min-height: 200px;
}
.quadrant .q-title {
font-size: 16px; font-weight: 700; margin-bottom: 14px;
display: flex; align-items: center; gap: 8px;
}
.quadrant .q-icon {
width: 28px; height: 28px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700;
}
.quadrant .q-items { list-style: none; }
.quadrant .q-items li {
font-size: 13px; padding: 5px 0 5px 18px;
position: relative; line-height: 1.5;
}
.quadrant .q-items li::before {
content: ''; position: absolute; left: 0; top: 11px;
width: 6px; height: 6px; border-radius: 50%;
}
/* SWOT colors — low-sat backgrounds, sat borders */
.q-strengths { background: #F0FDF4; border-color: #86EFAC; }
.q-strengths .q-title { color: #065F46; }
.q-strengths .q-icon { background: #D1FAE5; color: #065F46; }
.q-strengths li { color: #1F2937; }
.q-strengths li::before { background: #10B981; }
.q-weaknesses { background: #FEF2F2; border-color: #FECACA; }
.q-weaknesses .q-title { color: #991B1B; }
.q-weaknesses .q-icon { background: #FEE2E2; color: #991B1B; }
.q-weaknesses li { color: #1F2937; }
.q-weaknesses li::before { background: #EF4444; }
.q-opportunities { background: #EFF6FF; border-color: #93C5FD; }
.q-opportunities .q-title { color: #1E40AF; }
.q-opportunities .q-icon { background: #DBEAFE; color: #1E40AF; }
.q-opportunities li { color: #1F2937; }
.q-opportunities li::before { background: #3B82F6; }
.q-threats { background: #FFF7ED; border-color: #FDE68A; }
.q-threats .q-title { color: #92400E; }
.q-threats .q-icon { background: #FEF3C7; color: #92400E; }
.q-threats li { color: #1F2937; }
.q-threats li::before { background: #F59E0B; }
</style>
</head>
<body>
<div id="root">
<div class="diagram-title">SWOT 分析</div>
<div class="diagram-subtitle">企业战略定位评估</div>
<div class="quadrant-grid">
<div class="quadrant q-strengths">
<div class="q-title"><div class="q-icon">S</div> 优势 Strengths</div>
<ul class="q-items">
<li>核心技术领先</li>
<li>品牌知名度高</li>
<li>供应链成熟</li>
</ul>
</div>
<div class="quadrant q-weaknesses">
<div class="q-title"><div class="q-icon">W</div> 劣势 Weaknesses</div>
<ul class="q-items">
<li>国际化经验不足</li>
<li>产品线单一</li>
<li>人才储备有限</li>
</ul>
</div>
<div class="quadrant q-opportunities">
<div class="q-title"><div class="q-icon">O</div> 机会 Opportunities</div>
<ul class="q-items">
<li>新兴市场需求增长</li>
<li>政策利好</li>
<li>技术融合趋势</li>
</ul>
</div>
<div class="quadrant q-threats">
<div class="q-title"><div class="q-icon">T</div> 威胁 Threats</div>
<ul class="q-items">
<li>竞争加剧</li>
<li>原材料价格波动</li>
<li>法规变化风险</li>
</ul>
</div>
</div>
</div>
</body>
</html>
```
**No connectors needed** — the 2×2 grid itself communicates the four-quadrant relationship. Adding arrows would be visual noise.
---
## Connector Rules
1. **Connectors are ALWAYS drawn by script reading bounding boxes** — never hardcode x/y values in HTML/CSS
2. **Straight lines only** (horizontal or vertical) — no diagonal lines unless top/bottom cards are offset from center
3. **Dashed lines** (`stroke-dasharray: 6,4`) for conceptual relationships
4. **Solid lines** for causal/sequential relationships
5. **Arrow direction**: model chooses based on diagram semantics — pick ONE style per diagram, don't mix:
- **Outward** (`marker-end` only): center influences/drives dimensions (e.g. BSC: strategy → dimensions)
- **Inward** (`marker-start` only): dimensions report/pressure center (e.g. Porter: forces → competition)
- **Bidirectional** (`marker-start` + `marker-end`): mutual influence (e.g. feedback loops)
6. **🚫 All lines MUST originate from center EDGE MIDPOINTS** (cx or cy) — never from center corners
7. **Line color**: `#94A3B8` (gray-blue) — never use dimension-specific colors for connectors (visual chaos)
### SVG Arrow Markers (copy-paste into defs)
```javascript
// Include both markers; use only what the diagram needs
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.innerHTML = `
<marker id="arrowEnd" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#94A3B8" />
</marker>
<marker id="arrowStart" markerWidth="8" markerHeight="6" refX="1" refY="3" orient="auto">
<polygon points="8 0, 0 3, 8 6" fill="#94A3B8" />
</marker>
`;
svg.appendChild(defs);
// Outward (center → card):
line.setAttribute('marker-end', 'url(#arrowEnd)');
// Inward (card → center):
line.setAttribute('marker-start', 'url(#arrowStart)');
// Bidirectional:
line.setAttribute('marker-start', 'url(#arrowStart)');
line.setAttribute('marker-end', 'url(#arrowEnd)');
```
---
## Content Rules
1. **Each dimension card: max 6 bullet items** — more than 6 → group into sub-categories or use a separate detail table
2. **Bullet text: max 15 Chinese characters per line** — longer text wraps naturally (word-break: break-word)
3. **Center node text: max 8 Chinese characters** — keep it a short label, not a sentence
4. **Dimension title: max 10 Chinese characters** — concise category name
---
## Font Size Rules
| Element | Size | Weight |
|---------|------|--------|
| Diagram title | 22px | 700 |
| Center node | 18px | 700 |
| Dimension title | 15-16px | 700 |
| Bullet items | 13px | 400 |
| Subtitle/footnote | 14px | 400 |
---
## Quality Checklist
1. **No overlap** — all dimension cards fully visible, none clipped or covering another
2. **Connectors point to card edges** — not to random points in space
3. **Center node visually dominant** — largest, darkest, or most contrast
4. **Dimension cards visually equal** — similar size, similar padding, no one card 3× larger
5. **Colors follow scheme** — each dimension has its own color (border + title), backgrounds stay pale (white or near-white)
6. **Layout is centered** — equal margins on all sides
7. **Canvas large enough** — min 900px wide for cross layout, min 800px for 2×2 quadrant
+324
View File
@@ -0,0 +1,324 @@
# Seaborn Template Library
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
Seaborn is built on top of matplotlib, specializing in **statistical visualization** — distributions, regression, categorical comparisons, etc.
Its default styles are already much better looking than matplotlib, but still need tuning to reach professional standards.
## Environment Setup
```python
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd
# ═══ Chinese Font Setup ═══
# Font path: adjust for your system. Common locations:
# macOS: '/System/Library/Fonts/Supplemental/SimHei.ttf'
# Linux: '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc'
# Custom: './fonts/SimHei.ttf'
import os
SIMHEI_PATH = os.environ.get('SIMHEI_FONT', '/System/Library/Fonts/Supplemental/SimHei.ttf')
matplotlib.font_manager.fontManager.addfont(SIMHEI_PATH)
# Seaborn theme + custom overrides
sns.set_theme(style='whitegrid', font='SimHei', rc={
'axes.unicode_minus': False,
'figure.facecolor': '#FFFFFF',
'axes.facecolor': '#FFFFFF',
'axes.edgecolor': '#E5E7EB',
'axes.linewidth': 0.8,
'axes.spines.top': False,
'axes.spines.right': False,
'grid.color': '#F3F4F6',
'grid.alpha': 0.5,
'grid.linewidth': 0.5,
'xtick.major.size': 0,
'ytick.major.size': 0,
'axes.titlesize': 16,
'axes.titleweight': 'bold',
'axes.titlepad': 16,
'legend.frameon': False,
'figure.dpi': 200,
'savefig.dpi': 200,
'savefig.bbox': 'tight',
})
# Colors (consistent with matplotlib.md)
COOL = ['#3B82F6', '#06B6D4', '#8B5CF6', '#F59E0B', '#EF4444', '#10B981']
CB_SAFE = ['#0077BB', '#33BBEE', '#009988', '#EE7733', '#CC3311', '#EE3377']
```
## Seaborn Palette Setup
```python
# Method 1: use hex list directly
sns.set_palette(COOL)
# Method 2: register custom palette
from matplotlib.colors import ListedColormap
bc_palette = ListedColormap(COOL, name='charts')
```
---
## Template 1: Distribution Plot (Histogram + KDE)
**Scenario**: View data distribution shape, detect skewness and outliers.
```python
def dist_plot(data, title, xlabel, color='#3B82F6', save_path='dist.png'):
fig, ax = plt.subplots(figsize=(10, 6))
sns.histplot(data, kde=True, color=color, edgecolor='white',
linewidth=0.5, alpha=0.7, ax=ax)
# Bold the KDE line
for line in ax.get_lines():
line.set_linewidth(2.5)
ax.set_title(title, loc='left')
ax.set_xlabel(xlabel)
ax.set_ylabel('Frequency')
plt.tight_layout()
plt.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Template 2: Box Plot (Categorical Comparison)
**Scenario**: Compare distribution characteristics across groups (median, quartiles, outliers).
```python
def box_compare(df, x_col, y_col, title, palette=None, save_path='box.png'):
if palette is None:
palette = COOL
fig, ax = plt.subplots(figsize=(10, 6))
sns.boxplot(data=df, x=x_col, y=y_col, palette=palette,
width=0.5, linewidth=1.2, fliersize=4,
boxprops=dict(edgecolor='white'),
medianprops=dict(color='white', linewidth=2),
ax=ax)
ax.set_title(title, loc='left')
plt.tight_layout()
plt.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
### Box Plot + Label Avoidance (For Complex Scenarios)
When you need to annotate outliers, specific data points, or group names on a box plot, **label avoidance is required**:
```python
def box_annotated(df, x_col, y_col, title, annotations=None,
palette=None, save_path='box_annotated.png'):
"""
annotations: [{'x': 0, 'y': 45, 'text': '产线A 异常'}, ...]
"""
from adjustText import adjust_text
if palette is None:
palette = COOL
fig, ax = plt.subplots(figsize=(12, 7)) # Slightly larger canvas, leave room for annotations
sns.boxplot(data=df, x=x_col, y=y_col, palette=palette,
width=0.5, linewidth=1.2, fliersize=4,
boxprops=dict(edgecolor='white'),
medianprops=dict(color='white', linewidth=2),
ax=ax)
# Annotation text — use adjustText for auto-avoidance
if annotations:
texts = []
for ann in annotations:
t = ax.text(ann['x'], ann['y'], ann['text'],
fontsize=9, color='#374151',
bbox=dict(boxstyle='round,pad=0.3',
facecolor='#FFF7ED', edgecolor='#F59E0B',
alpha=0.9))
texts.append(t)
adjust_text(texts, ax=ax,
arrowprops=dict(arrowstyle='->', color='#9CA3AF', lw=0.8),
force_text=(0.8, 1.0),
force_points=(0.5, 0.8))
ax.set_title(title, loc='left')
plt.tight_layout()
plt.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
### Box Plot + Colorbar (e.g., MTTR Analysis)
When a box plot needs to work with a colorbar (e.g., colored by dimension), you must leave enough space for the colorbar:
```python
def box_with_colorbar(df, x_col, y_col, color_col, title,
save_path='box_cbar.png'):
import matplotlib.gridspec as gridspec
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
fig = plt.figure(figsize=(14, 7), constrained_layout=True)
gs = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[1, 0.03], wspace=0.05)
ax = fig.add_subplot(gs[0, 0])
cbar_ax = fig.add_subplot(gs[0, 1])
sns.boxplot(data=df, x=x_col, y=y_col, ax=ax,
width=0.5, linewidth=1.2)
# Colorbar in its own subplot, won't obscure box plot
norm = Normalize(vmin=df[color_col].min(), vmax=df[color_col].max())
sm = ScalarMappable(norm=norm, cmap='Blues')
fig.colorbar(sm, cax=cbar_ax, label=color_col)
ax.set_title(title, loc='left')
fig.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Template 3: Violin Plot
**Scenario**: Enhanced version of box plot, showing distribution density simultaneously.
```python
def violin_plot(df, x_col, y_col, title, palette=None, save_path='violin.png'):
if palette is None:
palette = COOL
fig, ax = plt.subplots(figsize=(10, 6))
sns.violinplot(data=df, x=x_col, y=y_col, palette=palette,
inner='box', linewidth=1, ax=ax)
ax.set_title(title, loc='left')
plt.tight_layout()
plt.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Template 4: Regression Scatter Plot
**Scenario**: Two-variable relationship + linear regression + confidence interval.
```python
def reg_plot(df, x_col, y_col, title, color='#3B82F6', save_path='reg.png'):
fig, ax = plt.subplots(figsize=(10, 7))
sns.regplot(data=df, x=x_col, y=y_col, color=color,
scatter_kws={'s': 50, 'alpha': 0.6, 'edgecolor': 'white', 'linewidth': 1},
line_kws={'linewidth': 2},
ax=ax)
ax.set_title(title, loc='left')
plt.tight_layout()
plt.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Template 5: Correlation Heatmap
**Scenario**: Correlation coefficient matrix among multiple variables.
```python
def corr_heatmap(df, title, cmap='RdBu_r', save_path='corr.png'):
corr = df.corr()
fig, ax = plt.subplots(figsize=(10, 8))
# Show only lower triangle
mask = np.triu(np.ones_like(corr, dtype=bool))
sns.heatmap(corr, mask=mask, cmap=cmap, center=0,
vmin=-1, vmax=1, annot=True, fmt='.2f',
square=True, linewidths=1, linecolor='white',
cbar_kws={'shrink': 0.8, 'label': 'Correlation'},
annot_kws={'size': 9},
ax=ax)
ax.set_title(title, loc='left', pad=16)
plt.tight_layout()
plt.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Template 6: Pair Plot
**Scenario**: Overview of pairwise relationships among multiple variables (distribution on diagonal, scatter plots elsewhere).
```python
def pair_plot(df, hue_col=None, palette=None, save_path='pair.png'):
if palette is None:
palette = COOL
g = sns.pairplot(df, hue=hue_col, palette=palette,
diag_kind='kde', plot_kws={'alpha': 0.6, 's': 30},
height=2.5, aspect=1)
g.figure.suptitle('Pairwise Variable Relationships', y=1.02, fontsize=16, fontweight='bold')
g.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Template 7: Facet Grid (FacetGrid)
**Scenario**: Split into multiple subplots by categorical variable for comparison.
```python
def facet_hist(df, col_var, value_col, title, color='#3B82F6', save_path='facet.png'):
g = sns.FacetGrid(df, col=col_var, col_wrap=3, height=3.5, aspect=1.3)
g.map(sns.histplot, value_col, color=color, edgecolor='white',
linewidth=0.5, alpha=0.7, kde=True)
g.set_titles('{col_name}', fontsize=12, fontweight='bold')
g.figure.suptitle(title, y=1.02, fontsize=16, fontweight='bold')
g.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
plt.close()
```
---
## Seaborn vs matplotlib Selection Guide
| What to Plot | Use Seaborn | Use matplotlib |
|---------|-----------|--------------|
| Histogram/KDE distribution | ✅ `histplot` / `kdeplot` | Works, but more code |
| Box plot/Violin plot | ✅ One-liner | Works, but rougher style |
| Regression scatter + confidence interval | ✅ `regplot` auto-calculates | Manual fitting + plotting |
| Correlation heatmap | ✅ `heatmap` + mask | Manual `imshow` tedious |
| Pairwise relationship matrix | ✅ `pairplot` unique | No equivalent |
| Facet grid | ✅ `FacetGrid` | `plt.subplots` manual loop |
| Regular bar chart | Less flexible than matplotlib | ✅ More control |
| Line trend chart | Less than matplotlib | ✅ More control |
| Custom annotations/arrows | Not suitable | ✅ `ax.annotate` |
**Principle: Use Seaborn for statistical charts, matplotlib for customized charts.**
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# ---
# name: charts-setup
# author: Z.AI
# version: "1.0"
# description: Environment setup for the Charts skill. Checks and installs all required dependencies.
# ---
#
# Installs only dependencies required by the Charts skill.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
ok() { echo -e " ${GREEN}✓${NC} $1"; }
fail() { echo -e " ${RED}✗${NC} $1"; }
warn() { echo -e " ${YELLOW}○${NC} $1"; }
info() { echo -e " ${BLUE}→${NC} $1"; }
echo "============================================"
echo " Charts Skill — Environment Setup"
echo "============================================"
echo ""
OS="$(uname -s)"
ARCH="$(uname -m)"
echo "Platform: $OS $ARCH"
echo ""
# ── 0. macOS: Homebrew ──
if [ "$OS" = "Darwin" ]; then
echo "--- Homebrew (macOS package manager) ---"
if command -v brew &>/dev/null; then
BREW_VER=$(brew --version 2>/dev/null | head -1)
ok "brew ($BREW_VER)"
else
fail "brew not found — most dependencies below need Homebrew on macOS"
info "Install: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
fi
echo ""
fi
# ── 1. Python 3 ──
echo "--- Python ---"
if command -v python3 &>/dev/null; then
PY_VER=$(python3 --version 2>&1)
ok "python3 ($PY_VER)"
if [ "$OS" = "Darwin" ]; then
PY_PATH=$(which python3 2>/dev/null)
if [[ "$PY_PATH" == "/usr/bin/python3" ]]; then
warn "Using macOS system Python (limited). Recommend: brew install python3"
fi
fi
else
fail "python3 not found"
case "$OS" in
Darwin) info "Install: brew install python3" ;;
Linux) info "Install: sudo apt install python3 python3-pip (Debian/Ubuntu)"
info " sudo dnf install python3 python3-pip (Fedora/RHEL)" ;;
*) info "Install: https://www.python.org/downloads/" ;;
esac
fi
# ── 2. pip ──
echo ""
echo "--- pip ---"
if python3 -m pip --version &>/dev/null 2>&1; then
PIP_VER=$(python3 -m pip --version 2>/dev/null | head -1)
ok "pip ($PIP_VER)"
else
fail "pip not found"
case "$OS" in
Darwin) info "Install: python3 -m ensurepip --upgrade"
info " or: brew install python3 (includes pip)" ;;
Linux) info "Install: sudo apt install python3-pip (Debian/Ubuntu)" ;;
*) info "Install: python3 -m ensurepip --upgrade" ;;
esac
fi
# ── 3. Python packages (matplotlib / seaborn for data charts) ──
echo ""
echo "--- Python Packages (Data Charts) ---"
PY_PKGS=(
"matplotlib:matplotlib"
"seaborn:seaborn"
"numpy:numpy"
"adjustText:adjustText"
)
MISSING_PY=()
for entry in "${PY_PKGS[@]}"; do
mod="${entry%%:*}"
pkg="${entry##*:}"
if python3 -c "import $mod" 2>/dev/null; then
ver=$(python3 -c "import $mod; print(getattr($mod, '__version__', 'installed'))" 2>/dev/null)
ok "$pkg ($ver)"
else
fail "$pkg not installed"
MISSING_PY+=("$pkg")
fi
done
if [ ${#MISSING_PY[@]} -gt 0 ]; then
echo ""
if [ -t 0 ]; then
read -p " Install missing Python packages? [Y/n] " -n 1 -r REPLY
echo ""
REPLY=${REPLY:-Y}
else
warn "Non-interactive mode — skipping auto-install. Run interactively or install manually."
REPLY=N
fi
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
python3 -m pip install -q "${MISSING_PY[@]}" 2>/dev/null \
|| python3 -m pip install -q --user "${MISSING_PY[@]}" 2>/dev/null \
|| python3 -m pip install -q --break-system-packages "${MISSING_PY[@]}" 2>/dev/null \
|| { fail "pip install failed. Try manually: pip install ${MISSING_PY[*]}"; }
ok "Installed: ${MISSING_PY[*]}"
fi
fi
# ── 4. Node.js ──
echo ""
echo "--- Node.js (Interactive Charts & Diagrams) ---"
if command -v node &>/dev/null; then
NODE_VER=$(node --version)
ok "node ($NODE_VER)"
else
fail "node not found"
case "$OS" in
Darwin) info "Install: brew install node" ;;
Linux) info "Install: curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
info " sudo apt install -y nodejs" ;;
*) info "Install: https://nodejs.org/" ;;
esac
fi
# ── 5. npm ──
echo ""
echo "--- npm ---"
if command -v npm &>/dev/null; then
NPM_VER=$(npm --version 2>/dev/null)
ok "npm ($NPM_VER)"
else
fail "npm not found"
case "$OS" in
Darwin) info "Install: brew install node (includes npm)" ;;
Linux) info "Install: comes with nodejs" ;;
*) info "Install: https://nodejs.org/" ;;
esac
fi
# ── 6. Playwright + Chromium (for HTML→PNG/PDF rendering) ──
echo ""
echo "--- Playwright (Structural Diagrams & HTML Charts) ---"
if node -e "require('playwright')" 2>/dev/null; then
PW_VER=$(node -e "console.log(require('playwright/package.json').version)" 2>/dev/null)
ok "playwright ($PW_VER)"
else
fail "playwright not installed"
info "Install: npm install -g playwright"
fi
if [ "$OS" = "Darwin" ]; then
PW_CACHE="$HOME/Library/Caches/ms-playwright"
else
PW_CACHE="$HOME/.cache/ms-playwright"
fi
if ls "$PW_CACHE"/chromium-* &>/dev/null 2>&1; then
CR_DIR=$(ls -d "$PW_CACHE"/chromium-* 2>/dev/null | tail -1)
ok "chromium ($(basename "$CR_DIR"))"
else
fail "chromium not installed"
info "Install: npx playwright install chromium"
if [ "$OS" = "Linux" ]; then
info " npx playwright install-deps (system libs, needs sudo)"
fi
fi
# ── 7. CJK Fonts (for Chinese chart labels) ──
echo ""
echo "--- CJK Fonts (Chinese text in charts) ---"
CJK_FOUND=false
# Check matplotlib registered fonts
if python3 -c "
import matplotlib.font_manager as fm
fonts = [f.name for f in fm.fontManager.ttflist]
if 'SimHei' in fonts or 'Heiti SC' in fonts or 'Noto Sans CJK' in fonts or 'PingFang SC' in fonts:
print('ok')
else:
print('missing')
" 2>/dev/null | grep -q "ok"; then
ok "CJK font registered in matplotlib (SimHei/Heiti SC/Noto Sans CJK/PingFang SC)"
CJK_FOUND=true
fi
# Check system CJK fonts
if [ "$OS" = "Darwin" ]; then
if ls /System/Library/Fonts/PingFang.ttc &>/dev/null 2>&1 \
|| ls /System/Library/Fonts/STHeiti*.ttc &>/dev/null 2>&1; then
ok "macOS CJK system fonts available (PingFang/STHeiti)"
CJK_FOUND=true
fi
elif [ "$OS" = "Linux" ]; then
if fc-list :lang=zh 2>/dev/null | head -1 | grep -q .; then
ok "system CJK fonts available (fc-list)"
CJK_FOUND=true
fi
fi
if [ "$CJK_FOUND" = false ]; then
warn "No CJK font detected — Chinese labels may show as □"
info "The skill configures font fallback via rcParams at runtime."
info "Ensure a CJK font file exists (e.g., SimHei.ttf) and is registered."
if [ "$OS" = "Darwin" ]; then
info "macOS ships with PingFang — try: plt.rcParams['font.sans-serif'] = ['PingFang SC', 'DejaVu Sans']"
elif [ "$OS" = "Linux" ]; then
info "Install: sudo apt install fonts-noto-cjk"
fi
fi
# ── Summary ──
echo ""
echo "============================================"
echo " Setup complete."
echo " Data charts: matplotlib + seaborn"
echo " Structural diagrams: Playwright + CSS"
echo " Interactive charts: ECharts / D3.js via Node.js"
echo "============================================"
+210
View File
@@ -0,0 +1,210 @@
---
name: cheat-sheet
description: 将 PDF/Word/Markdown 学习资料转化为精炼的知识浓缩卡文档。支持三种风格(知识点速查卡/思维导图式/Q&A式),输出双栏小字 PDF。当用户说"生成知识浓缩卡"、"生成 Cheatsheet"、"帮我做个速查表"、"把这个资料整理成一页纸"、"做个知识卡片"时触发。**不处理**:基于材料出题(→ quiz-mastery)、长期学习项目(→ study-buddy)。
---
# Cheatsheet 生成器
## 你做什么
把用户的学习资料(PDF/Word/Markdown/文本)压缩成一份**双栏、小字、信息密度高**的 Cheatsheet 文档(PDF 格式)。
**核心原则:只保留干货,砍掉废话。**
---
## ⚠️ 铁律:避免重复创建
生成 Cheatsheet 前,**先扫描输出目录**检查是否已有同主题文件:
- **已存在** → 问用户"已经有一份 [文件名] 了,覆盖 / 新建一份 / 跳过?" 等用户回复再动手
- **不存在** → 直接生成
- **绝不**未经询问直接覆盖
---
## 工作流程
### 步骤 1:接收资料
用户提供学习资料文件,支持格式:
- `.pdf` — 调 PDF skill 的 process 路线提取文本
- `.docx` — 调 PDF skill 的 process 路线提取文本
- `.md` / `.txt` — 直接读取
**提取文本命令:**
```bash
# PDF 提取文本
python3 "$PDF_SCRIPTS/pdf.py" extract.text <file_path>
# Word 转 PDF 后提取(如需要)
python3 "$PDF_SCRIPTS/pdf.py" convert.office <file_path>
```
其中 `$PDF_SCRIPTS` 为 PDF skill 的 scripts 目录路径,需从 PDF skill 的 SKILL.md 位置推导。
如果用户没有提供文件而是直接贴了文本内容,跳过提取步骤,直接用文本。
---
### 步骤 2:选择风格
**必须询问用户**想要哪种风格(不要自己替用户选):
向用户展示以下选项:
1. 📋 **知识点速查卡** — 核心概念 + 定义 + 关键公式/要点,一眼扫到,适合考前突击
2. 🌳 **思维导图式** — 按层级结构组织(大标题→子标题→要点),有大纲感,适合梳理体系
3. ❓ **Q&A 式** — 把知识点变成"问题→答案"对,适合自测复习
用户选择后进入步骤 3。
---
### 步骤 3:LLM 提炼内容
根据用户选择的风格,构建不同的 prompt 让 LLM 从原文中提炼 cheatsheet 内容。
#### 风格 1:知识点速查卡
提炼规则:
- 提取所有核心概念、定义、公式、关键数据
- 每个知识点用 **术语:一句话解释** 的格式
- 相关知识点分组,每组有小标题
- 重要公式/代码片段原样保留
- 砍掉所有举例、过渡句、背景铺垫
输出结构:
```
## [分组标题]
- **术语A**:一句话定义
- **术语B**:一句话定义
- 📐 公式:`公式内容`
## [分组标题]
...
```
#### 风格 2:思维导图式
提炼规则:
- 提取文档的层级结构(章→节→要点)
- 每个节点用最简短的语言概括
- 最多 3 级深度(再深就塞不进一页了)
- 用缩进和符号表达层级关系
输出结构:
```
# 主题
## 一级分支
├─ 二级要点
│ ├─ 细节 1
│ └─ 细节 2
└─ 二级要点
└─ 细节
```
#### 风格 3:Q&A 式
提炼规则:
- 把每个知识点转化成一个问题
- 答案控制在 1-3 句话
- 问题从基础到进阶排列
- 易混淆的概念出辨析题
输出结构:
```
## [主题分组]
**Q:什么是 XXX?**
A:一句话回答。
**Q:XXX 和 YYY 的区别?**
A:简短对比。
```
---
### 步骤 4:用户确认与调整
LLM 生成内容后,**先以文本形式展示给用户**,询问:
> "内容整理好了,你看看有没有要调整的?比如:
> - 某些部分要加重点标记?
> - 某些内容要删掉或补充?
> - 排版上有什么偏好?(比如字号再小一点、分区颜色区分等)"
用户确认"可以"后,进入步骤 5。
用户提出修改 → 调整内容 → 再次展示 → 等待确认。
---
### 步骤 5:生成 PDF
调用 PDF skill 的 **Report 路线(ReportLab)** 生成双栏 PDF。
**排版规格(默认值):**
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 页面大小 | A4 | 可按用户要求调整 |
| 栏数 | 双栏 | 默认双栏,用户可选单栏 |
| 正文字号 | 8pt | 信息密度优先,可按用户要求调整 |
| 标题字号 | 10pt(一级)/ 9pt(二级) | |
| 行距 | 1.2 | 紧凑但可读 |
| 页边距 | 上下左右各 12mm | 最大化内容区域 |
| 字体 | 中文用 UniSong/UniHei,英文用 Helvetica | |
**生成流程:**
1. 将 LLM 提炼的 Markdown 内容转换为 ReportLab 排版指令
2. 调 PDF skill 的 report 路线生成 PDF
3. 生成文件路径告知用户
**调用 PDF skill 时遵循其 SKILL.md 中的所有规则**,包括:
- CJK 字体检查
- 表格溢出防护
- 页面填充率检查
- 元数据设置
---
### 步骤 6:交付
输出给用户:
- 📄 PDF 文件路径
- 文件大小、页数
- 提示用户可以继续调整
---
## 注意事项
### 内容质量
- **不编造内容**:所有知识点必须来自原文,不能自由发挥
- **不遗漏关键内容**:核心概念、公式、定义必须保留
- **术语保持原文用词**:不要擅自替换专业术语
### 文件操作
- 生成的 PDF 默认保存到工作区根目录,文件名格式:`知识浓缩卡_[主题]_[日期].pdf`
- 查重见上方"⚠️ 铁律:避免重复创建"
---
## 与其他 Skill 的关系
| Skill | 关系 |
|-------|------|
| PDF skill | 调用其 process 路线提取文本,调用其 report 路线生成 PDF |
| study-buddy | study-buddy 可在用户完成学习项目后推荐生成 cheatsheet |
| quiz-mastery | 无直接关系,但 cheatsheet 内容可作为出题的知识点来源 |
---
## 文件结构
```
skills/cheat-sheet/
├── SKILL.md ← 当前文件
```
本 skill 是纯流程指引,不包含独立脚本。所有文件操作和 PDF 生成通过调用 PDF skill 完成。
+120
View File
@@ -0,0 +1,120 @@
---
name: coding-agent
slug: code
version: 1.0.4
homepage: https://clawic.com/skills/code
description: Coding workflow with planning, implementation, verification, and testing for clean software development.
changelog: Improved description for better discoverability
metadata: {"clawdbot":{"emoji":"💻","requires":{"bins":[]},"os":["linux","darwin","win32"]}}
---
## When to Use
User explicitly requests code implementation. Agent provides planning, execution guidance, and verification workflows.
## Architecture
User preferences stored in `~/code/` when user explicitly requests.
```
~/code/
- memory.md # User-provided preferences only
```
Create on first use: `mkdir -p ~/code`
## Quick Reference
| Topic | File |
|-------|------|
| Memory setup | `memory-template.md` |
| Task breakdown | `planning.md` |
| Execution flow | `execution.md` |
| Verification | `verification.md` |
| Multi-task state | `state.md` |
| User criteria | `criteria.md` |
## Scope
This skill ONLY:
- Provides coding workflow guidance
- Stores preferences user explicitly provides in `~/code/`
- Reads included reference files
This skill NEVER:
- Executes code automatically
- Makes network requests
- Accesses files outside `~/code/` and the user's project
- Modifies its own SKILL.md or auxiliary files
- Takes autonomous action without user awareness
## Core Rules
### 1. Check Memory First
Read `~/code/memory.md` for user's stated preferences if it exists.
### 2. User Controls Execution
- This skill provides GUIDANCE, not autonomous execution
- User decides when to proceed to next step
- Sub-agent delegation requires user's explicit request
### 3. Plan Before Code
- Break requests into testable steps
- Each step independently verifiable
- See `planning.md` for patterns
### 4. Verify Everything
| After | Do |
|-------|-----|
| Each function | Suggest running tests |
| UI changes | Suggest taking screenshot |
| Before delivery | Suggest full test suite |
### 5. Store Preferences on Request
| User says | Action |
|-----------|--------|
| "Remember I prefer X" | Add to memory.md |
| "Never do Y again" | Add to memory.md Never section |
Only store what user explicitly asks to save.
## Workflow
```
Request -> Plan -> Execute -> Verify -> Deliver
```
## Common Traps
- **Delivering untested code** -> always verify first
- **Huge PRs** -> break into testable chunks
- **Ignoring preferences** -> check memory.md first
## Self-Modification
This skill NEVER modifies its own SKILL.md or auxiliary files.
User data stored only in `~/code/memory.md` after explicit request.
## External Endpoints
This skill makes NO network requests.
| Endpoint | Data Sent | Purpose |
|----------|-----------|---------|
| None | None | N/A |
## Security & Privacy
**Data that stays local:**
- Only preferences user explicitly asks to save
- Stored in `~/code/memory.md`
**Data that leaves your machine:**
- None. This skill makes no network requests.
**This skill does NOT:**
- Execute code automatically
- Access network or external services
- Access files outside `~/code/` and user's project
- Take autonomous actions without user awareness
- Delegate to sub-agents without user's explicit request
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn73vp5rarc3b14rc7wjcw8f8580t5d1",
"slug": "code",
"version": "1.0.4",
"publishedAt": 1771467169291
}
+48
View File
@@ -0,0 +1,48 @@
# Criteria for Storing Preferences
Reference for when to save user preferences to `~/code/memory.md`.
## When to Save (User Must Request)
Save only when user explicitly asks:
- "Remember that I prefer X"
- "Always do Y from now on"
- "Save this preference"
- "Don't forget that I like Z"
## When NOT to Save
- User didn't explicitly ask to save
- Project-specific requirement (applies to this project only)
- One-off request ("just this once")
- Temporary preference
## What to Save
**Preferences:**
- Coding style preferences user stated
- Tools or frameworks user prefers
- Patterns user explicitly likes
**Things to avoid:**
- Approaches user explicitly dislikes
- Patterns user asked not to repeat
## Format in memory.md
```markdown
## Preferences
- prefers TypeScript over JavaScript
- likes detailed comments
- wants tests for all functions
## Never
- no class-based React components
- avoid inline styles
```
## Important
- Only save what user EXPLICITLY asked to save
- Ask user before saving: "Should I remember this preference?"
- Never modify any skill files, only `~/code/memory.md`
+42
View File
@@ -0,0 +1,42 @@
# Execution Guidance
Reference for executing multi-step implementations.
## Recommended Flow
When user approves a step:
1. Execute that step
2. Verify it works
3. Report completion to user
4. Wait for user to approve next step
## Progress Tracking
Show user the current state:
```
- [DONE] Step 1 (completed)
- [WIP] Step 2 <- awaiting user approval
- [ ] Step 3
- [ ] Step 4
```
## When to Pause and Ask User
- Before starting any new step
- When encountering an error
- When a decision is needed (A vs B)
- When credentials or permissions are needed
## Error Handling
If an error occurs:
1. Report the error to user
2. Suggest possible fixes
3. Wait for user decision on how to proceed
## Patterns to Follow
- Report completion of each step
- Ask before proceeding to next step
- Let user decide retry strategy
- Keep user informed of progress
+38
View File
@@ -0,0 +1,38 @@
# Memory Setup - Code
## Initial Setup
Create directory on first use:
```bash
mkdir -p ~/code
touch ~/code/memory.md
```
## memory.md Template
Copy to `~/code/memory.md`:
```markdown
# Code Memory
## Preferences
<!-- User's coding workflow preferences. Format: "preference" -->
<!-- Examples: always run tests, prefer TypeScript, commit after each feature -->
## Never
<!-- Things that don't work for this user. Format: "thing to avoid" -->
<!-- Examples: inline styles, console.log debugging, large PRs -->
## Patterns
<!-- Approaches that work well. Format: "pattern: context" -->
<!-- Examples: TDD: for complex logic, screenshots: for UI work -->
---
Last updated: YYYY-MM-DD
```
## Notes
- Check `criteria.md` for additional user-specific criteria
- Use `planning.md` for breaking down complex requests
- Verify with tests and screenshots per `verification.md`
+31
View File
@@ -0,0 +1,31 @@
# Planning Reference
Consult when breaking down a multi-step request.
## When to Plan
- Multiple files or components
- Dependencies between parts
- UI that needs visual verification
- User says "build", "create", "implement"
## Step Format
```
Step N: [What]
- Output: [What exists after]
- Test: [How to verify]
```
## Good Steps
- Clear output (file, endpoint, screen)
- Testable independently
- No ambiguity in what "done" means
## Bad Steps
- "Implement the thing" (vague output)
- No test defined
- Depends on undefined prior step
## Don't Plan
- One-liner functions
- Simple modifications
- Questions about existing code
+60
View File
@@ -0,0 +1,60 @@
# State Tracking Guidance
Reference for tracking multiple tasks or requests.
## Request Tracking
Label each user request:
```
[R1] Build login page
[R2] Add dark mode
[R3] Fix header alignment
```
Track state for user visibility:
```
[R1] [DONE] Done
[R2] [WIP] In progress (awaiting user approval for step 2)
[R3] [Q] Queued
```
## Managing Multiple Requests
When user sends a new request while another is in progress:
1. Acknowledge: "Got it, I'll add this to the queue"
2. Show updated queue to user
3. Ask user if priority should change
## Handling Interruptions
| Situation | Suggested Action |
|-----------|------------------|
| New unrelated request | Add to queue, ask user priority |
| Request affects current work | Pause, explain impact, ask user how to proceed |
| User says "stop" or "wait" | Stop immediately, await instructions |
| User changes requirements | Summarize impact, ask user to confirm changes |
## User Decisions
Always ask user before:
- Starting work on queued items
- Changing priority order
- Rolling back completed work
- Modifying the plan
## Progress File (Optional)
User may request a state file:
```markdown
## In Progress
[R2] Dark mode - Step 2/4 (awaiting user approval)
## Queued
[R3] Header fix
## Done
[R1] Login page [DONE]
```
Update only when user requests or approves changes.
+39
View File
@@ -0,0 +1,39 @@
# Verification Reference
Consult when verifying implementations visually or with tests.
## Screenshots
- Wait for full page load (no spinners)
- Review yourself before sending
- Split long pages into 3-5 sections (~800px each)
- Caption each: "Hero", "Features", "Footer"
## Before Sending
```
[ ] Content loaded
[ ] Shows the specific change
[ ] No visual bugs
[ ] Caption explains what user sees
```
## Fix-Before-Send
If screenshot shows problem:
1. Fix code
2. Re-deploy
3. New screenshot
4. Still broken? -> back to 1
5. Fixed? -> now send
Never send "I noticed X is wrong, will fix" - fix first.
## No UI? Show Output
When verifying API endpoints, show actual output:
```
GET /api/users -> {"id": 1, "name": "test"}
```
Include actual response, not just "it works".
## Flows
Number sequential states: "1/4: Form", "2/4: Loading", "3/4: Error", "4/4: Success"
+181
View File
@@ -0,0 +1,181 @@
---
name: content-strategy
description: Build and execute a content marketing strategy for a solopreneur business. Use when planning what content to create, deciding on content formats and channels, building a content calendar, measuring content performance, or systematizing content production. Covers audience research for content, content pillars, distribution strategy, repurposing workflows, and metrics. Trigger on "content strategy", "content marketing", "what content should I create", "content plan", "content calendar", "content ideas", "content distribution", "grow through content".
---
# Content Strategy
## Overview
Content marketing is how solopreneurs build authority, attract customers, and grow without paid ads. But random content doesn't work — you need a strategy. This playbook builds a repeatable system for creating content that actually drives business results, not just likes.
---
## Step 1: Define Your Content Goals
Content without a goal is just noise. Before you create anything, answer: what is this content supposed to DO?
**Common solopreneur content goals:**
- **Generate awareness** (new people discover you exist)
- **Build trust** (people see you as credible and knowledgeable)
- **Drive leads** (people give you their email or book a call)
- **Enable sales** (content answers objections and shortens sales cycles)
- **Retain customers** (existing customers stay engaged and see ongoing value)
**Rule:** Pick ONE primary goal per piece of content. You can have secondary benefits, but clarity on the main goal determines format, channel, and CTA.
Example: A tutorial blog post might have the primary goal of "generate awareness" (via SEO) and a secondary goal of "drive leads" (with an email signup CTA at the end).
---
## Step 2: Research Your Audience's Content Needs
Great content solves a specific problem for a specific person. Bad content talks about what YOU want to talk about.
**Research workflow (spend 2-3 hours on this before creating anything):**
1. **Mine customer conversations.** Go through support tickets, sales calls, discovery calls. What questions do prospects and customers ask repeatedly? Those are your content topics.
2. **Check competitor content.** What are the top 3-5 players in your space publishing? Look for gaps — topics they're NOT covering or covering poorly.
3. **Keyword research (if doing SEO).** Use free tools (Google autocomplete, AnswerThePublic, or "People Also Ask" in Google results) to see what people are actually searching for related to your niche.
4. **Community mining.** Go to Reddit, Slack communities, Facebook groups, or forums in your space. What questions get asked over and over? Those are high-value topics.
**Output:** A list of 20-30 content ideas ranked by: (a) relevance to your ICP, (b) search volume or community demand, (c) your unique perspective or experience on the topic.
---
## Step 3: Build Content Pillars
Content pillars are 3-5 broad topic areas that all your content falls under. They keep you focused and prevent random one-off content that doesn't build momentum.
**How to define pillars:**
- Each pillar should map to a core problem your product/service solves or a key interest area of your ICP.
- Pillars should be broad enough to generate dozens of pieces of content but specific enough to be relevant.
- Aim for 3-5 pillars max. More than that dilutes focus.
**Example (for an n8n automation consultant):**
```
Pillar 1: Workflow Automation Fundamentals
Pillar 2: No-Code Tool Comparisons
Pillar 3: Business Process Optimization
Pillar 4: Real Client Case Studies
```
Every piece of content you create should fit under one of these pillars. If it doesn't, don't create it.
---
## Step 4: Choose Your Content Formats and Channels
Solopreneurs can't do everything. Pick 1-2 primary formats and 1-2 primary channels. Go deep, not wide.
**Content formats:**
| Format | Best For | Time Investment | Longevity |
|---|---|---|---|
| **Blog posts** | SEO, teaching, depth | 2-4 hrs/post | High (evergreen) |
| **Videos (YouTube)** | Visual topics, personality-driven brands | 3-6 hrs/video | High (evergreen) |
| **Podcasts** | Thought leadership, interviews | 2-3 hrs/episode | Medium |
| **Twitter/X threads** | Quick insights, community building | 30 min/thread | Low (24-48hr shelf life) |
| **LinkedIn posts** | B2B, professional content | 30-60 min/post | Low-medium |
| **Email newsletters** | Relationship building, owned audience | 1-2 hrs/newsletter | Medium (subscribers keep it) |
| **Short-form video (TikTok, Reels)** | Viral potential, younger demos | 1-2 hrs/video | Low (algorithmic churn) |
**Selection criteria:**
- Where does your ICP hang out? (B2B = LinkedIn. Developers = Twitter. Visual products = Instagram.)
- What format do you NOT hate creating? (If you hate being on camera, don't pick YouTube.)
- What has the best ROI for your goals? (Lead gen = blog + email. Brand building = Twitter + LinkedIn.)
**Recommended solopreneur starting stack:**
- **Primary format:** Blog posts or long-form LinkedIn posts (depending on B2B vs B2C)
- **Secondary format:** Email newsletter (this is your owned channel — never skip this)
---
## Step 5: Build a Content Calendar
A content calendar prevents the "what should I post today?" panic. Plan 2-4 weeks ahead.
**Calendar structure:**
```
DATE | PILLAR | TOPIC | FORMAT | CHANNEL | CTA | STATUS
```
**Example:**
```
Feb 10 | Automation | "5 n8n workflows every SaaS founder needs" | Blog | Website + LinkedIn | Email signup | Draft
Feb 13 | Case Study | "How we saved Client X 20hrs/week" | LinkedIn post | LinkedIn | Book a call | Scheduled
Feb 17 | Tool Comparison | "Zapier vs n8n: Which is right for you?" | Blog | Website + Twitter | Free guide download | Outline
```
**Cadence recommendations:**
- Blog: 1-2x/week (minimum 2x/month to maintain SEO momentum)
- Newsletter: 1x/week or biweekly (consistency matters more than frequency)
- Social (LinkedIn/Twitter): 3-5x/week
**Rule:** Batch creation. Write 4 posts in one sitting rather than 1 post four different days. Batching is 3x faster and produces better quality.
---
## Step 6: Distribution and Amplification
Creating content is 30% of the work. Distribution is the other 70%.
**Distribution checklist for every piece:**
- [ ] Publish on primary channel (blog, YouTube, etc.)
- [ ] Share on 2-3 social channels with unique captions per platform (don't just copy-paste the same message)
- [ ] Send to email list (if it's a high-value piece)
- [ ] Post in 1-2 relevant communities (but add value to the discussion, don't just drop links)
- [ ] DM it to 3-5 people who you think would find it genuinely useful
- [ ] Repurpose into 2-3 other formats (see next step)
**Timing:** Publish early in the week (Tuesday-Thursday) for best engagement. Avoid Fridays and weekends unless your audience is specifically active then.
---
## Step 7: Repurpose Everything
One piece of long-form content can become 5-10 smaller pieces. This is how solopreneurs produce high volume without burning out.
**Repurposing workflow (example: one blog post):**
1. Original: 1,500-word blog post
2. Repurpose into: LinkedIn post (first 3 paragraphs + a hook)
3. Repurpose into: Twitter thread (key points broken into 8-10 tweets)
4. Repurpose into: Email newsletter (add a personal intro, link to full post)
5. Repurpose into: Carousel post (main points as slides on LinkedIn or Instagram)
6. Repurpose into: Short video (you on camera summarizing the key takeaway in 60 seconds)
**Rule:** Repurpose the high-performers. If a blog post gets good traffic or a LinkedIn post gets strong engagement, milk it — turn it into 5 more formats.
---
## Step 8: Measure What Matters
Track content performance so you can double down on what works and stop doing what doesn't.
**Metrics by goal:**
| Goal | Metrics to Track |
|---|---|
| Awareness | Impressions, reach, new visitors, social followers |
| Trust | Engagement rate (comments, shares), time on page, repeat visitors |
| Lead generation | Email signups, CTA clicks, lead magnet downloads |
| Sales enablement | Content assists (how many deals involved this content?), proposal open rates (if content is attached) |
**Dashboard (monthly check-in):**
- Top 5 performing pieces (by traffic or engagement)
- Traffic source breakdown (organic, social, direct, referral)
- Conversion rate (visitors → email signups or leads)
- Time investment vs results (which content type has the best ROI?)
**Iteration rule:** Every month, identify the top-performing content type and topic. Do 2x more of that next month. Identify the worst performer. Stop doing that format or adjust the approach.
---
## Content Strategy Mistakes to Avoid
- Creating content without a goal. Every piece should have a purpose tied to a business outcome.
- Not researching what your audience actually wants. Your assumptions are often wrong — validate with real data.
- Trying to be on every platform. Pick 1-2 and dominate them before expanding.
- Publishing inconsistently. One post a month doesn't build momentum. Consistency compounds.
- Not repurposing. Creating 10 original pieces is 5x harder than creating 2 original pieces and repurposing them into 8 more.
- Ignoring metrics. If you don't measure, you can't improve. Check your numbers monthly at minimum.
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn732qfbv22he1jqm63xbwq6e980kn8s",
"slug": "content-strategy",
"version": "0.1.0",
"publishedAt": 1770341804646
}
+229
View File
@@ -0,0 +1,229 @@
---
name: ExtractWisdom
description: Content-adaptive wisdom extraction — detects what domains exist in content and builds custom sections (not static IDEAS/QUOTES). Produces tailored insight reports from videos, podcasts, articles. USE WHEN extract wisdom, analyze video, analyze podcast, extract insights, what's interesting, extract from YouTube, what did I miss, key takeaways.
---
## Customization
**Before executing, check for user customizations at:**
`~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/ExtractWisdom/`
If this directory exists, load and apply any PREFERENCES.md, configurations, or resources found there. These override default behavior. If the directory does not exist, proceed with skill defaults.
# ExtractWisdom — Dynamic Content Extraction
**The next generation of extract_wisdom.** Instead of static sections (IDEAS, QUOTES, HABITS...), this skill detects what wisdom domains actually exist in the content and builds custom sections around them.
A programming interview gets "Programming Philosophy" and "Developer Workflow Tips." A business podcast gets "Contrarian Business Takes" and "Money Philosophy." A security talk gets "Threat Model Insights" and "Defense Strategies." The sections adapt because the content dictates them.
## When to Use
- Analyzing YouTube videos, podcasts, interviews, articles
- User says "extract wisdom", "what's interesting in this", "key takeaways"
- Processing any content where you want to capture the best stuff
- When standard extraction patterns miss the gems
## Depth Levels
Extract at different depths depending on need. Default is **Full** if no level is specified.
| Level | Sections | Bullets/Section | Closing Sections | When |
|-------|----------|----------------|-----------------|------|
| **Instant** | 1 | 8 | None | Quick hit. One killer section. |
| **Fast** | 3 | 3 | None | Skim in 30 seconds. |
| **Basic** | 3 | 5 | One-Sentence Takeaway only | Solid overview without the deep cuts. |
| **Full** | 5-12 | 3-15 | All three | The default. Complete extraction. |
| **Comprehensive** | 10-15 | 8-15 | All three + Themes & Connections | Maximum depth. Nothing left behind. |
**How to invoke:** "extract wisdom (fast)" or "extract wisdom at comprehensive level" or just "extract wisdom" for Full.
**Comprehensive extras:**
- **Themes & Connections** closing section: identify 3-5 throughlines that connect multiple sections. Not summaries — the deeper patterns the speaker may not even realize they're revealing.
- Prioritize breadth. Every significant wisdom domain gets its own section.
- No merging sections to save space. If the content supports 15 sections, use 15.
**All levels use the same voice, tone rules, and quality standards.** The only thing that changes is structure. An Instant extraction should hit just as hard per-bullet as a Comprehensive one.
## Workflow Routing
| Workflow | Trigger | File |
|----------|---------|------|
| **Extract** | "extract wisdom from", "analyze this", YouTube URL | `Workflows/Extract.md` |
## The Core Idea
Old extract_wisdom: Static sections. Same headers every time. IDEAS. QUOTES. HABITS. FACTS.
This skill: **Read the content first. Figure out what's actually in there. Build sections around what you find.**
The output should feel like your smartest friend watched/read the thing and is telling you about it over coffee. Not a book report. Not documentation. A real person pointing out the parts that made them go "holy shit" or "wait, that's actually brilliant."
## Tone Rules (CRITICAL)
**Canonical voice reference: `PAI/USER/WRITINGSTYLE.md`** — read this file for the full voice definition. The bullets should sound like {PRINCIPAL.NAME} telling a friend about it over coffee. Not compressed info nuggets. Not clever one-liners. Actual spoken observations.
**THREE LEVELS — we're aiming for Level 3:**
**Level 1 (BAD — documentation):**
- The speaker discussed the importance of self-modifying software in the context of agentic AI development
- It was noted that financial success has diminishing returns beyond a certain threshold
- The distinction between "vibe coding" and "agentic engineering" was emphasized as meaningful
**Level 2 (BETTER — but still "smart bullet points"):**
- He built self-modifying software basically by accident — just made the agent aware of its own source code
- Money has diminishing returns. A cheeseburger is a cheeseburger no matter how rich you are.
- "Vibe coding is a slur" — he calls it agentic engineering, and only does vibe coding after 3am
**Level 3 (YES — this is what we want — conversational, {PRINCIPAL.NAME}'s voice):**
- He wasn't trying to build self-modifying software. He just let the agent see its own source code and it started fixing itself.
- Past a certain point, money stops mattering. A cheeseburger is a cheeseburger no matter how rich you are.
- He calls vibe coding a slur. What he does is agentic engineering. The vibe coding only happens after 3am, and he regrets it in the morning.
**The difference between Level 2 and 3:** Level 2 is compressed info with em-dashes. Level 3 is how you'd actually SAY it. Varied sentence lengths. Letting a thought breathe. Not trying to be clever — just being clear and direct and a little bit personal.
**Key signals of Level 3:**
- Reads naturally when spoken aloud
- Varied sentence lengths — some short, some longer
- Understated — lets the content carry the weight
- Uses periods, not em-dashes, to let ideas land
- Feels opinionated ("Past a certain point, money stops mattering") not just informational
- The reader should think "I want to watch this" not "I got the summary"
## Rules for Extracted Points
1. **Write like you'd say it.** Read each bullet aloud. If it sounds like a press release or a compressed tweet, rewrite it. If it sounds like you telling a friend what you just watched, you nailed it.
2. **8-16 words per sentence.** This is the target range. Mix short (8-10) with medium (11-14) and longer (15-16). Don't make them all the same length. Exception: verbatim quotes can be any length since they're the speaker's actual words.
3. **Let ideas breathe.** Use periods between thoughts, not em-dashes. Short sentences. Then a slightly longer one to explain. That's the rhythm.
4. **Include the actual detail.** Not "he talked about money" but "a cheeseburger is a cheeseburger no matter how rich you are."
5. **Use the speaker's words when they're good.** If they said something perfectly, use it.
6. **No hedging language.** Not "it was suggested that" or "the speaker noted." Just say the thing.
7. **Capture what made you stop.** Every bullet should be something worth telling someone about.
8. **Vary your openers.** Don't start three bullets the same way. And don't front-load with "He" — if more than 3 bullets in a section start with the speaker's name, you're writing a biography.
9. **Capture the human moments.** Burnout stories, moments of doubt, something that moved them. That's wisdom too. Don't skip it because it's not "technical."
10. **Insight over inventory.** "He uses Go for CLIs" is inventory. "He picked a language he doesn't even like because the ecosystem fits agents perfectly. That's the new normal." is insight. Go deeper.
11. **Specificity is everything.** "He was impressed by the agent" = bad. "The agent found ffmpeg, curled the Whisper API, and transcribed a voice message nobody taught it to handle" = good.
12. **Tension and surprise.** The best bullets have a contradiction or reversal. "Every VC is offering hundreds of millions. He genuinely doesn't care." The gap between the offer and the indifference IS the wisdom.
13. **Understated, not clever.** Let the content carry the weight. You don't need to manufacture drama or craft the perfect one-liner. Just state what's interesting plainly and move on.
## How Dynamic Sections Work
### Phase 1: Content Scan
Read/listen to the full content. As you go, notice what DOMAINS of wisdom are present. These aren't the topics discussed — they're the TYPES of insight being delivered.
Examples of wisdom domains (these are illustrative, not exhaustive):
- Programming Philosophy (how to think about code, not specific syntax)
- Developer Workflow (practical tips for how to work)
- Business/Money Philosophy (unconventional takes on money, success, building companies)
- Human Psychology (insights about how people think, behave, learn)
- Technology Predictions (where things are headed)
- Life Philosophy (how to live, what matters)
- Contrarian Takes (things that go against conventional wisdom)
- First-Time Revelations (things you're hearing for the first time — genuinely new)
- Technical Architecture (how something is built, design decisions)
- Leadership & Team Dynamics (managing people, working with others)
- Creative Process (how to make things, craft, art)
### Phase 2: Section Selection
Pick sections based on depth level (default Full = 5-12). Requirements:
- Section count follows depth level table. Full = 5-12, Comprehensive = 10-15, Basic/Fast = 3, Instant = 1.
- Each section must have at least 3 STRONG bullets to justify existing (except Fast, where 3 tight bullets IS the section). If you can only scrape together 2 weak ones, merge into a related section.
- Always include "Quotes That Hit Different" if the content has good ones
- Always include "First-Time Revelations" if there are genuinely new ideas — things you literally didn't know before
- Section names should be conversational, not academic. "Money Philosophy" not "Financial Considerations"
- Sections should be SPECIFIC to this content. Generic sections = failure.
- **Kill inventory sections.** If a section is just a list of facts ("uses X for Y, uses A for B"), it's not wisdom. Either go deeper on WHY those choices matter or merge the facts into a section about the underlying philosophy.
- **Don't split what belongs together.** If "burnout recovery" and "money philosophy" are actually both about "what success really means," make one richer section instead of two thin ones.
- **Name sections like a magazine editor.** "The Death of 80% of Apps" is great. "Technology Predictions" is not. The section name itself should make you curious. It's a headline, not a category.
- **Surprise density per section.** If a section has 6+ bullets but only 2 are genuinely surprising, kill the padding and keep the winners. Quality > quantity per section.
- **Don't drop your best material between drafts.** If a spicy take, stunning moment, or first-time revelation was identified in an earlier pass, it MUST survive into the final version. Losing great material is worse than adding mediocre material.
### Phase 3: Extraction
For each section, extract 3-15 bullets depending on density. Apply all tone rules. Every bullet earns its place.
**The Spiciest Take Rule:** If the speaker has a genuinely contrarian or hot take on a topic (e.g., "screw MCPs", "X is dead", "Y is overhyped"), that take MUST appear somewhere. Spicy takes are the most memorable, shareable, and valuable parts of any content. Don't water them down. Don't leave them out.
**The "Would I Tweet This?" Test:** After extraction, scan your bullets. If fewer than half would make a good standalone tweet or social media post, your bullets are too generic. The best extractions are effectively a thread of tweetable insights.
### Phase 4: Closing Sections (Depth-Level Dependent)
Which closing sections to include depends on depth level:
| Level | Closing Sections |
|-------|-----------------|
| **Instant** | None |
| **Fast** | None |
| **Basic** | One-Sentence Takeaway only |
| **Full** | One-Sentence Takeaway + If You Only Have 2 Minutes + References & Rabbit Holes |
| **Comprehensive** | All three above + Themes & Connections |
**One-Sentence Takeaway**
The single most important thing from the entire piece in 15-20 words.
**If You Only Have 2 Minutes**
The 5-7 absolute must-know points. The cream of the cream.
**References & Rabbit Holes**
People, projects, books, tools, and ideas mentioned that are worth following up on. Brief context for each.
**Themes & Connections** (Comprehensive only)
3-5 throughlines that connect multiple sections. The deeper patterns the speaker may not realize they're revealing. Not summaries. Synthesis.
## Output Format
```markdown
# EXTRACT WISDOM: {Content Title}
> {One-line description of what this is and who's talking}
---
## {Dynamic Section 1 Name}
- {bullet}
- {bullet}
- {bullet}
## {Dynamic Section 2 Name}
- {bullet}
- {bullet}
[... more dynamic sections ...]
---
## One-Sentence Takeaway
{15-20 word sentence}
## If You Only Have 2 Minutes
- {essential point 1}
- {essential point 2}
- {essential point 3}
- {essential point 4}
- {essential point 5}
## References & Rabbit Holes
- **{Name/Project}** — {one-line context of why it's worth looking into}
- **{Name/Project}** — {context}
```
## Quality Check
Before delivering output, verify:
- [ ] Sections are specific to THIS content, not generic
- [ ] No bullet sounds like it was written by a committee
- [ ] Every bullet has a specific detail, quote, or insight — not vague summaries
- [ ] Section names are conversational and headline-worthy (not category labels)
- [ ] Section count matches depth level (Instant=1, Fast/Basic=3, Full=5-12, Comprehensive=10-15)
- [ ] Closing sections match depth level (see Phase 4 table)
- [ ] No bullet starts with "The speaker" or "It was noted that"
- [ ] No more than 3 bullets per section start with "He" or the speaker's name
- [ ] No bullet exceeds 25 words
- [ ] No inventory sections (just listing facts without insight)
- [ ] "If You Only Have 2 Minutes" bullets are each under 20 words
- [ ] Reading the output makes you want to consume the original content
+60
View File
@@ -0,0 +1,60 @@
# Extract Workflow
Extract dynamic, content-adaptive wisdom from any content source.
## Input Sources
| Source | Method |
|--------|--------|
| YouTube URL | `fabric -y "URL"` to get transcript |
| Article URL | WebFetch to get content |
| File path | Read the file directly |
| Pasted text | Use directly |
## Execution Steps
### Step 1: Get the Content
Obtain the full text/transcript. For YouTube, use `fabric -y "URL"` to extract transcript. Save to a working file if large.
### Step 2: Deep Read
Read the entire content. Don't extract yet. Notice:
- What domains of wisdom are present?
- What made you stop and think?
- What's genuinely novel vs. commonly known?
- What would {PRINCIPAL.NAME} highlight if he were reading this?
- What quotes land perfectly?
### Step 3: Select Dynamic Sections
Based on your deep read, pick 5-12 section names. Rules:
- Section names must be conversational, not academic
- Each must have at least 3 quality bullets
- Always include "Quotes That Hit Different" if source has quotable moments
- Always include "First-Time Revelations" if genuinely new ideas exist
- Be SPECIFIC — "Agentic Engineering Philosophy" not "Technology Insights"
### Step 4: Extract Per Section
For each section, extract 3-15 bullets. Apply tone rules from SKILL.md:
- 8-20 words, flexible for clarity
- Specific details, not vague summaries
- Speaker's words when they're good
- No hedging language
- Every bullet worth telling someone about
### Step 5: Add Closing Sections
Always append:
1. **One-Sentence Takeaway** (15-20 words)
2. **If You Only Have 2 Minutes** (5-7 essential points)
3. **References & Rabbit Holes** (people, projects, books, tools mentioned)
### Step 6: Quality Check
Run the quality checklist from SKILL.md before delivering.
### Step 7: Output
Present the complete extraction in the format specified in SKILL.md.
+14
View File
@@ -0,0 +1,14 @@
---
name: ContentAnalysis
description: Content extraction and analysis — wisdom extraction from videos, podcasts, articles, and YouTube. USE WHEN extract wisdom, content analysis, analyze content, insight report, analyze video, analyze podcast, extract insights, key takeaways, what did I miss, extract from YouTube.
---
# ContentAnalysis
Unified skill for content extraction and analysis workflows.
## Workflow Routing
| Request Pattern | Route To |
|---|---|
| Extract wisdom, content analysis, insight report, analyze content | `ExtractWisdom/SKILL.md` |
+92
View File
@@ -0,0 +1,92 @@
# Design Skill Index
This file is a directory map for the design skill package. It should not contain runtime routing rules. Runtime behavior lives in `SKILL.md`; template selection lives in `design-templates/TEMPLATE-ROUTER.md`.
## Runtime Entry
### `SKILL.md`
Main design router. It decides the primary artifact skill, output target, design-system/reference usage, template usage, horizontal craft triggers, quality gate, export, and version-management handoff.
## Active Artifact Skills
| File | Output target | Use for |
|---|---|---|
| `landing-page.md` | `responsive-html` | Product landing pages, SaaS homepages, marketing pages, waitlists, service pages. |
| `portfolio.md` | `responsive-html` | Personal sites, portfolios, creator profiles, resumes, project showcases. |
| `prototype.md` | `responsive-html` / `mobile-html` | Product prototypes, app flows, dashboards, admin panels, wireframes, UI mockups. |
| `content-page.md` | `responsive-html` | Articles, editorial pages, newsletters, public-account layouts, long-form reading pages. |
| `info-interactive.md` | `responsive-html` | Interactive reports, data stories, explorable explainers, filterable information. |
| `web-tool.md` | `responsive-html` / `mobile-html` | Single-task web tools: calculators, generators, converters, checkers, pickers, quizzes/tests, decision tools, H5/mobile tools. |
| `social-card.md` | `fixed-image` | Social cards, Xiaohongshu/RED cards, WeChat/Douyin covers, screenshot cards, KPI cards, quote cards, image-led covers, long-image card sequences. |
| `deck.md` | `html-slide-deck` | Slide decks, pitch decks, report decks, strategy decks, teaching decks, presentation-style HTML. |
Legacy note: old references to `xiaohongshu-card.md` should route to `social-card.md`; old `tool-h5.md` references should route to `web-tool.md`.
## System / Reference Skills
| File | Purpose |
|---|---|
| `design-system-reference.md` | Select and apply existing style skills, brand inspiration, open-source design systems, uploaded DESIGN.md, screenshots, or user-owned references. |
| `design-system-generation.md` | Import, extract, normalize, or persist reusable `DESIGN.md`. |
| `canvas-and-device.md` | Viewport presets, device frames, deck canvases, social-card sizes, long-image/export surfaces, fixed aspect ratios. |
| `quality-gate.md` | Final blocking delivery gate for generated or major-edited design artifacts. |
| `export.md` | Package, convert, or hand off the current artifact. |
| `skills/version-management/SKILL.md` | Parallel version-management skill for editable web entry files (`.html`, `.jsx`, `.tsx`, `.vue`); not a design reference. |
## Template Library
| File | Purpose |
|---|---|
| `design-templates/INDEX.md` | Human/model-readable template directory: what exists and where. |
| `design-templates/TEMPLATE-ROUTER.md` | Template selection rules: carrier gate, conflict handling, and when to select templates. |
| `design-templates/TEMPLATE-INDEX.json` | Machine-readable template metadata for tooling or automated retrieval. |
Template rule: choose the artifact skill first, then use `TEMPLATE-ROUTER.md` to decide whether a template applies.
## Design Systems
### `design-systems/`
Style skills and brand inspiration references. Use `design-systems/index.json` for candidate selection, then read only the matched `DESIGN.md` or style skill.
The index may contain:
- `style_skills`
- `brand_inspiration`
- selection tags such as `industry`, `product_type`, `mood`, `formality`, `density`, `scheme`, `era`, `best_for`, and `avoid_for`
Keep detailed vocabulary and selection logic inside the design-system reference files, not here.
## Horizontal Craft
Read only when the task trigger is present. `SKILL.md` contains the runtime trigger matrix.
| File | Use when |
|---|---|
| `horizontal-craft/chinese-typography.md` | Substantial Chinese text or Chinese reading comfort matters. |
| `horizontal-craft/reference/title-and-breaking.md` | Titles, covers, slides, social-card headings, or line-break-sensitive Chinese. |
| `horizontal-craft/reference/punctuation.md` | Punctuation-heavy Chinese long text. |
| `horizontal-craft/reference/text-detail.md` | Font size, line height, paragraph rhythm, dense reading surfaces. |
| `horizontal-craft/reference/fonts.md` | Font selection materially affects visual direction. |
| `horizontal-craft/reference/grid-system.md` | Reports, long images, publication-like or dense grid layouts. |
| `horizontal-craft/reference/directions.md` | Vertical text, classical Chinese, regional typography differences, or traditional layouts. |
| `horizontal-craft/color.md` | No design system or color direction is underspecified. |
| `horizontal-craft/icon-system.md` | UI/feature/nav/status icons or icon-like marks appear. |
| `horizontal-craft/accessibility.md` | Interactive or publish-ready artifacts; also part of final checks. |
| `horizontal-craft/animation-discipline.md` | Motion, transitions, scroll effects, animated storytelling. |
| `horizontal-craft/form-validation.md` | Forms, inputs, validation, signup, checkout, submission, settings. |
| `horizontal-craft/laws-of-ux.md` | Pricing, onboarding, dashboards, H5 tools, conversion or interaction-heavy flows. |
| `horizontal-craft/state-coverage.md` | Product, dashboard, form, tool, H5, data, and prototype states. |
| `horizontal-craft/data-integrity.md` | Metrics, charts, tables, rankings, percentages, or claims. |
| `horizontal-craft/link-and-proof.md` | Links, CTAs, citations, client logos, testimonials, proof claims. |
| `horizontal-craft/technique-library.md` | Advanced motion, 3D, shaders, data-viz, or effects beyond plain CSS. |
| `horizontal-craft/anti-ai-slop.md` | Final anti-default/anti-template-quality check; light generation reminder, heavier delivery check. |
## Deprecated / Removed
- `chinese-copy.md` — removed from core craft. Lightweight AI-flavored copy checks belong in `anti-ai-slop.md` when they affect design quality.
- `execution-checklists.md` — removed. Canonical checks live in `SKILL.md` and `quality-gate.md`.
- `xiaohongshu-card.md` — replaced by `social-card.md`; keep only as a temporary alias if needed.
- `tool-h5.md` — replaced by `web-tool.md`; keep only as a temporary alias if needed.
- `METADATA.md` — not required unless a separate tool explicitly consumes it. Prefer `SKILL.md` frontmatter plus this index.
Loaded 100 of 1089 files, more files were not shown because too many files have changed in this diff. Show more