Restore PROMPT branding, rename waterfall-wiki, and add system tag workflows
@@ -1,10 +1,10 @@
|
||||
name: wiki CI and deployment
|
||||
name: waterfall-wiki CI and deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/wiki/**'
|
||||
- '.gitea/workflows/wiki.yml'
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/waterfall-wiki.yml'
|
||||
- 'Dockerfile'
|
||||
- 'compose.yml'
|
||||
- 'scripts/deploy.sh'
|
||||
@@ -14,8 +14,8 @@ on:
|
||||
- 'pnpm-workspace.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/wiki/**'
|
||||
- '.gitea/workflows/wiki.yml'
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/waterfall-wiki.yml'
|
||||
- 'Dockerfile'
|
||||
- 'compose.yml'
|
||||
- 'scripts/deploy.sh'
|
||||
@@ -34,16 +34,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Validate configuration
|
||||
run: docker compose --env-file apps/wiki/.env.example config --quiet
|
||||
- name: Build and verify Wiki
|
||||
run: docker build --target integration -t "prompt-tests:${{ gitea.sha }}" .
|
||||
run: docker compose --env-file apps/waterfall-wiki/.env.example config --quiet
|
||||
- name: Build and verify waterfall-wiki
|
||||
run: docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
|
||||
- name: Build runtime
|
||||
run: docker build --target runtime -t "prompt:${{ gitea.sha }}" .
|
||||
run: docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
|
||||
- name: Integration stack
|
||||
env:
|
||||
PROMPT_IMAGE: prompt:${{ gitea.sha }}
|
||||
PROMPT_TEST_IMAGE: prompt-tests:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Deploy Wiki
|
||||
- name: Deploy waterfall-wiki
|
||||
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||
run: bash scripts/deploy.sh "${{ gitea.sha }}"
|
||||
@@ -7,16 +7,16 @@ RUN apt-get update \
|
||||
|
||||
FROM base AS dependencies
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/wiki/pnpm-lock.yaml ./
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY apps/wiki/package.json ./
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
COPY apps/wiki/ .
|
||||
COPY apps/waterfall-wiki/ .
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
|
||||
FROM build AS integration
|
||||
@@ -29,9 +29,9 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
|
||||
COPY --from=build --chown=node:node /app/db/ ./db/
|
||||
COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
|
||||
COPY --chown=node:node apps/wiki/seed/characters/ ./seed/characters/
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
|
||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,7 +1,102 @@
|
||||
# PROMPT monorepo
|
||||
# PROMPT
|
||||
|
||||
Wiki、创作站和 Python 导出工具。
|
||||
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
|
||||
|
||||
详见 [项目结构与运行方式](docs/monorepo.md)。
|
||||
**PROMPT 是网站与项目品牌;`waterfall-wiki` 是知识库应用的内部名称。** 目录、包、CI、镜像和 Compose 服务使用内部名称,网站导航与页面标题使用 PROMPT。
|
||||
|
||||
业务接口详见 [API 说明](docs/api.md),线上纯文本目录为 `/api?agent_mode=True`。
|
||||
线上入口:[PROMPT](http://8.211.154.134:18131/)。内容、媒体、API 与 Studio 共用登录入口。
|
||||
|
||||
## 从一篇 post 开始
|
||||
|
||||
每篇内容都有稳定的 `/explore/<id>` 地址。标题、标签、正文和封面可以修改,链接保持不变。数据库内容可以保留 24 位 public ID,内部仍使用 UUID;旧 `/p/<slug>`、`/skills/...` 等地址保留兼容跳转。
|
||||
|
||||
- **浏览**:首页按不同笔记的反向引用数优先排序。重复引用和自引用不重复计数,代码、图片与外站链接不冒充笔记引用。详情可以查看引用来源。
|
||||
- **搜索**:标题、简介、正文、普通标签和系统标签共同匹配。支持多个关键词及双引号短语,先按相关性、再按引用数和时间排序;先筛选再分页。
|
||||
- **标签**:普通标签使用 `#中国风`、`#贴纸` 等名称,可以创建、添加或移除。系统标签是固定列表,目前为“文档入口”和“参考图”,只能搜索并勾选,不能通过 `#` 创建。系统标签显示在图片角标,普通标签显示在文字区;分类页顶部按系统标签筛选。
|
||||
- **编辑**:卡片与详情都能直接编辑标签;完整编辑器可以修改标题、简介、Markdown。保存使用 revision 检测并发冲突,避免旧窗口覆盖新修改。
|
||||
- **素材**:原件和加工结果都是普通资产,可被多篇 post 引用。文档封面与实际参考素材分开,子文档可以有自己的封面,同时保留原参考图。
|
||||
|
||||
## 给 AI 的入口
|
||||
|
||||
在同一个 post 地址加 `?agent_mode=True` 可以读取纯文本正文和媒体链接。业务 API 的相同参数会先返回接口说明;已有查询参数时使用 `&agent_mode=True`。这些入口同样需要登录或有效 Bearer JWT。
|
||||
|
||||
| 地址 | 用途 |
|
||||
| --- | --- |
|
||||
| `/explore/<id>` | 人看的 post;加 `?agent_mode=True` 读纯文本 |
|
||||
| `/api?agent_mode=True` | 业务 API 目录 |
|
||||
| `/api/explore-feed` | 统一搜索、系统/普通标签筛选与分页 |
|
||||
| `/api/notes/<id>` | GET 读取;PUT 编辑全文;PATCH 仅更新标签 |
|
||||
| `/api/tags` | GET 查看普通/系统标签;POST 创建或复用普通标签 |
|
||||
| `/api/upload` | 上传媒体;`assetOnly=true` 独立保存,不创建 post |
|
||||
| `/api/posts` | 用正文和已有资产创建普通 post |
|
||||
| `/api/media/<id>` | 媒体原件与衍生文件,支持 Range |
|
||||
| `/api/preview` | 有尺寸上限的 WebP 预览,不接受任意远程 URL |
|
||||
|
||||
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
|
||||
|
||||
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
|
||||
|
||||
## Studio:从教程进入工具
|
||||
|
||||
Studio 负责交互式排版与无状态渲染,知识库负责教程、来源和入口。`/studio` 返回 PROMPT 首页;请从对应 post 打开具体工具。
|
||||
|
||||
现有工具:
|
||||
|
||||
- `/studio/ideas/learning-skills-template`:ref-17 同一母版的四张占位封面。
|
||||
- `/studio/ideas/layout-selection`:ref-01、ref-07、ref-26 三种构图;可修改文字与配色,核对构件边界。
|
||||
- `/studio/api/export/<slug>`:GET 读取方案或初始 SVG;POST 校验自定义参数并生成 SVG/JSON。详细说明读取该地址的 `?agent_mode=True`。
|
||||
|
||||
渲染不自动保存作品,也不自动发布 post。需要保留的结果应下载后再通过普通媒体和 post API 发布。原图的 T/I/D 构件、画板比例与来源保留为可读取数据;文字替换和矢量重建的范围应在教程中明确。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
|
||||
| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API |
|
||||
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
|
||||
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
|
||||
| `scripts` | CI 部署、运维、API 发布与作者工具 |
|
||||
| `docs` | 数据模型、接口、认证与部署说明 |
|
||||
|
||||
两个 JavaScript 应用分别维护冻结的 pnpm 锁文件。根 Dockerfile 是 waterfall-wiki 的构建入口;应用目录的 Dockerfile 与它保持一致。
|
||||
|
||||
## 环境与检查
|
||||
|
||||
运行环境:Node.js 24、pnpm 11.25.0、PostgreSQL 17、S3 兼容对象存储;视频处理使用 FFmpeg,部分导出使用 Chromium。Python 工具需要 Python 3.12+。
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm --dir apps/waterfall-wiki typecheck
|
||||
pnpm --dir apps/waterfall-wiki test
|
||||
pnpm --dir apps/studio typecheck
|
||||
bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
|
||||
```
|
||||
|
||||
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
|
||||
|
||||
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 ces-control CI/CD,最后直接检查服务器。不要启动本地网页预览,不用 localhost 页面作为视觉验收或交付入口。具体约定见 [AGENTS.md](AGENTS.md)。
|
||||
|
||||
## 部署与持久化
|
||||
|
||||
Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。
|
||||
|
||||
| 服务 | 入口/端口 |
|
||||
| --- | --- |
|
||||
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
||||
| PostgreSQL | `127.0.0.1:18132` |
|
||||
| 对象存储 | `127.0.0.1:18133` |
|
||||
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
|
||||
| Python | 无网络端口,按需运行 |
|
||||
|
||||
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
||||
|
||||
部署在停止写入后备份数据库,再运行迁移和启动新版。失败时恢复兼容旧镜像的备份并回滚服务。生产不执行 `down -v`,也不清空对象文件。早期合集验证会遍历分页,不假定旧文章一定在第一页。
|
||||
|
||||
## 登录与自动化
|
||||
|
||||
共享验证器的 6 位 TOTP 动态码登录,JWT 保存在 HttpOnly Cookie 中;这属于单因素动态码登录,不是 2FA。密钥保存在服务器认证卷,不进入 Git、镜像或 HTTP 二维码接口。
|
||||
|
||||
服务器管理员在交互式 SSH 终端运行 `sudo /opt/prompt/auth qr` 绑定验证器。不要把二维码、动态密钥或 JWT 粘贴到聊天、日志和仓库。服务端自动化可使用 `node auth-cli.cjs run <脚本>`,在进程内为本机 API 请求附加短期凭据,不输出令牌。
|
||||
|
||||
更多说明:[登录与 SSH 绑定](docs/dynamic-code-login.md) · [API](docs/api.md) · [数据模型](docs/data-model.md) · [项目结构与部署](docs/monorepo.md)。
|
||||
|
||||
@@ -9,5 +9,5 @@ export function LayoutSelection(){
|
||||
async function download(format:'svg'|'json'){
|
||||
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
|
||||
}
|
||||
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← waterfall-wiki 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={layout.referenceUrl} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={layout.referenceUrl} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function TemplateTrial({idea}:{idea:Idea}){
|
||||
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
|
||||
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
|
||||
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← waterfall-wiki 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
|
||||
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
|
||||
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
|
||||
|
||||
@@ -5,6 +5,6 @@ import {LayoutSelection} from '../../components/LayoutSelection';
|
||||
import {tutorialPost} from '../../content/tool-links';
|
||||
import {TemplateTrial} from '../../components/TemplateTrial';
|
||||
import {ideas,type Idea} from '../../content/ideas';
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← waterfall-wiki 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← PROMPT 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
|
||||
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
|
||||
|
||||
@@ -7,16 +7,16 @@ RUN apt-get update \
|
||||
|
||||
FROM base AS dependencies
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/wiki/pnpm-lock.yaml ./
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY apps/wiki/package.json ./
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
COPY apps/wiki/ .
|
||||
COPY apps/waterfall-wiki/ .
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
|
||||
FROM build AS integration
|
||||
@@ -29,9 +29,9 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
|
||||
COPY --from=build --chown=node:node /app/db/ ./db/
|
||||
COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
|
||||
COPY --chown=node:node apps/wiki/seed/characters/ ./seed/characters/
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
|
||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,13 @@
|
||||
BEGIN;
|
||||
ALTER TABLE tags ADD COLUMN kind text NOT NULL DEFAULT 'normal' CHECK(kind IN ('normal','system'));
|
||||
ALTER TABLE tags ADD COLUMN system_key text UNIQUE;
|
||||
ALTER TABLE tags ADD CONSTRAINT tags_system_identity CHECK(
|
||||
(kind='normal' AND system_key IS NULL) OR
|
||||
(kind='system' AND system_key IS NOT NULL AND system_key IN ('document-entry','reference-image')));
|
||||
-- Reserve system names without discarding associations if an ordinary tag already used one.
|
||||
INSERT INTO tags(id,name,slug,kind,system_key) VALUES
|
||||
('6f890bd2-9f64-4781-8a21-651467ab7101','文档入口','document-entry','system','document-entry'),
|
||||
('6f890bd2-9f64-4781-8a21-651467ab7102','参考图','reference-image','system','reference-image')
|
||||
ON CONFLICT(lower(btrim(name))) DO UPDATE SET kind='system',system_key=excluded.system_key;
|
||||
INSERT INTO schema_migrations(version) VALUES('008_system_tags');
|
||||
COMMIT;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@prompt/wiki",
|
||||
"name": "@prompt/waterfall-wiki",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
|
Before Width: | Height: | Size: 451 B After Width: | Height: | Size: 451 B |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
@@ -23,7 +23,7 @@ async function main() {
|
||||
const config = authConfig(); if (!config) throw Error('Authentication is not configured');
|
||||
if (command === 'qr') {
|
||||
if (!process.stdout.isTTY) throw Error('QR display requires an interactive SSH terminal (ssh -t, docker compose exec without -T). Never send QR output to CI logs.');
|
||||
const uri = 'otpauth://totp/waterfall-wiki:shared?'+new URLSearchParams({secret:config.secret,issuer:'waterfall-wiki',algorithm:'SHA1',digits:'6',period:'30'});
|
||||
const uri = 'otpauth://totp/PROMPT:shared?'+new URLSearchParams({secret:config.secret,issuer:'PROMPT',algorithm:'SHA1',digits:'6',period:'30'});
|
||||
console.log('Private binding QR — everyone scanning this uses the same authenticator key.');
|
||||
console.log(await QRCode.toString(uri,{type:'terminal',small:true,errorCorrectionLevel:'M'}));
|
||||
return;
|
||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 769 KiB After Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 986 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 854 KiB After Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 884 KiB After Width: | Height: | Size: 884 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 782 KiB After Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 1020 KiB After Width: | Height: | Size: 1020 KiB |
|
Before Width: | Height: | Size: 866 KiB After Width: | Height: | Size: 866 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -8,6 +8,7 @@ import {previewUrl,exploreUrl} from '@/content/page-links';
|
||||
import {Shell} from './Shell';
|
||||
import {ExploreFeed} from './ExploreFeed';
|
||||
import {NoteMarkdown} from './NoteMarkdown';
|
||||
import {QuickTags} from './QuickTags';
|
||||
import {NoteEditor} from './NoteEditor';
|
||||
export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;background:ExploreEntry[]}){
|
||||
const [entry,setEntry]=useState(initial),[index,setIndex]=useState(0),[copied,setCopied]=useState(''),[editing,setEditing]=useState(false),[copyError,setCopyError]=useState('');
|
||||
@@ -31,7 +32,7 @@ export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;bac
|
||||
setCopied(mode);setCopyError('');
|
||||
}catch{setCopied('');setCopyError('复制失败,请重试。')}
|
||||
}
|
||||
return <Shell><Head><title>{entry.title} · waterfall-wiki</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
|
||||
return <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
|
||||
<div aria-hidden="true" inert><ExploreFeed key={entry.revision} initial={background.map(e=>e.id===entry.id?entry:e)}/></div>
|
||||
<div className="note-overlay" onClick={()=>{if(!editing)close()}}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}>
|
||||
<button className="note-close" ref={closeRef} aria-label={editing?'取消编辑':'关闭详情'} onClick={close}><X/></button>
|
||||
@@ -39,7 +40,7 @@ export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;bac
|
||||
{media?.kind==='image'?<img key={media.url} src={previewUrl(media.url,1280)} alt={media.title} decoding="async"/>:media?.kind==='video'?<video controls playsInline preload="metadata" src={media.url} poster={media.poster?previewUrl(media.poster,960):undefined}/>:media?.kind==='audio'?<div className="note-audio"><span>♫</span><audio controls preload="metadata" src={media.url}/></div>:<div className="note-title-cover" style={{background:entry.color}}><small>{entry.namespace}</small><strong>{entry.title}</strong><span>灵感笔记</span></div>}
|
||||
{entry.media.length>1&&<><button className="note-prev" aria-label="上一张" disabled={index===0} onClick={()=>setIndex(n=>n-1)}><ChevronLeft/></button><button className="note-next" aria-label="下一张" disabled={index===entry.media.length-1} onClick={()=>setIndex(n=>n+1)}><ChevronRight/></button><div className="note-dots">{entry.media.map((_,i)=><button key={i} className={i===index?'selected':''} aria-label={'查看第 '+(i+1)+' 张'} aria-pressed={i===index} onClick={()=>setIndex(i)}/>)}</div><span className="note-count">{index+1} / {entry.media.length}</span></>}
|
||||
</section><section className="note-info"><div className="note-content">
|
||||
{editing?<NoteEditor key={entry.id} entry={entry} onCancel={()=>setEditing(false)} onSaved={updated=>{setEntry(updated);setEditing(false);setCopied('')}}/>:<><h1 id="note-title">{entry.title}</h1><p className="note-description">{entry.description}</p><div className="note-tags">{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}</div><NoteMarkdown text={entry.markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'')}/><section id="backlinks" className="note-backlinks"><h2>被 {entry.backlinkCount??0} 篇笔记引用</h2>{entry.backlinks?.length?<ul>{entry.backlinks.map(ref=><li key={ref.id}><Link href={exploreUrl(ref.id)}>{ref.title}</Link></li>)}</ul>:<p>还没有笔记引用本文。</p>}</section><div className="note-id">内容 ID · {entry.id}</div></>}
|
||||
{editing?<NoteEditor key={entry.id} entry={entry} onCancel={()=>setEditing(false)} onSaved={updated=>{setEntry(updated);setEditing(false);setCopied('')}}/>:<><h1 id="note-title">{entry.title}</h1><p className="note-description">{entry.description}</p><div className="note-tags">{entry.systemTags?.map(t=><Link className="system-tag-pill" key={t.id} href={"/categories?category="+encodeURIComponent(t.name)}>{t.name}</Link>)}{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}<QuickTags entry={entry} onSaved={setEntry}/></div><NoteMarkdown text={entry.markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'')}/><section id="backlinks" className="note-backlinks"><h2>被 {entry.backlinkCount??0} 篇笔记引用</h2>{entry.backlinks?.length?<ul>{entry.backlinks.map(ref=><li key={ref.id}><Link href={exploreUrl(ref.id)}>{ref.title}</Link></li>)}</ul>:<p>还没有笔记引用本文。</p>}</section><div className="note-id">内容 ID · {entry.id}</div></>}
|
||||
</div>{!editing&&<footer><button onClick={()=>void copy('web')}><LinkIcon size={18}/>{copied==='web'?'网页链接已复制':'复制网页链接'}</button><button onClick={()=>void copy('agent')}><FileText size={18}/>{copied==='agent'?'纯文本链接已复制':'复制纯文本链接'}</button><button onClick={()=>setEditing(true)}><Pencil size={17}/>编辑</button>{copyError&&<span role="status">{copyError}</span>}</footer>}</section>
|
||||
</div></div></Shell>;
|
||||
}
|
||||
@@ -2,16 +2,17 @@ import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {Images,Link2} from 'lucide-react';
|
||||
import type {ExploreEntry} from '@/lib/explore';
|
||||
import {QuickTags} from './QuickTags';
|
||||
import {exploreUrl,previewUrl} from '@/content/page-links';
|
||||
export function ExploreFeed({initial,cursor:initialCursor=null,q='',category='',total}:{initial:ExploreEntry[];cursor?:string|null;q?:string;category?:string;total?:number}){
|
||||
const [entries,setEntries]=useState(initial);const [cursor,setCursor]=useState(initialCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
|
||||
async function more(){setBusy(true);try{const res=await fetch('/api/explore-feed?'+new URLSearchParams({q,category,cursor:cursor||''}));if(!res.ok)throw Error();const data=await res.json();setEntries(old=>[...old,...data.entries.filter((e:ExploreEntry)=>!old.some(o=>o.id===e.id))]);setCursor(data.nextCursor);setError('');}catch{setError('加载失败,点击重试')}finally{setBusy(false)}}
|
||||
return <>{q&&<div className="search-results-heading" role="status">搜索「{q}」{total!==undefined?` · ${total} 篇`:""}<span>按匹配程度、被引用数排序</span></div>}<div className="discovery-grid">{entries.map((entry,i)=><article key={entry.id} className="discovery-card">
|
||||
<Link href={exploreUrl(entry.id)} className="discovery-note-link"><div className="discovery-cover" style={{background:entry.color,aspectRatio:i%3===1?'1 / 1':'3 / 4'}}>
|
||||
<Link href={exploreUrl(entry.id)} className="discovery-note-link"><div className="discovery-cover" style={{background:entry.color,aspectRatio:'4 / 5'}}>
|
||||
{entry.media[0]?<img src={previewUrl(entry.media[0].poster||entry.media[0].url,480)} alt={entry.media[0].title} loading={i<4?'eager':'lazy'} decoding="async"/>:<><small>{entry.namespace.toUpperCase()}</small><strong>{entry.title}</strong><span>记录 · 连接 · 灵感</span></>}
|
||||
{!!entry.systemTags?.length&&<span className="discovery-system-tags">{entry.systemTags.map(t=><span key={t.id}>{t.name}</span>)}</span>}
|
||||
{entry.imageCount>1&&<span className="discovery-image-count" aria-label={`${entry.imageCount} 张图片`}><Images size={13}/>{entry.imageCount}</span>}
|
||||
</div><h2>{entry.title}</h2><p className="discovery-description">{entry.description}</p>{q&&entry.searchExcerpt&&<p className="search-excerpt">{entry.searchExcerpt}</p>}</Link>
|
||||
<Link className="backlink-count" href={exploreUrl(entry.id)+"#backlinks"} aria-label={`被 ${entry.backlinkCount??0} 篇笔记引用`}><Link2 size={14}/>被引用 {entry.backlinkCount??0}</Link>
|
||||
{entry.categories.length>0&&<div className="discovery-tags" aria-label="笔记标签">{entry.categories.map(tag=><Link key={tag} href={'/categories?category='+encodeURIComponent(tag)}>#{tag}</Link>)}</div>}
|
||||
</div><h2>{entry.title}</h2>{q&&entry.searchExcerpt&&<p className="search-excerpt">{entry.searchExcerpt}</p>}</Link>
|
||||
<div className="discovery-card-meta"><div className="discovery-tags" aria-label="普通标签">{entry.categories.slice(0,2).map(tag=><Link key={tag} href={'/categories?category='+encodeURIComponent(tag)}>#{tag}</Link>)}{entry.categories.length>2&&<span title={entry.categories.map(t=>'#'+t).join(' ')}>+{entry.categories.length-2}</span>}</div><Link className="backlink-count" href={exploreUrl(entry.id)+"#backlinks"} aria-label={`被 ${entry.backlinkCount??0} 篇笔记引用`}><Link2 size={14}/><span>{entry.backlinkCount??0}</span></Link><QuickTags entry={entry} onSaved={updated=>setEntries(old=>old.map(e=>e.id===entry.id?updated:e).filter(e=>!category||e.categories.includes(category)||e.systemTags?.some(t=>t.name===category||t.system_key===category)||e.namespace===category))}/></div>
|
||||
</article>)}</div>{!entries.length&&<p className="discovery-empty">{q?'没有匹配的内容,试试更短的关键词。':'这个分类还没有内容。'}</p>}{cursor&&<div className="load-more"><button disabled={busy} onClick={more}>{busy?'加载中…':error||'再看一些'}</button></div>}</>;
|
||||
}
|
||||
@@ -31,12 +31,12 @@ export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:
|
||||
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
|
||||
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags;
|
||||
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
|
||||
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · waterfall-wiki</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
|
||||
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · PROMPT</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
|
||||
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
|
||||
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
|
||||
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
|
||||
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?<Masonry posts={posts} includeSkills={!section} q={String(router.query.q||'')}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
|
||||
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
|
||||
<footer className="page-footer">waterfall-wiki <span>·</span> PROBABLY RENDERING ONE MORE PIXEL TONIGHT</footer>
|
||||
<footer className="page-footer">PROMPT <span>·</span> PROBABLY RENDERING ONE MORE PIXEL TONIGHT</footer>
|
||||
</Shell>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {Shell} from './Shell';
|
||||
import {SkillCard} from './SkillCard';
|
||||
import {skillCatalog} from '@/content/skills/catalog';
|
||||
import {guidanceDocuments,humanDocumentUrl} from '@/content/skills/guidance';
|
||||
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · waterfall-wiki</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
|
||||
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · PROMPT</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
|
||||
<header className="page-header"><div><h1>指导文档</h1><p>每篇都有独立地址,网页与 Markdown 一一对应。沿着引用,找到需要的方法。</p></div></header>
|
||||
<article className="docs-body"><h2>全部文档</h2><p>这些分组只帮助浏览。任何一篇都可以独立进入,也可以引用其他文档。</p><div className="guidance-directory">{guidanceDocuments.map(doc=><div key={doc.path}><Link href={humanDocumentUrl(doc.path)}>{doc.title}</Link><small>{doc.collection}</small><a href={`${humanDocumentUrl(doc.path)}/index.md`}>Markdown ↗</a></div>)}</div></article>
|
||||
<h2>按创作主题浏览</h2>
|
||||
@@ -7,7 +7,7 @@ export function NoteEditor({entry,onSaved,onCancel}:{entry:ExploreEntry;onSaved:
|
||||
const [title,setTitle]=useState(entry.title),[description,setDescription]=useState(entry.description),[markdown,setMarkdown]=useState(entry.editableMarkdown||'');
|
||||
const [allTags,setAllTags]=useState<Tag[]>([]),[selected,setSelected]=useState<string[]>([]),[revision,setRevision]=useState('');
|
||||
const [loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[tagBusy,setTagBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let live=true;void(async()=>{try{const res=await fetch('/api/notes/'+entry.id);const data=await res.json();if(!res.ok)throw Error(data.error||'无法读取笔记');if(live){setTitle(data.entry.title);setDescription(data.entry.description);setMarkdown(data.entry.editableMarkdown);setRevision(data.entry.revision);setAllTags(data.tags);setSelected(data.tags.filter((t:Tag)=>data.entry.categories.some((name:string)=>name.toLocaleLowerCase()===t.name.toLocaleLowerCase())).map((t:Tag)=>t.id));}}catch(e){if(live)setError(e instanceof Error?e.message:'读取失败');}finally{if(live)setLoading(false)}})();return()=>{live=false}},[entry.id]);
|
||||
useEffect(()=>{let live=true;void(async()=>{try{const res=await fetch('/api/notes/'+entry.id);const data=await res.json();if(!res.ok)throw Error(data.error||'无法读取笔记');if(live){setTitle(data.entry.title);setDescription(data.entry.description);setMarkdown(data.entry.editableMarkdown);setRevision(data.entry.revision);setAllTags(data.tags);setSelected(data.tags.filter((t:Tag)=>[...data.entry.categories,...(data.entry.systemTags||[]).map((s:Tag)=>s.name)].some((name:string)=>name.toLocaleLowerCase()===t.name.toLocaleLowerCase())).map((t:Tag)=>t.id));}}catch(e){if(live)setError(e instanceof Error?e.message:'读取失败');}finally{if(live)setLoading(false)}})();return()=>{live=false}},[entry.id]);
|
||||
async function save(e:FormEvent){e.preventDefault();if(busy||loading||tagBusy||!revision)return;setBusy(true);setError('');try{const res=await fetch('/api/notes/'+entry.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,description,markdown,tags:selected,revision})});const data=await res.json();if(!res.ok)throw Error(data.error||'保存失败');onSaved(data.entry);}catch(e){setError(e instanceof Error?e.message:'保存失败');}finally{setBusy(false)}}
|
||||
return <form className="note-editor" onSubmit={save} aria-label="编辑笔记"><h1 id="note-title">编辑笔记</h1>{loading&&<p role="status">正在读取最新内容…</p>}
|
||||
<fieldset disabled={loading||busy}><div className="form-field"><label htmlFor="note-edit-title">标题</label><input id="note-edit-title" autoFocus required maxLength={200} value={title} onChange={e=>setTitle(e.target.value)}/></div>
|
||||
@@ -0,0 +1,11 @@
|
||||
import {useState} from 'react';
|
||||
import {Tags,X} from 'lucide-react';
|
||||
import type {ExploreEntry} from '@/lib/explore';
|
||||
import type {Tag} from '@/lib/model';
|
||||
import {TagInput} from './TagInput';
|
||||
export function QuickTags({entry,onSaved}:{entry:ExploreEntry;onSaved:(value:ExploreEntry)=>void}){
|
||||
const [open,setOpen]=useState(false),[tags,setTags]=useState<Tag[]>([]),[selected,setSelected]=useState<string[]>([]),[revision,setRevision]=useState(''),[loading,setLoading]=useState(false),[busy,setBusy]=useState(false),[tagBusy,setTagBusy]=useState(false),[error,setError]=useState('');
|
||||
async function show(){setOpen(true);setLoading(true);setError('');setRevision('');try{const r=await fetch('/api/notes/'+entry.id),d=await r.json();if(!r.ok)throw Error(d.error);setTags(d.tags);const names=[...d.entry.categories,...(d.entry.systemTags||[]).map((t:Tag)=>t.name)];setSelected(d.tags.filter((t:Tag)=>names.includes(t.name)).map((t:Tag)=>t.id));setRevision(d.entry.revision);}catch{setError('读取标签失败,请关闭后重试')}finally{setLoading(false)}}
|
||||
async function save(){setBusy(true);setError('');try{const r=await fetch('/api/notes/'+entry.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({tags:selected,revision})}),d=await r.json();if(!r.ok)throw Error(d.error);onSaved(d.entry);setOpen(false);}catch(e){setError(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}}
|
||||
return <><button type="button" className="quick-tags-trigger" aria-label={'编辑 '+entry.title+' 的标签'} onClick={()=>void show()}><Tags size={14}/></button>{open&&<div className="tag-popover" role="dialog" aria-label={'编辑 '+entry.title+' 的标签'}><header><b>编辑标签</b><button type="button" aria-label="关闭标签编辑" disabled={busy||tagBusy} onClick={()=>setOpen(false)}><X size={17}/></button></header>{loading?<p role="status">读取中…</p>:revision&&<TagInput initial={tags} selected={selected} onChange={setSelected} disabled={busy} onBusyChange={setTagBusy}/>}<p role="alert" className="error-message">{error}</p><button type="button" className="primary-button" disabled={loading||busy||tagBusy||!revision} onClick={()=>void save()}>{busy?'保存中…':'保存标签'}</button></div>}</>;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function Shell({children}:{children:ReactNode}){
|
||||
const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||''));
|
||||
useEffect(()=>setQuery(String(router.query.q||'')),[router.query.q]);
|
||||
return <div className="app-shell"><button className="mobile-menu icon-button" aria-label={open?"关闭导航":"展开导航"} aria-expanded={open} onClick={()=>setOpen(!open)}>{open?<X size={22}/>:<Menu size={22}/>}</button>
|
||||
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="waterfall-wiki 首页" onClick={()=>setOpen(false)}><img className="brand-mark" src="/brand-mark.svg" width={40} height={40} alt=""/><strong>waterfall-wiki</strong></Link>
|
||||
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="PROMPT 首页" onClick={()=>setOpen(false)}><img className="brand-mark" src="/brand-mark.svg" width={40} height={40} alt=""/><strong>PROMPT</strong></Link>
|
||||
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
|
||||
|
||||
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
|
||||
@@ -26,7 +26,7 @@ export function SkillDocs({documentPath,documentId='index',skillId='street-peopl
|
||||
setCopied(true);
|
||||
}catch{setCopied(false)}
|
||||
}
|
||||
return <Shell><Head><title>{doc.title} · 指导文档 · waterfall-wiki</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
|
||||
return <Shell><Head><title>{doc.title} · 指导文档 · PROMPT</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
|
||||
<div className="docs-top"><Link href="/skills">← 全部指导文档</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {doc.resources.length}</button></div>
|
||||
<div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body">
|
||||
<div className="docs-eyebrow">{doc.collection} / 指导文档</div><h1>{doc.title}</h1>
|
||||
@@ -0,0 +1,11 @@
|
||||
import {useId,useState} from 'react';
|
||||
import type {Tag} from '@/lib/model';
|
||||
export function TagInput({initial,selected,onChange,disabled=false,onBusyChange}:{initial:Tag[];selected:string[];onChange:(ids:string[])=>void;disabled?:boolean;onBusyChange?:(busy:boolean)=>void}){
|
||||
const uid=useId(),[all,setAll]=useState(initial),[input,setInput]=useState(''),[systemQuery,setSystemQuery]=useState(''),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
const name=input.replace(/^#/,'').trim(),isHash=input.startsWith('#'),ordinary=all.filter(t=>t.kind!=='system'),systems=all.filter(t=>t.kind==='system');
|
||||
const suggestions=ordinary.filter(t=>!selected.includes(t.id)&&(!name||t.name.toLocaleLowerCase().includes(name.toLocaleLowerCase()))).slice(0,8);
|
||||
function toggle(id:string){onChange(selected.includes(id)?selected.filter(v=>v!==id):[...selected,id]);}
|
||||
async function add(){if(!isHash||!name||busy||disabled||selected.length>=50)return;setBusy(true);onBusyChange?.(true);setError('');try{const res=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:input})});const tag:Tag&{error?:string}=await res.json();if(!res.ok)throw Error(tag.error||'标签创建失败');if(tag.kind==='system')throw Error('请在系统标签列表勾选');setAll(a=>a.some(t=>t.id===tag.id)?a:[...a,tag]);if(!selected.includes(tag.id))onChange([...selected,tag.id]);setInput('');}catch(e){setError(e instanceof Error?e.message:'标签创建失败');}finally{setBusy(false);onBusyChange?.(false);}}
|
||||
return <div className="tag-manager"><div className="form-field"><label htmlFor={uid+'-normal'}>普通标签 <small>输入 #标签,回车添加或新建</small></label><div className="tag-input-selected">{ordinary.filter(t=>selected.includes(t.id)).map(t=><button key={t.id} type="button" disabled={disabled||busy} aria-label={'移除标签 '+t.name} onClick={()=>toggle(t.id)}>#{t.name}<span aria-hidden="true">×</span></button>)}</div><div className="tag-input-row"><input id={uid+'-normal'} value={input} maxLength={61} disabled={disabled||busy} placeholder="#添加普通标签" onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!e.nativeEvent.isComposing){e.preventDefault();void add()}}}/><button type="button" className="secondary-button" disabled={!isHash||!name||busy||disabled||selected.length>=50} onClick={()=>void add()}>{busy?'添加中…':'添加'}</button></div><div className="tag-input-suggestions">{suggestions.map(t=><button key={t.id} type="button" disabled={disabled||busy||selected.length>=50} onClick={()=>{toggle(t.id);setInput('')}}>#{t.name}</button>)}</div></div>
|
||||
<fieldset className="system-tag-picker" disabled={disabled||busy}><legend>系统标签 <small>只能搜索和勾选已有标签</small></legend><input aria-label="搜索系统标签" placeholder="搜索系统标签" value={systemQuery} onKeyDown={e=>{if(e.key==='Enter')e.preventDefault()}} onChange={e=>setSystemQuery(e.target.value)}/><div>{systems.filter(t=>t.name.includes(systemQuery.trim())).map(t=><label key={t.id}><input type="checkbox" checked={selected.includes(t.id)} disabled={!selected.includes(t.id)&&selected.length>=50} onChange={()=>toggle(t.id)}/>{t.name}</label>)}</div>{!systems.some(t=>t.name.includes(systemQuery.trim()))&&<p>没有匹配的系统标签</p>}</fieldset>{error&&<p role="alert" className="error-message">{error}</p>}</div>;
|
||||
}
|
||||
@@ -58,6 +58,6 @@ export function guidanceMarkdown(path:string,base=origin){
|
||||
}
|
||||
export function guidanceIndexMarkdown(root=false,base=origin){
|
||||
const url=(href:string)=>new URL(href,base).href;
|
||||
return `# ${root?'waterfall-wiki':'指导文档'}\n\n[网页版本](${url(root?'/':'/skills')}) · [指导文档目录](${url('/skills/index.md')})\n\n每篇指导文档都有独立网址,网页地址加 /index.md 即为对应的 Markdown。文档可以相互引用,不要求从某个技能入口逐级进入。\n\n## 全部指导文档\n\n`+
|
||||
return `# ${root?'PROMPT':'指导文档'}\n\n[网页版本](${url(root?'/':'/skills')}) · [指导文档目录](${url('/skills/index.md')})\n\n每篇指导文档都有独立网址,网页地址加 /index.md 即为对应的 Markdown。文档可以相互引用,不要求从某个技能入口逐级进入。\n\n## 全部指导文档\n\n`+
|
||||
guidanceDocuments.map(d=>`- [${d.title}](${url(markdownDocumentUrl(d.path))}) — ${d.collection}`).join('\n')+'\n';
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export const namespaces=['Main','File','Category','Template','Draft','Help'] as
|
||||
export type Namespace=typeof namespaces[number];
|
||||
export type WikiPage={namespace:Namespace;slug:string;title:string;summary:string;body:string;categories:string[];color:string};
|
||||
export const wikiPages:WikiPage[]=[
|
||||
{namespace:'Main',slug:'welcome',title:'让灵感有处可寻',summary:'像翻阅笔记一样,发现和连接知识。',body:'这里是 waterfall-wiki Wiki。每个条目都有一个稳定的页面地址,以及对应的 Markdown 正文。\n\n名字空间决定页面的用途,分类把不同用途的内容连接起来。素材可以被多篇条目引用,草稿也可以在成熟后成为正式条目。',categories:['创作方法'],color:'#f5ddd2'},
|
||||
{namespace:'Main',slug:'welcome',title:'让灵感有处可寻',summary:'像翻阅笔记一样,发现和连接知识。',body:'这里是 PROMPT Wiki。每个条目都有一个稳定的页面地址,以及对应的 Markdown 正文。\n\n名字空间决定页面的用途,分类把不同用途的内容连接起来。素材可以被多篇条目引用,草稿也可以在成熟后成为正式条目。',categories:['创作方法'],color:'#f5ddd2'},
|
||||
{namespace:'Help',slug:'namespaces',title:'名字空间,内容的不同房间',summary:'条目、文件、分类、模板与草稿,各有自己的位置。',body:'Main:正式知识条目。\nFile:图片、视频、音频的说明页,现有素材包位于 /wiki/File:slug,同时保留 /p/ 兼容地址。\nCategory:跨名字空间的分类索引。\nTemplate:可复用的内容结构说明。\nDraft:待完善的草稿(当前内容公开,不用于私密信息)。\nHelp:使用指南。\n\n在 src/content/wiki.ts 添加内容,即可生成网页与 AI 正文。现有素材包也提供 /api/wiki-post/slug 的 Markdown。',categories:['使用指南'],color:'#dde8df'},
|
||||
{namespace:'Template',slug:'idea',title:'一个想法的骨架',summary:'从问题、观察到表达,让创作有迹可循。',body:'## 问题\n你想回答什么?\n\n## 观察\n收集具体场景与可信来源。\n\n## 表达\n把结论写成独立的页面,再交给创作站制作。',categories:['创作方法'],color:'#e6def0'},
|
||||
{namespace:'Draft',slug:'inbox',title:'还没成形,也值得留下',summary:'给正在生长的想法留一个位置。',body:'草稿用于公开孵化内容。补充来源、结构和分类后,可以迁移到 Main 名字空间。',categories:['创作方法'],color:'#f0e7ca'}];
|
||||
@@ -20,8 +20,8 @@ export const apiDocuments:Record<string,readonly string[]>={
|
||||
],
|
||||
"/api/notes/{id}": [
|
||||
"读取与编辑笔记",
|
||||
"GET, PUT",
|
||||
"路径参数:id,笔记稳定 ID(publicId 或存储 UUID)。PUT 使用 application/json,以下字段必填:title(1–200 字符)、description(最多 1000 字符)、markdown(最多 100000 字符)、tags(最多 50 个标签 UUID)、revision(GET 返回的 entry.revision,64 位十六进制)。可选 assets(最多 100 项 {id:媒体 UUID,role:content|caption},替换媒体关联,原二进制不删除);coverAssetId(图片 UUID 或 null,必须为本笔记 content 图片;null 清除显式封面)。静态代码页须先通过创建接口保存为数据库笔记,才可更新媒体。",
|
||||
"GET, PUT, PATCH",
|
||||
"PATCH 只更新标签,application/json 必须为 {tags:标签 UUID 数组,revision:GET 返回值};系统标签从 GET /api/tags 中 kind=system 的固定项选择。路径参数:id,笔记稳定 ID(publicId 或存储 UUID)。PUT 使用 application/json,以下字段必填:title(1–200 字符)、description(最多 1000 字符)、markdown(最多 100000 字符)、tags(最多 50 个标签 UUID)、revision(GET 返回的 entry.revision,64 位十六进制)。可选 assets(最多 100 项 {id:媒体 UUID,role:content|caption},替换媒体关联,原二进制不删除);coverAssetId(图片 UUID 或 null,必须为本笔记 content 图片;null 清除显式封面)。静态代码页须先通过创建接口保存为数据库笔记,才可更新媒体。",
|
||||
"GET 返回 {entry,tags},entry.backlinks 为引用来源 {id,title} 数组,entry.backlinkCount 为其长度;只计算正文中本站 post 的 Markdown 链接(含引用式链接和粘贴的完整网址),排除代码块、图片、外站链接及自引用。entry.assets 为媒体关联、entry.coverAssetId 为显式封面;PUT 返回更新后的 {entry}。先 GET 获取 revision,再 PUT;冲突后重新读取。",
|
||||
"GET /api/notes/{id}\nPUT /api/notes/{id}\n{\"title\":\"标题\",\"description\":\"简介\",\"markdown\":\"正文\",\"tags\":[],\"revision\":\"<GET 返回的 entry.revision>\"}",
|
||||
"400:输入错误;404:笔记不存在;409:版本冲突。"
|
||||
@@ -37,7 +37,7 @@ export const apiDocuments:Record<string,readonly string[]>={
|
||||
"/api/tags": [
|
||||
"标签列表与创建",
|
||||
"GET, POST",
|
||||
"POST 使用 application/json:name(必填,去掉开头 # 并 trim 后 1–60 字符,不含 #、空白、英文或中文逗号);slug(可选,最多 60 字符,以小写字母或数字开头,仅含小写字母、数字、连字符)。",
|
||||
"标签 JSON 含 kind=normal|system;系统项另含 system_key。POST 只允许创建/复用普通标签,不能提交 kind 或 system_key,也不能用名称添加系统标签;系统标签只通过已有 UUID 选择。POST 使用 application/json:name(必填,去掉开头 # 并 trim 后 1–60 字符,不含 #、空白、英文或中文逗号);slug(可选,最多 60 字符,以小写字母或数字开头,仅含小写字母、数字、连字符)。",
|
||||
"GET 返回标签数组;POST 新建返回 201,名称已存在时复用并返回 200。标签含 id、name、slug、path。",
|
||||
"POST /api/tags\n{\"name\":\"排版\"}",
|
||||
"400:输入错误;409:slug 冲突。"
|
||||
@@ -1,5 +1,5 @@
|
||||
import {fromMarkdown} from 'mdast-util-from-markdown';
|
||||
export type SearchDocument={id:string;aliases?:string[];title:string;description:string;markdown:string;categories:string[];namespace:string;createdAt:string};
|
||||
export type SearchDocument={id:string;aliases?:string[];title:string;description:string;markdown:string;categories:string[];systemTags?:string[];namespace:string;createdAt:string};
|
||||
export type Backlink={id:string;title:string};
|
||||
const normalize=(value:string)=>value.normalize('NFKC').toLocaleLowerCase().replace(/\s+/g,' ').trim();
|
||||
/** Markdown AST excludes images, code examples and unused link definitions. */
|
||||
@@ -26,14 +26,14 @@ export function backlinksFor(documents:SearchDocument[]){
|
||||
}
|
||||
export function searchTerms(query:string){return (normalize(query).match(/"[^"]+"|[^\s"]+/g)||[]).map(s=>s.replace(/^"|"$/g,''));}
|
||||
export function searchScore(doc:SearchDocument,query:string):number{
|
||||
const fields=[normalize(doc.title),normalize(doc.categories.join(' ')),normalize(doc.description),normalize(doc.markdown)],terms=searchTerms(query);
|
||||
const fields=[normalize(doc.title),normalize([...doc.categories,...(doc.systemTags||[])].join(' ')),normalize(doc.description),normalize(doc.markdown)],terms=searchTerms(query);
|
||||
if(!terms.length)return 0;
|
||||
if(!terms.every(t=>fields.some(f=>f.includes(t))))return -1;
|
||||
return terms.reduce((score,t)=>score+fields.reduce((n,f,i)=>n+(f.includes(t)?[20,10,5,1][i]:0),0),0)+(fields[0]===normalize(query)?100:0);
|
||||
}
|
||||
export function rankDocuments(documents:SearchDocument[],q='',category=''){
|
||||
const links=backlinksFor(documents);
|
||||
return documents.map(doc=>({doc,score:searchScore(doc,q),backlinkCount:links.get(doc.id)!.length})).filter(x=>x.score>=0&&(!category||x.doc.categories.includes(category)||x.doc.namespace===category)).sort((a,b)=>b.score-a.score||b.backlinkCount-a.backlinkCount||b.doc.createdAt.localeCompare(a.doc.createdAt)||a.doc.id.localeCompare(b.doc.id));
|
||||
return documents.map(doc=>({doc,score:searchScore(doc,q),backlinkCount:links.get(doc.id)!.length})).filter(x=>x.score>=0&&(!category||x.doc.categories.includes(category)||x.doc.systemTags?.includes(category)||x.doc.namespace===category)).sort((a,b)=>b.score-a.score||b.backlinkCount-a.backlinkCount||b.doc.createdAt.localeCompare(a.doc.createdAt)||a.doc.id.localeCompare(b.doc.id));
|
||||
}
|
||||
export class InvalidDiscoveryCursor extends Error{}
|
||||
export function discoveryPage<T extends {doc:{id:string}}>(ranked:T[],q:string,category:string,cursor?:string){
|
||||
@@ -1,3 +1,5 @@
|
||||
import type {Tag} from './model';
|
||||
import {systemTags,ordinaryTags,selectedSystemTags} from './system-tags';
|
||||
import type {PoolClient} from 'pg';
|
||||
import {noteDescriptions} from '@/content/note-descriptions';
|
||||
import {guidanceDocuments,guidanceMarkdown} from '@/content/skills/guidance';
|
||||
@@ -9,8 +11,8 @@ import {getPostById,discoveryDocuments,postsByPublicIds} from './library';
|
||||
import {backlinksFor,rankDocuments,discoveryPage,type SearchDocument,type Backlink} from './discovery';
|
||||
import {applyStaticEdit,staticEdits,withRevision} from './note-edits';
|
||||
export type ExploreMedia={kind:'image'|'video'|'audio';url:string;title:string;poster?:string};
|
||||
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string;editableMarkdown?:string;revision?:string;assets?:{id:string;kind:string;role:string}[];coverAssetId?:string|null;backlinkCount?:number;backlinks?:Backlink[];searchExcerpt?:string};
|
||||
export const staticEntries:ExploreEntry[]=[...guidanceDocuments.map(doc=>{const card=skillCatalog.find(c=>doc.path===c.id);return {id:pageId('guidance:'+doc.path),title:doc.title,description:noteDescriptions[doc.path]||doc.blocks.find(b=>b.type==='paragraph')?.text||doc.title,imageCount:new Set([...(card?[card.image]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>r.href)]).size,namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media:Array.from(new Map([...(card?[{kind:'image' as const,url:card.image,title:card.alt}]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>({kind:'image' as const,url:r.href,title:r.title}))].map(m=>[m.url,m])).values()),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,description:page.summary,imageCount:0,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))];
|
||||
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];systemTags?:Tag[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string;editableMarkdown?:string;revision?:string;assets?:{id:string;kind:string;role:string}[];coverAssetId?:string|null;backlinkCount?:number;backlinks?:Backlink[];searchExcerpt?:string};
|
||||
export const staticEntries:ExploreEntry[]=[...guidanceDocuments.map(doc=>{const card=skillCatalog.find(c=>doc.path===c.id);return {id:pageId('guidance:'+doc.path),title:doc.title,description:noteDescriptions[doc.path]||doc.blocks.find(b=>b.type==='paragraph')?.text||doc.title,imageCount:new Set([...(card?[card.image]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>r.href)]).size,namespace:'Main',categories:[doc.collection,...(doc.collection==='照片入皮影'?['中国风']:doc.collection==='街头小人'?['贴纸']:[])],systemTags:card?[systemTags[0]]:[],markdown:guidanceMarkdown(doc.path)!,media:Array.from(new Map([...(card?[{kind:'image' as const,url:card.image,title:card.alt}]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>({kind:'image' as const,url:r.href,title:r.title}))].map(m=>[m.url,m])).values()),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,description:page.summary,imageCount:0,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))];
|
||||
// Canonicalize document references, including absolute links; pictures remain explicit links.
|
||||
export function canonicalMarkdown(text:string){return text.replace(/\]\((?:http:\/\/8\.211\.154\.134:18131)?\/skills\/([^ )]+)\)/g,(whole,path:string)=>{const clean=path.replace(/\/index\.md$/,'').replace(/\.md$/,'');return pageId('guidance:'+clean)?']('+guidanceUrl(clean)+'?agent_mode=True)':whole;});}
|
||||
async function readEntry(id:string,client?:PoolClient):Promise<ExploreEntry|null>{
|
||||
@@ -21,13 +23,13 @@ async function readEntry(id:string,client?:PoolClient):Promise<ExploreEntry|null
|
||||
const media:ExploreMedia[]=orderedAssets.filter(a=>a.kind!=='text'&&a.role==='content').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+post.assets.find(p=>p.source_asset_id===a.id&&p.derivation==='poster')?.id}:{})}));
|
||||
const editableMarkdown=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n');
|
||||
const body=editableMarkdown+'\n\n'+post.assets.filter(a=>a.derivation).map(a=>'['+({poster:'视频封面',audio_track:'独立音轨',silent_video:'静音视频'}[a.derivation||'']||a.derivation)+'](/api/media/'+a.id+')').join(' · ');
|
||||
return withRevision({id:publicPostId(post),assets:post.assets.filter(a=>a.kind!=='text').map(a=>({id:a.id,kind:a.kind,role:a.role})),coverAssetId:post.cover_asset_id,title:post.title,editableMarkdown,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug});
|
||||
return withRevision({id:publicPostId(post),assets:post.assets.filter(a=>a.kind!=='text').map(a=>({id:a.id,kind:a.kind,role:a.role})),coverAssetId:post.cover_asset_id,title:post.title,editableMarkdown,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:ordinaryTags(post.tags).map(t=>t.name),systemTags:selectedSystemTags(post.tags),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug});
|
||||
}
|
||||
export async function discoveryCorpus(client?:PoolClient){
|
||||
const [stored,edits]=await Promise.all([process.env.DATABASE_URL||client?discoveryDocuments(client):Promise.resolve([]),staticEdits(client)]);
|
||||
const published=new Set(stored.map(p=>p.public_id));
|
||||
const pages=staticEntries.filter(e=>!published.has(e.id)).map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));
|
||||
const documents:SearchDocument[]=[...pages.map(e=>({id:e.id,title:e.title,description:e.description,markdown:canonicalMarkdown(e.markdown),categories:e.categories,namespace:e.namespace,createdAt:''})),...stored.map(p=>({id:p.public_id||p.id,aliases:[p.id],title:p.title,description:p.description,markdown:canonicalMarkdown(p.markdown),categories:p.categories,namespace:['image','video','audio'].includes(p.kind)?'File':'Main',createdAt:new Date(p.created_at).toISOString()}))];
|
||||
const documents:SearchDocument[]=[...pages.map(e=>({id:e.id,title:e.title,description:e.description,markdown:canonicalMarkdown(e.markdown),categories:e.categories,systemTags:e.systemTags?.flatMap(t=>[t.name,t.system_key||'']),namespace:e.namespace,createdAt:''})),...stored.map(p=>({id:p.public_id||p.id,aliases:[p.id],title:p.title,description:p.description,markdown:canonicalMarkdown(p.markdown),categories:p.categories,systemTags:(p.system_tags||[]).flatMap((t:Tag)=>[t.name,t.system_key||'']),namespace:['image','video','audio'].includes(p.kind)?'File':'Main',createdAt:new Date(p.created_at).toISOString()}))];
|
||||
return {documents,pages};
|
||||
}
|
||||
export async function getEntry(id:string,client?:PoolClient):Promise<ExploreEntry|null>{
|
||||
@@ -43,8 +45,8 @@ export async function exploreFeed(q='',category='',cursor?:string){
|
||||
if(original)return {...original,backlinkCount,...(q?{searchExcerpt:excerpt(doc.markdown,q)}:{})};
|
||||
if(!post)throw Error('Search result disappeared');
|
||||
const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id,video=post.assets.find(a=>a.kind==='video'),poster=post.assets.find(a=>a.source_asset_id===video?.id&&a.derivation==='poster')?.id;
|
||||
return {id:doc.id,title:doc.title,description:doc.description||doc.markdown.slice(0,180)||doc.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:doc.namespace,categories:doc.categories,markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:doc.title}]:poster?[{kind:'image' as const,url:'/api/media/'+poster,title:doc.title}]:[],color:'#eee5dc',backlinkCount,...(q?{searchExcerpt:excerpt(doc.markdown,q)}:{})};
|
||||
return {id:doc.id,title:doc.title,description:doc.description||doc.markdown.slice(0,180)||doc.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:doc.namespace,categories:doc.categories,systemTags:selectedSystemTags(post.tags),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:doc.title}]:poster?[{kind:'image' as const,url:'/api/media/'+poster,title:doc.title}]:[],color:'#eee5dc',backlinkCount,...(q?{searchExcerpt:excerpt(doc.markdown,q)}:{})};
|
||||
});return {entries,nextCursor:page.nextCursor,total:page.total,sort:q?'relevance,backlinks,newest':'backlinks,newest'};
|
||||
}
|
||||
function excerpt(markdown:string,q:string){const clean=markdown.replace(/[`#*_[\]<>]/g,'').replace(/\s+/g,' '),term=q.trim().replace(/^"|"$/g,'').split(/\s+/)[0]||'',at=clean.toLocaleLowerCase().indexOf(term.toLocaleLowerCase());return (at>50?'…':'')+clean.slice(Math.max(0,at-50),Math.max(0,at-50)+180);}
|
||||
export function entryText(entry:ExploreEntry){return `${entry.markdown}\n\nDescription: ${entry.description}\nBacklinks: ${entry.backlinkCount??0}\n${(entry.backlinks||[]).map(b=>'引用来源:['+b.title+']('+exploreUrl(b.id)+')').join('\n')}\nNamespace: ${entry.namespace}\nCategories: ${entry.categories.join(', ')}\n\n`+entry.media.map(m=>`- ${m.kind}: [${m.title}](${m.url})`).join('\n')+'\n\n[网页]('+exploreUrl(entry.id)+')\n';}
|
||||
export function entryText(entry:ExploreEntry){return `${entry.markdown}\n\nDescription: ${entry.description}\nBacklinks: ${entry.backlinkCount??0}\n${(entry.backlinks||[]).map(b=>'引用来源:['+b.title+']('+exploreUrl(b.id)+')').join('\n')}\nSystem tags: ${(entry.systemTags||[]).map(t=>t.name).join(', ')}\nNamespace: ${entry.namespace}\nCategories: ${entry.categories.join(', ')}\n\n`+entry.media.map(m=>`- ${m.kind}: [${m.title}](${m.url})`).join('\n')+'\n\n[网页]('+exploreUrl(entry.id)+')\n';}
|
||||
@@ -38,7 +38,8 @@ export async function publishedPublicIds():Promise<string[]>{return (await db().
|
||||
export async function discoveryDocuments(client?:PoolClient){
|
||||
return (await (client||db()).query(`SELECT p.id,p.public_id,p.title,p.description,p.kind,p.created_at,
|
||||
COALESCE((SELECT string_agg(a.body,E'\n\n' ORDER BY pa.position) FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND a.kind='text'),'') AS markdown,
|
||||
COALESCE((SELECT jsonb_agg(t.name) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS categories
|
||||
COALESCE((SELECT jsonb_agg(t.name) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id AND t.kind='normal'),'[]') AS categories,
|
||||
COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id AND t.kind='system'),'[]') AS system_tags
|
||||
FROM posts p WHERE EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role IN ('content','caption') AND ((a.storage_key IS NOT NULL AND a.byte_size>0) OR (a.kind='text' AND a.body IS NOT NULL)))`)).rows;
|
||||
}
|
||||
export async function postsByPublicIds(ids:string[],client?:PoolClient):Promise<Post[]>{
|
||||
@@ -7,7 +7,7 @@ export const sections = [
|
||||
] as const;
|
||||
export type PostKind = typeof sections[number]["kind"];
|
||||
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[]; role: string; source_asset_id: string | null; derivation: string | null };
|
||||
export type Tag = { id: string; name: string; slug: string; path: string };
|
||||
export type Tag = { id: string; name: string; slug: string; path: string;kind?:'normal'|'system';system_key?:string|null };
|
||||
export type Post = { description: string; id: string; public_id?: string | null; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
|
||||
export type FeedData = { posts: Post[]; tags: Tag[]; contributions: { day: string; total: number }[]; nextCursor: string | null; unavailable?: boolean };
|
||||
export function validSection(path: string) { return sections.find(s => s.path === path); }
|
||||
@@ -8,12 +8,12 @@ import {postInput} from './write';
|
||||
export function withoutTitle(markdown:string){return markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'');}
|
||||
export function withRevision(entry:ExploreEntry):ExploreEntry{
|
||||
const editableMarkdown=entry.editableMarkdown??withoutTitle(entry.markdown);
|
||||
const revision=createHash('sha256').update(JSON.stringify([entry.title,entry.description,editableMarkdown,[...entry.categories].sort(),entry.coverAssetId??null,entry.assets?.map(a=>[a.id,a.role])??[]])).digest('hex');
|
||||
const revision=createHash('sha256').update(JSON.stringify([entry.title,entry.description,editableMarkdown,[...entry.categories].sort(),(entry.systemTags||[]).map(t=>t.id).sort(),entry.coverAssetId??null,entry.assets?.map(a=>[a.id,a.role])??[]])).digest('hex');
|
||||
return {...entry,editableMarkdown,revision};
|
||||
}
|
||||
export async function staticEdits(client?:PoolClient){if(!process.env.DATABASE_URL)return [];return (await (client||db()).query(`SELECT e.*,COALESCE((SELECT jsonb_agg(t.name ORDER BY t.name) FROM note_edit_tags et JOIN tags t ON t.id=et.tag_id WHERE et.page_id=e.page_id),'[]') AS categories FROM note_edits e`)).rows;}
|
||||
export function applyStaticEdit(entry:ExploreEntry,edit?:{title:string;description:string;markdown:string;categories:string[]}):ExploreEntry{
|
||||
return edit?{...entry,title:edit.title,description:edit.description,editableMarkdown:edit.markdown,markdown:'# '+edit.title+'\n\n'+edit.markdown,categories:edit.categories}:entry;
|
||||
export async function staticEdits(client?:PoolClient){if(!process.env.DATABASE_URL)return [];return (await (client||db()).query(`SELECT e.*,COALESCE((SELECT jsonb_agg(t.name ORDER BY t.name) FROM note_edit_tags et JOIN tags t ON t.id=et.tag_id WHERE et.page_id=e.page_id AND t.kind='normal'),'[]') AS categories,COALESCE((SELECT jsonb_agg(t ORDER BY t.name) FROM note_edit_tags et JOIN tags t ON t.id=et.tag_id WHERE et.page_id=e.page_id AND t.kind='system'),'[]') AS "systemTags" FROM note_edits e`)).rows;}
|
||||
export function applyStaticEdit(entry:ExploreEntry,edit?:{title:string;description:string;markdown:string;categories:string[];systemTags?:ExploreEntry['systemTags']}):ExploreEntry{
|
||||
return edit?{...entry,title:edit.title,description:edit.description,editableMarkdown:edit.markdown,markdown:'# '+edit.title+'\n\n'+edit.markdown,categories:edit.categories,systemTags:edit.systemTags||[]}:entry;
|
||||
}
|
||||
export const noteEditInput=z.object({title:z.string().trim().min(1).max(200),description:z.string().trim().max(1000),markdown:z.string().max(100000),tags:z.array(z.uuid()).max(50),revision:z.string().regex(/^[0-9a-f]{64}$/),assets:postInput.shape.assets.unwrap().optional(),coverAssetId:z.uuid().nullable().optional()});
|
||||
export class NoteEditConflict extends Error{}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type {Tag} from './model';
|
||||
export const systemTags:Tag[]=[
|
||||
{id:'6f890bd2-9f64-4781-8a21-651467ab7101',name:'文档入口',slug:'document-entry',path:'document-entry',kind:'system',system_key:'document-entry'},
|
||||
{id:'6f890bd2-9f64-4781-8a21-651467ab7102',name:'参考图',slug:'reference-image',path:'reference-image',kind:'system',system_key:'reference-image'},
|
||||
];
|
||||
export const ordinaryTags=(values:Tag[])=>values.filter(t=>t.kind!=='system');
|
||||
export const selectedSystemTags=(values:Tag[])=>values.filter(t=>t.kind==='system');
|
||||