Compare commits

17 Commits
Author SHA1 Message Date
chenmoranzhiandCursor 7185f25dec 补上费用核对与缓存审计材料,方便后续按结论继续压创作成本。
交接说明自包含,不依赖聊天记录也能接着做优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:51 +08:00
chenmoranzhiandCursor 66ed1eaca8 会话与前端接上听书、呈现投影、请求轮廓和旁观气泡偏好。
新建作品可选听书连载,朗读与消费进度走专用接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:44 +08:00
chenmoranzhiandCursor cf6cb68429 收紧世界模拟器能力与壳提示,对齐新的落盘合同和呈现分工。
让选题填写与游玩投影读到一致的规格口径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:34 +08:00
chenmoranzhiandCursor ff5c77bfb1 新增听书连载进料,按消费进度连续吐出正文缓冲。
与对话落盘并列,适合边听边往后生成的路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:25 +08:00
chenmoranzhiandCursor 820397e578 接入边缘浏览器语音合成并做本地缓存,听书段落可直接朗读。
依赖随锁文件一并落下,避免环境缺包。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:48:43 +08:00
chenmoranzhiandCursor 1cc4ae557a 创作一轮改为先选题再填写,减少工具空转和重复覆盖。
上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:48:20 +08:00
chenmoranzhiandCursor d0a48f9431 把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:47:58 +08:00
chenmoranzhiandCursor bb4f934b3d 补齐缓存写量与请求轮廓记账,便于核对提示缓存是否真的命中。
给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:47:51 +08:00
fazhu d0274afb80 Merge pull request 'docs(README):更新一版README,丰富了项目介绍,提高可读性' (#1) from docs/readme-and-review into master
Reviewed-on: #1
2026-09-30 16:10:31 +08:00
ociflab-bot 48801c656d docs(README):更新一版README,丰富了项目介绍,提高可读性 2026-09-30 16:09:53 +08:00
chenmoranzhiandCursor d09829bdb8 补一份从启动到写卡开玩的使用说明,下载后能照着做。
截图按真实界面走了一遍最简的短对话卡。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 15:26:41 +08:00
chenmoranzhiandCursor 8bd469ceeb 正文组成的前端在验收时隔离预览,避免模板脚本碰到宿主页面。
超限只展示源码,测试钉住沙箱和文档拼装。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 15:26:33 +08:00
chenmoranzhiandCursor c2c9203fb0 新建作品改为直接进入对话落盘,按本局创作方案写能力,并把楼层呈现和零层卡分开。
输入栏同时可以按分组切换模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 12:00:46 +08:00
chenmoranzhiandCursor 39fe2732fb 把思考强度收到接口配置里,并支持分组、嗅探和切换同一接口下的模型。
预设不再保存思考强度。设置页会在首屏套用主题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 12:00:27 +08:00
chenmoranzhiandCursor defd6739b7 让技能、库、壳和统计页在首屏套用主题,并在页内滚动而不是整页被撑开。
技能池说明改为按本局清单取用能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 12:00:20 +08:00
chenmoranzhiandCursor 03847bdb29 启动网页服务时不再监视文件,并加上控制台脚本用来查询、启停和看日志。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 12:00:19 +08:00
chenmoranzhiandCursor a11c460223 把偏好库和文风库做成可提交的种子,方便数据丢了还能合并恢复。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 12:00:12 +08:00
201 changed files with 24097 additions and 3983 deletions
+92
View File
@@ -0,0 +1,92 @@
# Writing Agent
本地运行的多 Agent 文本创作与游玩平台:先为一部作品搭好可复用的运行规格,再进入游玩——你说一句,它按规格写一轮。适合交互式角色扮演、短局/长文、仿写扩写等场景。
数据默认落在本机用户目录(`~/.writing-agent`),通过浏览器或 Electron 桌面窗口使用;模型走你自己配置的 API。
## 它能做什么
- **创作**:为新作品选进料方式(工序编排 / 对话落盘(默认选项))与配方,由编排器调度技能,落成可复用的运行规格与开场
- **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度
- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择)
- **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复
- **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型
- **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat`
## 快速开始
1. 安装 [Node.js](https://nodejs.org/)(含 npm)
2. 双击 `start.bat`(桌面窗口则用 `start-app.bat`),浏览器打开 [http://localhost:23337](http://localhost:23337)
3. 在 **设置** 里新增模型接口(Base URL、API Key、型号);根目录已有 `.env` 时可跳过
4. **新建作品** → 对话写出一张简单可玩卡 → **保存定稿** → 左侧菜单选 **游玩**
完整图文步骤(含截图与示例提示词)见 **[从启动到开玩](docs/guide/getting-started.md)**。
## 核心回路
```text
新建作品 → 选进料方式与配方
→ 创作(编排技能 / 对话落盘)→ 运行规格 + 开场
→ 保存定稿
→ 游玩(执行单元按规格上场)→ 保存游玩
```
术语以 `[docs/ui-glossary.md](docs/ui-glossary.md)` 为准(进料方式、配方、编排器、技能、运行规格、执行单元)。
## 仓库一览
| 路径 | 用途 |
| ------------------- | ------------------------------ |
| `src/` | 运行时、会话、技能装载、Book/预设/库、HTTP API |
| `web/` | 前端静态页 |
| `skills/` | 技能包(见 `skills/README.md`) |
| `examples/presets/` | 示例预设 |
| `seeds/libraries/` | 文风/偏好库种子 |
| `electron/` | 桌面壳 |
| `docs/` | 架构与契约文档 |
| `tests/` | Vitest |
用户数据目录可用环境变量 `WRITING_AGENT_DATA_DIR` 覆盖;默认 `~/.writing-agent`(Windows 多为 `%USERPROFILE%\.writing-agent`)。
## 维护者
开发与排障常用命令:
```bash
npm install
npm run web # 启动 HTTP 服务(不监视文件)
npm run web:watch # 开发时监视 src/ 与 skills/
npm test # Vitest
npm run libraries:restore # 从 seeds 恢复库
npm run libraries:export # 导出库到 seeds
```
Windows 运维脚本:`console.bat`(查询 / 启停 / 日志)。入口实现:`src/server/web-server.ts` → `session-manager.ts` → `src/runtime/` + `src/main-agent/` + `skills/`。
**安全注意**:本地 HTTP 控制面默认无鉴权,且会监听本机端口;请勿把服务暴露到不可信网络。API Key 以明文保存在用户数据目录的配置里。
### 文档索引
| 文档 | 内容 |
| --------------------------------------------------------------- | -------------------- |
| [architecture.md](docs/architecture.md) | 系统架构与模块边界 |
| [ui-glossary.md](docs/ui-glossary.md) | 用户可见术语 |
| [implementation-guide.md](docs/implementation-guide.md) | 文档地图与实现约定 |
| [runtime-state-machine.md](docs/runtime-state-machine.md) | 相位机 |
| [tool-contracts.md](docs/tool-contracts.md) | Tool 契约 |
| [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) |
| [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 |
| [preset-format.md](docs/preset-format.md) | 预设格式 |
| [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 |
| [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 |
| [skills/README.md](skills/README.md) | 技能包目录说明 |
`docs/book-storage.md` 描述的是目标存储模型,**不是**当前磁盘布局;当前以 Book JSON + session + run-snapshots 为准。
## 许可与第三方
见 `[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)`。项目版本见 `package.json`(当前 `0.1.0`,`private`)。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Writing Agent">
<rect width="64" height="64" rx="14" fill="#1b353c"/>
<rect x="15" y="12" width="34" height="40" rx="4" fill="#f6f3ea"/>
<rect x="15" y="16" width="4" height="32" fill="#4a9fb0"/>
<rect x="24" y="21" width="18" height="2.6" rx="1.3" fill="#243e45"/>
<rect x="24" y="27.5" width="13" height="2.6" rx="1.3" fill="#4a9fb0"/>
<path d="M24 36.5 29.2 40.5 24 44.5" fill="none" stroke="#1b353c" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32 44.5h8" fill="none" stroke="#1b353c" stroke-width="2.4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

+162
View File
@@ -0,0 +1,162 @@
@echo off
chcp 65001 >nul
setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
title Writing Agent
set "PS=powershell -NoProfile -ExecutionPolicy Bypass -File"
set "CTL=%~dp0console.ps1"
call :ensure
if errorlevel 1 (
echo.
echo 启动失败。
pause
goto :menu
)
start "" "http://127.0.0.1:23337/"
:menu
cls
echo.
echo ========================================
echo Writing Agent 本机
echo http://localhost:23337
echo 不走隧道,也不在服务机上跑。
echo 不监视文件。改完代码后输入 update。
echo ========================================
echo.
echo start 启动服务并打开页面
echo stop 停止服务
echo restart 重启服务
echo update 用当前代码重启服务
echo open 打开页面
echo status 端口和 git 状态
echo pull git pull --ff-only
echo push git push
echo log 最近日志
echo help
echo exit 退出控制台,服务继续运行
echo.
set "LINE="
set /p "LINE=指令: "
if not defined LINE goto :menu
call :dispatch "%LINE%"
if "%ERRORLEVEL%"=="2" goto :eof
goto :menu
:dispatch
set "RAW=%~1"
set "CHK=%RAW:&=%"
if not "%CHK%"=="%RAW%" goto :bad
set "CHK=%RAW:|=%"
if not "%CHK%"=="%RAW%" goto :bad
set "CHK=%RAW:>=%"
if not "%CHK%"=="%RAW%" goto :bad
set "CHK=%RAW:<=%"
if not "%CHK%"=="%RAW%" goto :bad
set "CMD="
for /f "tokens=1" %%A in ("%RAW%") do set "CMD=%%A"
if /i "%CMD%"=="exit" exit /b 2
if /i "%CMD%"=="quit" exit /b 2
if /i "%CMD%"=="help" goto :show
if /i "%CMD%"=="start" goto :do_start
if /i "%CMD%"=="stop" goto :do_stop
if /i "%CMD%"=="restart" goto :do_update
if /i "%CMD%"=="update" goto :do_update
if /i "%CMD%"=="open" goto :do_open
if /i "%CMD%"=="status" goto :do_status
if /i "%CMD%"=="pull" goto :do_pull
if /i "%CMD%"=="push" goto :do_push
if /i "%CMD%"=="log" goto :do_log
goto :bad
:show
echo.
echo update 重启进程以加载当前代码,不监视文件。
echo pull 快进拉取,不自动重启。拉取后需要 update。
echo push 推送当前分支,不提交、不强制推送。
echo exit 只关这个窗口。
echo.
pause
exit /b 0
:do_start
echo.
%PS% "%CTL%" -Action start
if errorlevel 1 goto :held
start "" "http://127.0.0.1:23337/"
echo.
pause
exit /b 0
:do_stop
echo.
%PS% "%CTL%" -Action stop
echo.
pause
exit /b 0
:do_update
echo.
%PS% "%CTL%" -Action restart
if errorlevel 1 goto :held
echo 已用当前代码重启,没有开启文件监视。
echo.
pause
exit /b 0
:do_open
start "" "http://127.0.0.1:23337/"
exit /b 0
:do_status
echo.
%PS% "%CTL%" -Action status
echo.
pause
exit /b 0
:do_pull
echo.
git pull --ff-only
echo.
echo 代码已更新到磁盘后,输入 update 才会重启服务。
echo.
pause
exit /b 0
:do_push
echo.
git push
echo.
pause
exit /b 0
:do_log
echo.
%PS% "%CTL%" -Action log
echo.
pause
exit /b 0
:ensure
set "MODE="
for /f "delims=" %%A in ('%PS% "%CTL%" -Action query') do set "MODE=%%A"
if /i "!MODE!"=="web" exit /b 0
echo.
echo 正在启动服务(不监视文件)...
%PS% "%CTL%" -Action start
if errorlevel 1 exit /b 1
exit /b 0
:bad
echo.
echo 不认识这条指令。输入 help 查看。
echo.
pause
exit /b 0
:held
echo.
pause
exit /b 0
+166
View File
@@ -0,0 +1,166 @@
# Start and stop the local writing-agent web server. File watch stays off.
param(
[Parameter(Mandatory = $true)]
[ValidateSet("query", "start", "stop", "restart", "status", "log")]
[string]$Action
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$Port = 23337
$Log = Join-Path $env:USERPROFILE ".writing-agent\logs\runtime.log"
function Get-ListenerPid {
$lines = netstat -ano -p tcp | Select-String ":$Port\s" | Select-String "LISTENING"
foreach ($line in $lines) {
if ("$line" -match '\sLISTENING\s+(\d+)\s*$') {
return [int]$Matches[1]
}
}
return $null
}
function Get-ProcessById([int]$ProcessId) {
Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction SilentlyContinue
}
function Test-ServerCommand([string]$CommandLine) {
if (-not $CommandLine) { return $false }
if ($CommandLine -match 'Cursor|cursor-agent') { return $false }
return $CommandLine -match 'src[\\/]+server[\\/]+web-server|web:watch|tsx(\.cmd)?\s+watch|npm(\.cmd)?\s+run\s+web'
}
function Get-ChainText([int]$ProcessId) {
$parts = New-Object System.Collections.Generic.List[string]
$current = $ProcessId
for ($i = 0; $i -lt 10; $i++) {
$proc = Get-ProcessById $current
if (-not $proc) { break }
$cmd = [string]$proc.CommandLine
$parts.Add($cmd)
if ($cmd -match 'Cursor|cursor-agent') { break }
$parent = [int]$proc.ParentProcessId
if ($parent -le 0 -or $parent -eq $current) { break }
$current = $parent
}
return ($parts -join "`n")
}
function Get-ProtectedPids {
$protected = New-Object 'System.Collections.Generic.HashSet[int]'
$current = $PID
for ($i = 0; $i -lt 8; $i++) {
if ($current -le 0 -or -not $protected.Add($current)) { break }
$proc = Get-ProcessById $current
if (-not $proc) { break }
$parent = [int]$proc.ParentProcessId
if ($parent -le 0 -or $parent -eq $current) { break }
$current = $parent
}
return $protected
}
function Test-ForeignConsole([string]$CommandLine) {
if (-not $CommandLine) { return $false }
return $CommandLine -match 'tunnel\.ps1|ops-menu|srv\.bat|console\.bat'
}
function Get-StopRoot([int]$ProcessId, $Protected) {
$current = $ProcessId
$root = $ProcessId
$matched = $false
for ($i = 0; $i -lt 10; $i++) {
if ($Protected.Contains($current)) { break }
$proc = Get-ProcessById $current
if (-not $proc) { break }
$cmd = [string]$proc.CommandLine
if ($cmd -match 'Cursor|cursor-agent') { break }
if (Test-ForeignConsole $cmd) { break }
if (Test-ServerCommand $cmd) {
$root = $current
$matched = $true
}
$parent = [int]$proc.ParentProcessId
if ($parent -le 0 -or $parent -eq $current) { break }
$current = $parent
}
if (-not $matched) { return $null }
if ($Protected.Contains($root)) { return $null }
return $root
}
function Stop-Server {
$listener = Get-ListenerPid
if (-not $listener) {
Write-Output "down"
return
}
$root = Get-StopRoot $listener (Get-ProtectedPids)
if (-not $root) {
throw "Port $Port is in use by another process (pid $listener)."
}
& taskkill.exe /F /T /PID $root | Out-Null
if ($LASTEXITCODE -ne 0) { throw "taskkill failed ($LASTEXITCODE)" }
Start-Sleep -Seconds 1
if (Get-ListenerPid) { throw "Port $Port is still in use." }
Write-Output "stopped"
}
function Get-Mode {
$listener = Get-ListenerPid
if (-not $listener) { return "down" }
$chain = Get-ChainText $listener
if ($chain -notmatch 'web-server|npm(\.cmd)?\s+run\s+web') { return "other" }
if ($chain -match 'web:watch|tsx(\.cmd)?\s+watch') { return "watch" }
return "web"
}
function Start-Server {
$mode = Get-Mode
if ($mode -eq "other") {
throw "Port $Port is in use by another process (pid $(Get-ListenerPid))."
}
if ($mode -eq "watch") { Stop-Server | Out-Null }
if ($mode -eq "web") {
Write-Output "already"
return
}
$npm = "D:\tool\node\npm.cmd"
$found = Get-Command npm.cmd -ErrorAction SilentlyContinue
if ($found) { $npm = $found.Source }
if (-not (Test-Path $npm)) { throw "npm.cmd not found" }
Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$npm`" run web" -WorkingDirectory $Root -WindowStyle Hidden | Out-Null
$deadline = (Get-Date).AddSeconds(40)
do {
Start-Sleep -Seconds 1
$listener = Get-ListenerPid
if ($listener) {
$chain = Get-ChainText $listener
if ($chain -notmatch 'ssh\.exe') {
Write-Output "started"
return
}
}
} while ((Get-Date) -lt $deadline)
throw "Server did not listen on $Port."
}
switch ($Action) {
"query" { Write-Output (Get-Mode) }
"start" { Start-Server }
"stop" { Stop-Server }
"restart" {
Stop-Server | Out-Null
Start-Server
}
"status" {
Write-Output ("mode=" + (Get-Mode))
Push-Location $Root
try { git status -sb } finally { Pop-Location }
}
"log" {
if (-not (Test-Path $Log)) { Write-Output "no log: $Log"; break }
Get-Content -Path $Log -Tail 40
}
}
+84
View File
@@ -0,0 +1,84 @@
# 创作轮次与前缀花费
> 状态:结论已记录。缓存粘滞已接入:本局 `sessionId` 在 OpenRouter 请求体写成 `session_id`。不手选节点导致的「先选再写」尚无做法。
> 记录:2026-10-01。用量取自本地 `~/.writing-agent/stats`,2026-09-20 起。
> 相关:`src/dictate/prompt.ts`、`src/dictate/turn.ts`、`src/dictate/read-module.ts`、`src/llm/client.ts`。
## 已记录的花费
9 月 20 日以来,OpenRouter 上的 token 主要在创作落盘的输入,不在游玩输出。
| 路径 | 请求 | 会话约 | prompt | 其中缓存 | 未缓存 | 输出 |
|---|---:|---:|---:|---:|---:|---:|
| 创作落盘 `dictate_agent` | 416 | 5 | 1198 万 | 782 万 | 417 万 | 38 万 |
| 游玩 `worker:world-simulator` | 28 | 2 | 77 万 | 0 | 77 万 | 7 万 |
输出大约是 prompt 的 3%–9%。输出单价更高,仍然要算,但排在输入后面。缓存命中仍按折扣价计,缩体积会同时降低全价和折扣价。
每轮花费可以看成:
```text
请求次数 ×(未缓存输入 × 全价 + 缓存输入 × 折扣价 + 输出 × 输出价)
```
创作这 416 次是最大的一块。大约五个会话,一句用户话后面跟着一串读和写。
## 缓存粘滞:必做
OpenRouter 请求体现由统计层带上本局 `sessionId`,字段名 `session_id`(最长 256 字)。直连其它渠道不写这个字段。有 `session_id` 后,粘滞从该会话第一次成功请求就开始,不再等缓存命中,也不再因为第一条 user 被重写而换上游。`provider.order` 仍不发送,它会关掉这层粘滞。
同一条前缀,落点稳定时能命中,换上游就是 0:
- 创作工具循环间隔大约 7–15 秒。Gemini 上同一轮里缓存从约 3.8k 爬到 7.6k、11.5k、15k,整段 prompt 大约 50%–68% 命中。burst 的第一下经常是 0。中间偶尔整段掉回 0,是换了上游。
- 游玩 world-simulator 在 OpenRouter 上 28 次全是 0。gemini-3.7 间隔中位数约 5.5 分钟,gemini-3.8 约 22 分钟,prompt 1.8 万–3.4 万。
- 直连 glm-5.3 的游玩,间隔 8–30 分钟仍常见 80%–91%。某一轮从约 11k 掉到 6656 并停住,说明前缀中段改过,只保住开头约 6.6k。
因此缓存粘滞是必做项。不传稳定的 `session_id`,OpenRouter 每一轮按新开头重新选上游,游玩前缀缓存等于没有。`provider.order` 会关掉这层粘滞,不能拿来代替 `session_id`。
上下文排序对游玩前缀是有效的:人设和静态产物在前,历史居中,变量和本轮输入在后。它只在同一上游上表现得出来。
## 未决:不手选节点,选和写至少两轮
用户不手动指定本轮写哪个能力。系统提示里只有能力目录和短说明(何时用、何时不用、档位)。`prompt.md` 里的任务、原则、产物规格、评分不在这条提示里。
模型选定能力后调用 `read_module`。程序把这些段落放进工具结果,并在 `运行.对话落盘.已读能力` 记下该能力当前版本。版本是这些段落的哈希。随后的 `insert` 要核对这个账:没读过,或磁盘上的提示已经改过,就拒绝写入。`self_score` 也按这次读到的评分维度检查。`narrative`、`variable-context`、`context-order`、`worker-spec`、`refine` 以及 `auto` 能力不走这道门,其余内容能力都走。库条目同理:目录只是摘要,正文要等 `read_library_entry` 回来。
读的那一轮,完整写法还不在上下文里,模型给不出合格正文。同一项能力至少是:
```text
第 1 次请求:凭短说明选定,并 read_module
第 2 次请求:看见任务 / 原则 / 规格 / 评分之后 insert
```
前缀发送两遍。第二遍还多带上那份写法。手选节点时,程序可以把该节点的完整写法放进唯一一次请求,模型当场写完。不手选时,第一次请求不能把所有节点的完整提示都放进去,这第二次省不掉。
一次只返回一个工具时,倍数再乘上去。提示要求一轮通常写 2~3 个已经说清的能力,模型常做成「读甲、写甲、读乙、写乙」,最后再单独回用户一句。每一拍都把已经变长的前缀再送一遍。「选和写拆成两次」是翻倍的原因;「每次只动一个节点」把两倍变成好几倍。
驱动程序已经会在同一步执行模型这一次返回的全部工具。接口没有禁止一轮多个 `insert`。日志里偶尔同轮两次写入。多数时候模型一次只吐一个工具。
结构化输出可以把一次生成拆成多份产物:一份 JSON 里多条 `{ position, content }`,程序再落盘,用户可见回复也可以放在同一份里。这省的是写入侧重复发送前缀。规格说明仍须在生成这批产物之前到达模型;否则模型是在没看见写法的情况下填内容。读和写如何合成更少的请求,本文不规定做法。
## 一本书上的拆账
本机作品 `4c13b2b4` 的当前会话(25 份产物、22 轮用户话)按现在的 `buildDictateMessages` 拼出来,不发请求。字数大约按 2 字 1 token 对照:同书末次 OpenRouter 请求 prompt 为 46897 token。
| 块 | 字数 | 占当前 messages |
|---|---:|---:|
| 已有产物合计 | 60518 | 69% |
| 全量对话 | 19975 | 23% |
| 系统提示(含方案进度 4392) | 6695 | 8% |
| 工具定义 JSON | 4917 | 另计,约等于系统提示 |
| 当前选用预设里启用的正文 | 362 | 可忽略 |
产物没有一份独自占满。最大的是 `设计.正文组成` 8879 字,其次变量规则 6005、实现机制 4569。其余多为两三千字,加在一起才是大头。会话中段的检查点(第 119 条消息之前)产物已经是 56102 字,和结尾同一量级。
按前面两段收窄再量同一份材料:
| 方案 | 字数 |
|---|---:|
| 选择:方案进度 + 用户最后一句(130 字) | 4522 |
| 填写若仍带全部产物 + 最近 4 条对话(1770 字) | 约 6.2 万 |
| 再注入 1 份节点提示(正文约 5 千字,叙事指南 5557) | 约 6.7 万 |
| 现在这一次请求的 messages | 88218 |
选择请求很小,目录也稳定,缓存几乎打在整段上。填写请求的大头仍是已有产物,不是对话,也不是刚插入的节点提示。砍掉全量对话大约省两成,改不了产物那七成。节点提示每份约 5 千字,一次写两三份会在产物之上再加一两成,但只发送一次。
Binary file not shown.
+891
View File
@@ -0,0 +1,891 @@
# 对话落盘单一化 · 执行蓝图
> 状态:核心方向已确认,按本文依赖顺序实施;非工具节点仍须逐项审阅提示词。
> 修订:2026-09-30。
> 相关:`docs/play-presentation-shells.md`、`docs/context-fragment-design.md`、`docs/world-simulator-modules.md`。
## 执行状态(2026-09-28)
- [x] 基线扫尾:删除新建作品选配方的过时提示。
- [x] 工作包 A:本局创作方案、两态注入、能力覆盖校验、旧配方案例化。
- [x] 工作包 B:`read_module`、`read_library_entry`、提示版本门禁、按需读取库。
- [ ] 工作包 C(进行中):内容节点、普通呈现链和 0 层呈现链均已迁移;`user-requirements` 经审阅决定删除;四个共用运行步骤已确认为自动工具节点,尚待补齐完整确定性编译。
- [ ] 工作包 D:游玩投影与维护合同编译,未开始。
- [ ] 工作包 E(进行中):楼层/0 层双呈现节点与数据合同已建立;壳参考库已按 7 种区、5 只壳写入 `shell-refs/`;隔离 iframe、持久槽位运行时和重 roll 尚未实现。
- [ ] 工作包 F:库条目 schema、本局化与提示归属,部分完成;偏好/文风库按需读取;仓库 `seeds/libraries/*.json` 可 git 备份并 `npm run libraries:restore` 恢复。
- [ ] 工作包 G:长程记忆、表维护与固定输入语法,未开始。
## 0. 已确认结论
1. 新建作品只填写名称并直接进入对话落盘。这部分已经完成,不再列为工作包;旧工序编排书和后端 DAG 暂留兼容。
2. 创作入口与设计清单合并为一份中间产物「本局创作方案」。它根据自然语言生成本局方法(下文称“本局配方”),并给全部能力分档。
3. 旧配方不再供用户手动选择。修订后作为本局创作方案的参考案例,不作为固定路径。
4. 不写死“先美学、再清单、再逐节点”的统一工作流。本局创作方案描述依赖与重要程度,模型根据当前材料灵活推进,并可在用户改口后整份重写。
5. 创作对话保留一次用户输入触发多次 `insert` 的能力。逐节点审阅只约束本次程序改造:修改每份非工具节点提示词前必须给用户过目,不限制产品运行时一次只能写一个产物。
6. 壳最终仍是正文组成节点读取的参考库。否定的是当前七个外观壳及其按产品原型换皮的分类,不是否定参考库本身。
7. 正文前端采用隔离 iframe。它是显示容器,不提供 `action` 回传;用户仍从主输入框输入。
8. 创作源产物、创作交互与评分、游玩投影分开。开玩时不得再把全部 `设计.*` 原样挂给主世界层。
9. 长程记忆采用“写大总结”和“清空旧楼层”两个独立操作,不在大总结成功后自动删除历史。
10. 输入语法写死:裸写表示上帝视角,单井号包裹的 `#内容#` 表示角色行动,引号包裹的内容表示角色说话。
11. 新增“0 层卡”呈现模式:开场白所在气泡是持久前端;正文、指令、表盘、地图等都是其中的槽位,每轮就地更新,不继续向下叠加可见助手楼层。
12. 普通楼层与 0 层卡不共用呈现节点;现已复制 `回复呈现`、`正文组成`、`开场白与开场变量` 三个显示强相关节点,其它内容能力继续共用。
13. 0 层模式在“更多操作”中提供“重 roll 本轮”,复用本轮消息前 checkpoint;暂不提供任意历史回退。
## 1. 当前基线
以下内容已经落地,不再实施:
- 新建作品弹窗只保留作品名称。
- 新建作品固定 `creationMode: "dictate"`。
- 新会话默认进入对话落盘。
- 旧工序编排书仍可打开,DAG 后端和旧前端仅作兼容。
扫尾项(已完成):
- [x] 删除 `web/agent-ui.js` 中“请先在新建作品时选定配方”的过时提示;旧书展示所需的「工序编排」标签可保留。
现有但尚未达到本文目标的基础设施:
- 文风库、偏好库和库注册表已经存在,但对话落盘仍会整库注入。
- `play-bind` 已能自动装配部分运行上下文,但仍主要按 tag 分类并给主世界层全量投影。
- `auditor` 和 `maintain.v1` 已存在,但默认关闭,也没有本文所需的阻塞、总结和清空交互。
- 现有七个壳及预览器仍存在,只作为待替换旧实现。
## 2. 目标链路
```text
用户自然描述
→ 本局创作方案〔中间产物,可反复重写〕
- 生成本局配方:如何把本次需求做出来
- 给全部能力分档并说明理由、缺口和建议实例
- 只表达必要依赖,不生成固定流水线
→ 按当前材料灵活推进
- 写某能力前 read_module
- 需要库条目时 read_library_entry
- 同轮只连续写入这句话已经说清的能力,通常 2~3 个
- 每份写入携带独立 self_score
→ 回复呈现
→ 正文组成读取壳参考库,生成本局模板与数据契约
→ 开场白按数据契约生成 present.v1
→ 开玩时把源产物编译成各 Worker 的游玩投影
```
这不是硬编码流程:
- 用户这句话已经说清的能力,同轮可以读取并连续 `insert`,通常 2~3 个。没提到的「必须」和「有必要」按已有材料给 2~3 个选项询问,选定后再写。写开场前用未落项问用户哪些还没设计。
- 能力之间只有真实语义依赖。例如正文组成需要知道要呈现什么,开场白通常靠近创作收束;不能因此把所有作品强制塞进同一节点序列。
- 文本创作、世界模拟、短局、长局、数据化跑团等只是本局配方案例,不是程序分支。
- 用户改变目标时,先更新本局创作方案,再按新方案增删或改写产物。
## 3. 工作包 A · 本局创作方案〔已完成〕
### 3.1 能力与产物
新增 `modules/creation-plan/prompt.md`,目录登记:
```yaml
- id: creation-plan
name: 本局创作方案
layer: intermediate
artifact: 设计.本局创作方案
```
该产物同时承担原“创作入口”和“本局设计清单”的职责:
```json
{
"schema": "creation-plan.v1",
"技能": "本局创作方案",
"brief": "一句话说明本局靠什么兑现体验",
"recipe": {
"primary": "主要创作方法",
"secondary_traits": ["可混合的次要体验特征"],
"method": ["针对本局的做法,不是固定节点顺序"],
"references": ["采用或改造过的旧配方案例 id"]
},
"items": [
{
"module_id": "variable-design",
"tier": "必须",
"why": "任务时限与暴露度必须跨轮保存",
"missing": "暴露度达到哪一档会被识破尚未确定",
"instances": []
}
]
}
```
`items` 覆盖对话落盘可见的全部内容能力,但排除:
- 本局创作方案自身。
- 变量声明、变量映射、上下文排序等机械工具节点。
- 只供旧工序编排使用的节点。
四档含义:
| 档位 | 含义 | 默认行为 |
| ----- | -------------- | --------------- |
| 必须 | 缺少后无法运行或核心体验会崩 | 收口前问清。用户没提到时给 2~3 个选项,选定后再写 |
| 有必要 | 缺少仍可运行,但体验明显受损 | 同样先问再写;用户明确不要时可降档 |
| 有一定效果 | 有了更好 | 用户提及时做;可顺带询问一次 |
| 没有意义 | 本局用不上 | 不主动推进 |
四档是收口前的核对清单,不是本轮待办,也不是 DAG。程序不按照数组顺序强制执行节点。本轮通常只写用户这句话已经说清的 2~3 个能力。
### 3.2 旧配方改为案例
- 删除新建阶段和创作页中的手动配方选择。
- 修订现有对话落盘配方,只保留各自有辨识度的方法、适用信号和能力档位倾向。
- 本局创作方案可以引用、混合或改写多个案例;引用结果写入 `recipe.references`。
- 案例不能复制总提示的公共规则,也不能恢复成隐藏的固定工作流。
### 3.3 注入
`buildDictateSystemPrompt` 分两态:
- 没有方案:提供能力目录的短声明、使用边界和配方案例目录,要求根据用户当前材料形成方案。
- 已有方案:注入完整本局配方;能力按档分组,显示已落/未落和最低自评分。前三档附带该能力的何时用 / 何时不用(方案改写或补全后仍保留;可反复能力已落一条也保留),供本轮选型。不附带执行规格。「没有意义」只列名称。
用户改口、新增方向,或现有方案明显不再成立时,允许覆盖 `设计.本局创作方案`。
### 3.4 程序约束与验收
- 写入方案时校验能力覆盖;漏项则拒绝并列出 id。
- 插入开场白时,如仍有“必须”项未落,只提醒,不拒绝。
- 不要求体验锚定达到固定计数,也不要求先经过美学节点。
- 方案落盘后系统提示应明显短于全目录注入。
- 测试:`tests/creation-plan.test.ts`。
## 4. 工作包 B · 写产物前读取能力与参考〔已完成〕
### 4.1 `read_module`
`src/dictate/tools.ts` 新增:##
```json
{
"name": "read_module",
"parameters": {
"module_id": "能力 id,也接受中文名",
"reason": "可选,一句话"
}
}
```
返回该能力的:
1. `task`、`principles`、`output`、`score`。
2. 绑定库的条目目录与短说明,不返回整库正文。
3. 正文组成所需的壳参考目录、正文安全子集和 iframe 数据契约说明。
4. 该能力适用的程序约束。
会话记录已读取的能力及其提示版本。写某能力产物前未读该版本时,`insert` 拒绝并提示调用 `read_module`。同轮可以先读用户这句已经说清的能力(通常 2~3 个),再连续执行这些 `insert`。还没提到的先问,不先读后写。
机械工具产物不受此限制。
### 4.2 `read_library_entry`
仅返回库目录不足以完成选择,因此新增独立工具:
```json
{
"name": "read_library_entry",
"parameters": {
"library_id": "库 id",
"entry_ids": ["本轮选中的 1~2 个条目 id"]
}
}
```
约束:
- 只能读取当前能力绑定库中的条目。
- 默认最多读取两个;确需更多时必须给出原因。
- 本局源产物记录条目引用和本局微调;未选条目不进入后续提示。
- 用户提出库外新取向时,可先形成本局临时条目;是否回写全局库另行确认。
### 4.3 验收
- 未读取能力就写对应产物会被拒绝。
- 读取正文组成时能看到壳目录和 iframe 契约。
- 对话落盘不再默认注入文风库、偏好库全文。
- 测试:`tests/dictate-read-module.test.ts`、`tests/dictate-read-library-entry.test.ts`。
## 5. 工作包 C · 产物分层、评分与自然回复〔进行中〕
### 5.1 四类数据
1. **中间产物**:只服务创作决策,不进游玩。目前包括 `设计.本局创作方案`,以及 `lifecycle_intent = seed_only` 的生成规则。
2. **源产物**:能力形成的完整定稿,保留在黑板供编辑、验收和重新编译。
3. **游玩投影**:从源产物确定性提取、按 Worker 分发的运行时内容。
4. **创作交互与评分元数据**:回应、评价、启发、追问、缺口和分数;留在消息或元数据,不进入游玩投影。
`DictateProduct` 和黑板元数据新增 `layer`。目录的 `layer` 是默认值;生成规则根据产物中的 `lifecycle_intent` 覆盖默认值。
### 5.2 自评分
每个能力 `prompt.md` 新增 `score` fence,每个维度给出 5 / 3 / 1 分锚点。`insert` 新增:
```json
{
"self_score": {
"dims": [
{
"name": "维度名",
"score": 3,
"gap": "当前缺口",
"options": ["涉及用户偏好时提供的具体选项"]
}
]
}
}
```
- 分数为 1~5。
- `score <= 3` 必须写 `gap`;涉及用户选择时给 2~3 个 `options`。
- 维度名必须与该能力 `score` fence 一致。
- 分数存入黑板元数据,不再写进产物正文。
- `追问`、`开放问题`、完备度诊断同样不进入游玩投影。
迁移按 §9 逐节点审阅进行。过渡期兼容规则:
- 已有 `score` fence 的能力使用新参数并严格校验。
- 尚未迁移的能力继续接受旧 `output` 形状,不因缺少 `score` fence 阻塞写入。
- 全部内容节点迁移完成后,统一删除旧自评解析和兼容分支。
### 5.3 用户可见回复
回复不使用固定标题、固定句数或逐模块仪式。按当轮内容自然覆盖:
1. 回应用户输入中的有效判断、矛盾或值得保留之处。
2. 说明本轮形成或改写了什么;完整 JSON 由产物卡展示。
3. 用户没提到的「必须」或「有必要」,给 2~3 个根据原话推出的选项来问。这句话已经说清并写入的,不再强行追问。
4. 简短说明强项和缺口。
写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。
正文组成、0 层正文组成和开场的画面由产物卡按 frontend 渲染。完整 JSON 仍由产物卡展示。
## 6. 工作包 D · 游玩投影编译〔未开始〕
“游玩投影”指:创作时保存的完整产物不能直接塞进模型;开玩时先抽出每个 Worker 真正需要的字段,再分别装配。
### 6.1 编译入口
在 `src/dictate/play-bind.ts` 增加明确的编译阶段:
```text
黑板源产物
→ resolve module / layer
→ intermediate 与 seed_only 跳过
→ compileDictateProjection
→ 按 mount 分发给 world-simulator / narrator / auditor
→ 各自按 context_order 装配
```
不要再用 tag 正则把几乎全部 `设计.*` 挂给主世界层。
### 6.2 投影契约
能力目录声明默认 `mount`;每个最终能力提供确定性的投影器或字段允许列表:
- 主世界层:事实推进、角色扮演、输入解释、权限边界。
- 转述:本局选中的文风、呈现规则、正文数据契约。
- 副 Worker:表结构、当前值、维护规则及人物一致性所需的少量设定。
所有投影统一剥离:
- 创作期回应与评价。
- `self_score`、追问、开放问题、完备度和诊断依据。
- 本局创作方案状态。
- 不属于目标 Worker 的字段。
纯模型阅读内容优先生成有身份标题的 Markdown;变量、映射、数据契约和 `present.v1` 保持结构化。
这解决两个具体问题:
1. `layer` 只回答“整份产物是否进入游玩”,不足以回答最终产物中哪些字段能进入。
2. `mount` 只回答“给谁”,不足以回答“给这个 Worker 看哪些字段”。因此必须同时有分层、投影和分发。
### 6.3 验收
- 本局创作方案和 `seed_only` 规则不出现在游玩请求中。
- 主世界层、转述、副 Worker 得到不同且最小的投影。
- 请求日志中不存在创作回应、评分、追问和诊断字段。
- 具体实例暂时全量稳定注入,不引入关键词检索或 RAG。
- 扩展 `tests/dictate-play-bind.test.ts`,覆盖分层、字段剥离和按 Worker 分发。
### 6.4 维护方式分流与条件式编译工具节点
维护不固定走副 LLM。创作收口时先按复杂度选择一种运行策略:
1. `none`:没有跨轮可变内容,不挂维护能力。
2. `inline_tool`:只有 1~2 个简单独立变量,或少量可由主世界层明确决定的标量更新。给主世界层挂原生 `update_state` tool call;正文仍输出普通 Markdown。
3. `inline_structured`:模型不支持工具调用时,用 `{ body, state_ops }` 结构化整包,程序只显示 `body`。这会牺牲自然流式体验,因此不是首选。
4. `auditor_bundle`:变量较多、存在交叉依赖,或同时维护拓扑、补实例、其它备忘录时,运行 `maintenance-compile` 并启用统一维护副 Worker。
数量不是唯一判据:一个带扩展规则的关系图也应走 `auditor_bundle`;三个彼此独立、更新完全确定的计数器仍可走程序或 tool call。
`update_state` 只接受设计阶段声明过的变量、操作和 revision。主世界层先决定本轮事实并调用工具,程序校验、提交并返回结果,再输出用户可见正文。相比正文隐藏标签,它能在生成过程中报告拒绝或冲突;相比把长正文包进 JSON,它保留自然文本与流式输出。当前 GM harness 已有 `chance` tool loop,可沿用同一机制。
API 能力选路复用 profile 已有探测结果,但使用独立策略:
- `tools=ok`:使用可选 tool call;不能用 `forcedTool` 强迫每轮更新,因为“本轮无变化”是正常结果。
- `strictTool=ok`:动态工具带 `strict: true`;若供应商对实际 schema 拒绝,则降为普通 tool,并继续由本地校验全部参数。
- tools 不可用而 `jsonSchema / forcedTool / jsonObject` 任一可用:通过现有 `completeStructured` 选路提交 `{ body, state_ops }`。
- 只有纯文本能力:可请求 JSON 并严格解析、失败后重试;解析失败则不写状态。新实现不使用正文隐藏标签或正则维护变量。
动态结构随请求生成,不需要向 API 预注册。`inline_tool` 只用于少量变量,因此优先为每个变量生成一个类型明确、结构简单的小工具:工具名使用稳定安全 id,description 携带该变量的更新条件,parameters 只含该类型允许的 `set / delta`、值、`expected_rev` 与简短依据。避免为所有变量动态拼一棵带大量 `oneOf` 的复杂 schema。
现有能力探测验证协议是否支持 tools、强制 tool、strict tool 与各类 JSON 输出;它不能证明供应商支持任意复杂 schema 方言。因此实现时需增加一个与状态更新工具同形的代表性探测,运行请求仍保留“strict 被拒绝 → 非 strict + 本地校验”的降级。
`maintenance-compile` 是条件式自动工具节点,不调用 LLM 创作内容。仅在策略为 `auditor_bundle` 时扫描所有最终产物,把分散的跨轮维护要求编译成一份 `设计.维护合同`:
```json
{
"schema": "maintenance-manifest.v1",
"targets": [
{
"id": "目标短 id",
"kind": "table | topology | generation | memo",
"state_ref": "变量.当前 | 运行.拓扑#id | …",
"rule_refs": ["来源产物及字段路径"],
"allowed_ops": ["set", "delta"],
"timing": "pre_narrative | post_narrative",
"writers": ["program", "gm", "auditor", "user"],
"visibility": "public | hidden",
"rules": ["给维护者执行的短规则"],
"invariants": ["不可破坏的约束"]
}
]
}
```
主要来源包括:
- `设计.变量设计与更新规则` 的真值、写入源、更新时机、钳制和 side effects。
- `设计.拓扑图谱#*` 的动态模式、允许操作、写入者和一致性约束。
- `设计.生成规则#*` 中游玩期按需补实例的触发条件。
- 后续明确声明“游玩期可修改”的其它备忘录;纯静态设定不进入维护合同。
编译规则:
- 不复制整份创作产物,只提取维护所需字段并保留来源引用。
- `none / inline_tool / inline_structured` 不生成聚合合同,也不启用 `auditor`。
- 一旦启用 `auditor`,应把本轮所有需要其判断的维护目标、当前状态和更新规则一次性投影给它,不能让它只看到变量而漏掉拓扑或补实例规则。
- `inline_tool` 只给主世界层简短工具 schema,不注入完整维护文档。
- `auditor_bundle` 下正文生成者不承担隐藏维护清单;需要从正文事实推断的更新统一由回合末 `auditor` 处理。必须在正文前完成的机械更新单列为 `pre_narrative`,由程序或专门的前置调用处理。
运行期由程序从维护合同生成精简的 `maintenance-context.v1`,只包含本轮相关目标、当前状态、用户输入和最终正文。旁观输出升级为 `maintain.v2`,至少可携带:
- `table_ops`
- `topology_patches`
- `generation_requests`
- `memo_ops`
- `notes`
旧 `maintain.v1` 继续作为仅含 `table_ops / need_generate` 的兼容输入。
验收:
- 单个“用户存款”变量只挂 `update_state`,不启动 `maintenance-compile` 或副 LLM。
- 工具调用成功后只显示正文;工具参数、结果和隐藏状态不混入气泡。
- 工具不可用时结构化整包可正确分离正文与状态;纯文本解析失败不写状态。
- 运行期变量维护不依赖正则;SillyTavern 正则只作为外部预设导入兼容能力,不能写权威状态。
- 同一局既有变量、关系图和按需实例时,旁观一次收到完整合同并可在一份维护包中分别更新。
- 主世界层提示不再塞入仅供维护使用的完整更新规则。
- 每项操作都有目标 id、来源规则和 revision;未知目标或越权操作被拒绝。
- 多数无变化回合仍可返回空维护包,不为“检查过”制造写入。
## 7. 工作包 E · 壳参考库与隔离 iframe〔未开始〕
### 7.1 壳参考库
壳是正文组成读取的参考。创作时选一只最接近的,按本局启用的区删掉多余区并重排,生成一次前端后固定。游玩只输出各区内容,由程序装配。
可见区有 6 种。隐藏的变量更新不参与分类,也不进壳。
| 区 | 里面是什么 |
| --- | --- |
| 抬头 | 页首一行短内容,例如标题、日期、天气、地点;条目一多或当前值一长,改用数据卡 |
| 正文 | 这一轮的主叙事 |
| 小故事 | 论坛上的一段,或正文地点以外其他重要角色的一小段;同一轮只写一种 |
| 数据卡 | 一组变量的卡片或表;只露出点名的列和行 |
| 结构 | 地图、关系或要对着看的长表 |
| 文字选项 | 几条下一步,只显示文字,始终直接可见,不放进标签或折叠 |
参考壳按主面来,写在 `skills/dialogue/world-simulator/shell-refs/`:
| 称呼 | id | 什么时候用 |
| --- | --- | --- |
| 阅读 | `reading` | 只有正文、页首一行和选项 |
| 正文为主 | `dual-text` | 正文占满一栏。短文、地图用标签或折叠,点开才看。选项始终直接可见 |
| 数据常驻 | `data-cards` | 数据一直铺开,不替换正文。左栏是数百字正文,右栏是另一路短内容。选项始终直接可见 |
| 成稿 | `piece` | 一页一份能单独拿走的成品。内部小节在创作时按范例重排。选项在成稿下面 |
每只壳的备注只写两类:不替换的前端结构,以及可按本局用途替换的部位。备注不点名某一种应用场景。
`alongside`、`paged` 不再单独作为参考。另一篇长文不与正文左右对半。右栏是短内容时,可以和长正文并排。
美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。
### 7.2 正文组成产物
`设计.正文组成` 保存:
```json
{
"参考壳": ["按正文体验命名的参考 id"],
"为何": "它如何服务本局的阅读体验",
"数据契约": {
"blocks": {
"body": { "类型": "markdown", "职责": "主读正文" },
"任意本局键": { "类型": "kv | list | markdown", "职责": "用途" }
}
},
"frontend": {
"html": "<div id=\"app\"></div>",
"css": "",
"js": "present.onData(render)"
},
"示例灌数": {
"schema": "present.v1",
"blocks": {}
}
}
```
- 创作期生成一次模板并固定。游玩每轮只生成内容块,由程序装配进该模板,不再生成壳。
- 美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。
- 壳只包含本局实际启用的区。参考壳多出来的区在创作时删掉并重排,不留到游玩再空着。
- `blocks` 允许自定义键,前后端 normalization 不再吞掉未知键。
- `html + css + js` 合计上限 60 KB。
### 7.3 最小隔离
新增 `web/present-frame.js`:
- 使用 `<iframe sandbox="allow-scripts">`,不加 `allow-same-origin`。
- 禁止网络、表单、弹窗和顶层导航。
- 只提供 `present.onData(cb)`、安全 Markdown 渲染和自动高度回报。
- 不提供 `present.action`,也没有“直接发送”设置。
- 模板报错或 3 秒内未 ready 时,回退为安全的纯文本/基础块渲染;不回退到已否决的旧七壳。
这里是低成本故障隔离,不把自创模板当作敌对第三方脚本设计复杂权限系统。
### 7.4 接入与验收
- 游玩终稿、开场候选、正文组成预览接入 iframe。
- `SessionView` 下发 `presentationFrontend`。
- 流式期间先显示纯文本,完成后灌入 iframe。
- chat 型与长文阅读型参考必须肉眼显著不同。
- 模板损坏时消息仍可读。
- 测试:`tests/present-frame.test.ts`,覆盖 srcdoc、消息来源、ready/错误回退和自定义 blocks。
### 7.5 双呈现链
内容节点继续共用;只复制与显示形态直接耦合的三个节点:
- `回复呈现`:普通楼层版继续定义每轮消息区域;0 层版定义持久卡槽位及各槽内容规则。
- `正文组成`:普通楼层版生成每条消息的前端;0 层版生成开场时一次建立的持久前端、Tab 与槽位渲染器。
- `开场白与开场变量`:普通楼层版生成第一条消息;0 层版同时初始化持久前端和全部槽位初值。
当前实现:
- 已建立 `zero-layer-status / 设计.0层回复呈现`、`zero-layer-reply-format / 设计.0层正文组成`、`zero-layer-opening-setup / 设计.0层开场白与初态`。
- `本局创作方案.presentation_mode` 必须在 `message / zero_layer` 中选一种;`items` 只包含所选模式的三项呈现能力,不把另一套写成“没有意义”占位。
- 0 层更新合同使用 `zero-layer.card.v1` 与 `zero-layer.update.v1`;更新方式为 `replace / append / data`,并以 `base_revision` 原子提交。
- 主世界层与转述不复制能力节点,只按呈现模式切换最终输出合同。
### 7.6 0 层卡、槽位更新与重 roll
0 层卡是开场白所在气泡内的持久前端,不是额外的上下文深度:
- 正文也是一个槽位,可与“公司指令”“监控表盘”“地图”等槽位并列。
- 每轮助手结果仍写入内部对话历史,但前端不新增可见助手楼层;程序把本轮更新应用到 0 层卡。
- 槽位声明更新方式:`replace`(整块替换)、`append`(受上限约束的槽内日志)、`data`(结构化数据驱动渲染)。
- 0 层初始状态与每轮槽位更新均持久化;刷新、续作、run 快照和重 roll 后可恢复。
- 规范路径使用结构化 `slot_updates`;新增受限正则提取/替换兼容层,用于导入 SillyTavern 标签和显式声明的 `<公司指令>…</公司指令>` 一类输出,不以任意正则直接修改权威状态。
- iframe 只接收规范化后的槽位数据;LLM 不直接生成或修改运行中的 DOM。
“更多操作”菜单新增:
- **重 roll 本轮**:恢复最新一轮助手输出前的 checkpoint,重新生成该轮并重新应用槽位更新。
- 暂不提供任意历史回退:缺少稳定用户锚点前,不暴露多步撤销。
- 既有消息分支 / swipe 数据仍保留;0 层前端只显示当前活动分支重放后的状态。
验收:
- 连续多轮后仍只有一个可见 0 层前端,正文与其它槽位按合同更新。
- 重 roll 不残留被废弃版本写入的槽位值。
- 模板损坏时可查看本轮纯文本与结构化槽位数据。
- 正则适配器失败不修改权威槽位状态。
## 8. 工作包 F · 库、本局化与提示归属〔部分完成〕
### 8.1 偏好库与文风库
- 偏好库:输入用法、推进节奏、是否代演玩家、停笔方式、常用禁区。
- 文风库:遣词、段落节奏、笔墨焦点、写法档和示范片段。
- 故事推进和叙事指南改为“选择 + 本局化”节点,各读取最相关的 1~2 项,解决冲突并记录微调。
- 未选条目不进入后续提示。
### 8.2 规则归属
- 公共运行原则放总提示。
- 配方案例只写有辨识度的方法和档位倾向。
- 能力提示写使用边界、产物规格和评分。
- 可程序校验的格式与依赖只在程序实现。
- 删除面向用户的“落盘、收口、下一刀、模块”等内部流程腔。
- 重复长例只留在一个权威位置,其余位置引用。
## 9. 非工具节点逐项审阅
本节只约束实施改造,不约束创作运行时的 `insert` 次数。
每份非工具节点提示词按以下流程处理:
1. 一次讨论一个节点。
2. 修改前完整展示审阅表。
3. 用户批准后才修改该节点的 `prompt.md`、直接关联目录声明和测试。
4. 修改后展示实际变化与验收结果,再进入下一节点。
审阅表:
| 项 | 内容 |
| ------ | --------------------- |
| 节点 | id / 中文名 |
| 产物层 | intermediate / final |
| 本局职责 | 为什么存在,服务创作还是游玩 |
| 当前问题 | 现有提示、产物或消费链路的问题 |
| 使用边界 | 何时有用、何时没有意义、与相邻能力如何分工 |
| 产物形状 | 保留、删除、新增哪些字段 |
| 评分规则 | 维度及 5 / 3 / 1 锚点 |
| 用户可见交互 | 如何确认,何时根据缺口询问 |
| 程序消费 | 谁读取,怎样投影,是否进入游玩 |
| 待用户决定 | 尚有分歧的选项 |
| 审阅结论 | 未审 / 批准 / 修改后再审 / 否决 |
机械工具节点可以集中修改和测试,但不能借此改变内容节点的职责、语义或评分标准。
## 10. 工作包 G · 长程记忆、表维护与输入通道〔未开始〕
### 10.1 上下文拼装顺序
为尽量保留稳定前缀缓存,顺序调整为:
```text
稳定系统提示
→ 静态游玩投影
→ 本局数据契约与工具说明
→ 可变记忆表 / 变量当前值 / 最新总结
→ 清空后保留的最近对话楼层
→ 当前用户输入
```
静态内容在前,可变表和对话放后。表格每轮变化时只破坏其后的缓存,不让整个稳定前缀失效。
### 10.2 副 Worker 维护
```text
主世界层写出事实正文
→ 转述 / 呈现(若启用)
→ 副 Worker 根据已发生事实维护变量与记忆表
→ 维护完成后允许下一轮用户输入
```
- 默认启用独立副 Worker;多数轮允许空操作。
- 更新期间禁用发送,在输入区显示“正在更新记忆表…”。
- 成功后恢复输入;失败时保留旧表并提供重试,不让半成品进入下一轮。
- 表很少且规则简单时,未来可显式选择主世界层隐藏维护段;本阶段不做自动阈值切换。
- 正文组成只决定如何显示,不修改变量和记忆。
### 10.3 动态二维表
按本局需要建立少而承重的表:
- 事件账本:时间、地点、人物、事件、直接后果。
- 承诺与债务:双方、内容、状态、到期或触发条件。
- 人物关系:当前态度、公开关系、关键变化。
- 线索与未决事项:内容、来源、可信度、解决状态。
- 阶段事实:位置、目标、重要资源。
本局创作方案和变量设计决定需要哪些表,不要求每局全建。
### 10.4 大总结与清空
在输入框左侧“更多”菜单增加两个独立按钮:
**写大总结**
- 总结 Worker 读取当前保留历史和记忆表。
- 把仍有后续价值的事实更新进动态表。
- 在消息流最新处插入一层用户可见的总结层。
- 不删除任何旧楼层。
- 总结期间与表维护一样阻止发送;失败时不插入总结层,也不修改表。
**清空旧楼层**
- 只保留最新三层游玩消息,通常是最近用户输入、助手输出和总结层。
- 不要求这三层具有固定角色;按时间保留最后三层。
- 不删除动态表、源产物、静态游玩投影、开场实例或已保存快照。
- 属于显式不可逆操作,执行前显示将删除的楼层范围并二次确认。
用户通常先写大总结、确认内容,再手动清空。两步不做自动绑定,允许少量信息重复。
### 10.5 固定输入语法
输入语法固定,不放进偏好库,也不允许按局反转:
- 裸写表示上帝视角,用于提出推进、镜头、节奏和改写要求。
- 单井号成对包裹的 `#内容#` 表示角色行动。
- 中文或英文引号包裹的内容表示角色说话。
- 同一条输入可以混合三种内容。
示例:
```text
下一段加快节奏。#我推门进去#“你们在谈什么?”
```
程序拆成:
1. 上帝视角:`下一段加快节奏。`
2. 角色行动:`我推门进去`
3. 角色说话:`你们在谈什么?`
只有首尾配对的单井号才构成行动标记;未配对井号按普通裸写处理。`()`、`()`、`[]`、`【】` 不承担系统语义。
### 10.6 动态拓扑状态
`拓扑图谱` 用于“只生成一个、但普通无规则实例不足以表达”的结构对象,例如一张迷宫地图、一所学校的固定空间、一棵升级树或一条人物关系链。
它不是 0 层卡专属产物,而是开场后仍可修改的运行备忘录。普通楼层和 0 层卡共享同一份权威拓扑状态,只采用不同的显示入口。
创作期:
- 每个 `设计.拓扑图谱#id` 只定义一张图;能力可反复建立不同图。
- 产物定义节点/连接字段、初始结构、扩展规则,以及 `static | record | expand` 动态模式。
- 图数据与显示分离:拓扑节点只管权威结构,不要求提前实现渲染;是否显示、显示在哪里及采用何种形式,全部留给后续「正文组成」决定。
游玩期:
- 从设计图初始化 `运行.拓扑#id`,保存节点、连接、当前位置/当前焦点、发现状态与 `revision`。
- 新增 `topology.patch.v1`:支持增改删节点与连接、移动当前位置、揭示隐藏内容;带 `base_revision`,整批原子校验,禁止悬空连接。
- 设计产物声明允许的写入者和可编辑字段;程序、维护副 Worker 或用户都必须通过同一 patch 与 revision 冲突检查修改,不能直接覆盖整张图。
- 主世界层负责写正文事实,不在用户可见正文中夹带拓扑维护块。由正文事实产生的探索、关系等变化,在回合末交给统一维护副 Worker 记录。
- 确实必须先改状态再写正文的机械操作标为 `pre_narrative`;其余默认 `post_narrative`,不得为了拓扑一律增加前置调用。
- 语义位置属于图真相;纯显示坐标、缩放和折叠状态另存 `运行.拓扑布局#id`。
- 若「正文组成」决定展示,渲染器与壳一样在创作期生成一次并固定。游玩只把 `运行.拓扑` 的节点与连接灌入该渲染器出图。模型不输出 SVG,壳也不写死一份地点名单。普通楼层放在辅助区或标签页,0 层卡投影到 `data` 槽。未决定展示时只保存备忘录。
当前已有的“表格”只支持 `变量.当前` 的扁平键值格和逐格 revision,不支持多张任意表、节点/连接或拓扑 patch。因此不能直接把现有表格当成拓扑实现;可复用其 revision、用户编辑和冲突保护思路,但只先新增独立的拓扑存储与更新接口。显示入口等「正文组成」实际选用后再实现。
验收:
- 已访问节点和连接跨轮、刷新与 run 快照保持稳定。
- 返回旧地点时只能沿已持久化连接,不重新生成冲突结构。
- 旧 revision patch 被拒绝;非法 patch 不产生半次更新。
- 固定学校地图保持稳定相对位置;动态迷宫只追加已探索部分。
- 是否展示不影响存储;切换呈现模式不迁移或复制图数据。
### 10.7 随机数就是隐藏变量
随机数不另建表,也不另做协议。它是现有 `变量.当前` 里的行,玩家默认不可见,行名用「名称.随机数」,例如 `敏捷.随机数`、`攻击.随机数`。
这一行的值是还没用的数字。用掉第一个,就改写这一行;只剩 1 个时补到预备个数。程序出数,模型不编。重 roll 跟着这张表现有的检查点恢复。
与呈现模式的关系:
- **0 层卡**:创作与开场不写依赖随机数才能成立的玩法。开场落库时不应出现「没有随机数可用」的阻塞。
- **普通楼层**:随机行在第一次正式游玩回复前由程序按合同填入;若开场未预填,第一次回复也会正常补齐,不要求开场模型写出具体骰面。
## 11. 能力提示同步清单
| 文件 | 改动 |
| --------------------------------------- | -------------------------------------------------- |
| `modules/catalog.yaml` | 新增 `creation-plan`;增加默认 `layer`、`mount`;绑定库保持目录级声明 |
| `modules/creation-plan/prompt.md` | 新建本局配方 + 能力分档产物 |
| `modules/reply-format/prompt.md` | 读取新壳参考,生成前端与数据契约;删除渲染字段默认空 |
| `modules/opening-setup/prompt.md` | 按正文组成的数据契约输出 `present.v1` |
| `modules/narrative-guide/prompt.md` | 从文风库选 1~2 项并本局化 |
| `modules/story-progression/prompt.md` | 从偏好库选 1~2 项,钉输入用法与停笔边界 |
| `modules/variable-design/prompt.md` | 增加按需建立记忆表及维护方式 |
| `modules/*/prompt.md` | 按 §9 逐节点确认,迁移 `score` fence |
| `src/dictate/prompt.ts` | 方案驱动、读取工具、自然回复,删除固定路径与重复禁令 |
| `src/dictate/insert-feedback.ts` | 只提供关注点、缺口和启发,不生成固定回复模板 |
| 旧对话落盘配方 | 修订为本局创作方案可读取的案例 |
| `src/dictate/play-bind.ts` | 编译游玩投影,跳过中间层,按 Worker 分发 |
| `worker-templates/world-simulator.yaml` | 按固定输入语法解释裸写、`#行动#` 与引号;只读取自己的投影 |
| `worker-templates/narrator.yaml` | 按数据契约输出各块,只读本局文风选择 |
| `worker-templates/auditor.yaml` | 按统一维护合同处理变量、拓扑、补实例与可变备忘录;默认空操作 |
写提示词时遵循 `prompt-writing` 技能口径。
## 12. 实施顺序
```text
扫尾旧配方提示
→ 本局创作方案 + 旧配方案例化
→ read_module / read_library_entry
→ layer / self_score / 自然回复
→ 剩余非工具节点逐项审阅
→ 按复杂度选择维护策略;仅 auditor_bundle 运行 maintenance-compile
→ 游玩投影编译
正文体验分类讨论
→ 壳参考库重建
→ 源「回复呈现 / 正文组成 / 开场白」审阅完成
→ 复制 0 层呈现链
→ iframe、普通 present.v1 与 0 层 slot_updates
→ 0 层卡持久化、正则兼容层与“重 roll 本轮”
动态表规格
→ topology.patch.v1 与运行拓扑
→ 副 Worker 默认维护与输入阻塞
→ 上下文顺序调整
→ 大总结 / 清空旧楼层
→ 固定输入语法解析
```
实施约束:
- 非工具节点严格执行 §9;程序基础设施可以按工作包集中实现。
- 先完成游玩投影再允许新产物直接开玩,避免继续污染运行上下文。
- 具体实例当前保持全量注入;本阶段不做 RAG。
- 旧七壳在新参考库和安全回退渲染就绪后删除。
## 13. 决策状态
- [x] 本局创作方案引用 4 份已修订案例:快穿短局、数据化跑团体验、文本生成器、交互式长文生成器;删除“正文组成”案例。
- [x] 删除独立的“用户需求”节点;偏好库、文风库改由相关节点绑定并通过 `read_library_entry` 按需读取。
- [x] 生成规则:名称/用途→字段→池或提示→填写工作流;两份以上,或一份但字段多、需要先审字段与工作流时使用;一条字段很少的具名内容通常不建规则也不落实例;一产物一条规则;`只预生成` 为中间层。不因开场后是否继续生成而改道。
- [x] 具体实例:按规则填一条记录,一份产物一条;写入多条时程序按记录拆开。无规则只留下用户点名的一条字段很少的内容。给人读的键和专名用中文,英文专名音译。
- [x] 叙事指南:读文风库 1~2(最多 3)并本局化;产物用风格推荐与要求,少禁多做;推进仍归故事推进+偏好库。
- [x] 故事推进:全局唯一输入用法;一次输出单轮目标字数及扩写/续写定义、占比和字数预算;含 OOC 处理策略;其它节点不重复定义篇幅。
- [x] 实现机制:一个产物可含多条共同作用的稳定因果;用于推演事件与人物反应;缺失后果只作内部移除检验。
- [x] 库种子:`seeds/libraries/*.json` 进 git;`libraries:restore` / `libraries:export`。
- [x] 0 层卡:开场气泡内的持久前端;正文也是槽位,每轮就地更新;内部历史仍逐轮保存。
- [x] 双呈现链:所有源节点审阅完成后,只复制 `回复呈现 / 正文组成 / 开场白与开场变量`;内容能力共用。
- [x] 动态拓扑方向:一产物一张图,运行期用版本化 patch 持久扩展;它是呈现无关的可变备忘录,是否渲染完全由正文组成决定;节点提示已迁移。
- [x] 维护分流:简单状态优先原生 `update_state` tool call;工具不可用时按 profile 能力走结构化整包;运行期变量维护不依赖正则;复杂维护才编译合同并启用副 LLM。
- [x] 普通回复呈现:只定义实际启用的语义区域、出现条件、权威来源与内容要求;内联变量更新作为隐藏语义区,具体投递由 API 能力选路;篇幅只归故事推进。
- [x] 随机数:现有变量表里的隐藏行,行名为「名称.随机数」;用掉一个就改行,只剩 1 个时补。
- [x] 普通正文组成:把回复呈现的可见区域落成完整 HTML/CSS/JS、`present.v1` 数据契约和区域渲染;隐藏维护区不进 DOM,权威数据只由程序投影。
- [x] 普通开场白:默认 1 条(用户要求时 2~3);全部保存,默认条开玩;每条自带初态与可选角色;不写随机数具体值。
- [x] 0 层与随机:0 层不写依赖随机的玩法;随机行在第一次正式回复时由程序补齐。
- [x] 0 层“重 roll 本轮”复用消息前 checkpoint;暂不实现任意历史回退。
- [x] 壳按展示的区分类。参考壳三种称呼:阅读、正文为主、数据常驻。正文为主把短文和地图收进标签或折叠;数据常驻把数据一直摊开。选项始终直接可见。隐藏变量更新不进壳。
- [ ] 各最终能力的 `mount` 和字段投影允许列表。
- [ ] 文风库、偏好库条目 schema,以及临时条目回写全局库的权限。
- [ ] “清空旧楼层”是否提供快照恢复;当前方案按不可逆二次确认处理。
- [ ] 生图任务的独立输出契约和插入时机;不纳入本轮执行范围。
## 14. 剩余执行清单
按依赖顺序继续:
1. **完成内容节点审阅与迁移**
- [x] `topology`:一产物一张图、扩展合同和五维评分。
- [x] `variable-design`:真值、更新规则与维护复杂度判据。
- [x] `status-bar`:普通楼层版语义区域、权威来源与隐藏更新区。
- [x] `random-range`:变量表隐藏随机行、预备个数与补数规则。
- [x] `reply-format`:普通楼层版前端、区域落点与数据契约。
- [x] `opening-setup`:普通楼层开场候选、初态、角色与收口。
2. **完成共用运行工具审阅**
- 运行时固定顺序:用户需要填写的内容节点全部完成 → 单独调用 `prepare_opening` → 信息可见范围 → 游玩执行方式 → 上下文排序 → 运行配置组装 → 下一步才生成并写入开场白。开场候选选定后只落初态并重投影,不首次创建运行配置。
- [x] `variable-context / 信息可见范围`:程序从上游公开性、秘密与揭示声明编译谁能看到什么;不另造状态摘要。
- [x] `worker-spec / 游玩执行方式`:程序整理前面已经确认的独立转述、复杂维护、秘密隔离或写手路径需求,只选择固定执行单元。
- [x] `context-order / 上下文排序`:程序先按变化频率分组,再在同组内按重要性排列;稳定前缀在前,历史居中,当前状态与本轮输入靠后。
- [x] `refine / 运行配置组装`:把执行方式、上下文排序、变量表与所选呈现合同打包成运行期读取的 `设计.worker集`;不承担正文润色。
- [x] `narrative` 仅服务旧工序编排,不属于本轮对话落盘迁移。
3. **完成 0 层呈现链**
- [x] 为 `回复呈现 / 正文组成 / 开场白与开场变量` 建 0 层版本,确定 id、artifact tag、目录声明和测试。
- [x] 扩展本局创作方案,使普通楼层与 0 层卡互斥选型。
- [x] 定义 `zero-layer.card.v1`、`zero-layer.update.v1`、`slot_updates`、三种更新方式和初始槽位状态。
4. **实现游玩投影与运行合同**
- [ ] 把四个共用运行工具全部改为确定性程序调用;旧工序路径不得再用 LLM 猜执行槽、上下文顺序或最终运行配置。
- [ ] 实现 `none / inline_tool / inline_structured / auditor_bundle` 维护策略选择,并复用 profile 能力探测。
- [ ] 扩展代表性状态工具探测;实际 strict schema 被拒绝时降级为非 strict tool + 本地校验。
- [ ] 为简单变量新增受白名单、操作范围和 revision 约束的 `update_state` GM tool。
- [ ] 新增条件式 `maintenance-compile`,只为 `auditor_bundle` 产出 `maintenance-manifest.v1`。
- [ ] 编译最终产物的字段白名单和 Worker 分发,剥离评分、追问与中间层。
- [ ] 运行期生成精简 `maintenance-context.v1`,扩展 `maintain.v2` 的表、拓扑、补实例和备忘录操作。
- [ ] 普通楼层输出 `present.v1`;0 层模式输出槽位更新。
- [ ] 保持旧书、旧多规则产物和旧呈现数据可读。
5. **实现动态拓扑**
- [ ] `topology.v1`、`topology.patch.v1`、revision 与原子校验。
- [ ] 运行图、写入者权限、用户编辑,以及默认由回合末副 Worker 维护。
- [ ] 本阶段不预做图谱 UI;后续正文组成选择展示时,再实现所需入口和渲染器。
6. **实现前端与重 roll**
- [x] 重建壳参考库:`shell-refs/` 五只壳供正文组成与 0 层正文组成读取。隔离 iframe 与自定义 blocks 运行时尚未接入。
- [ ] 0 层持久前端、Tab、槽位重放与正则兼容适配器。
- [ ] “更多操作”增加“重 roll 本轮”;恢复 checkpoint 后重新生成,不提供任意历史回退。
- [ ] 新前端和安全回退就绪后删除旧七壳。
7. **实现长程运行能力**
- [ ] 动态二维表与副 Worker 维护阻塞。
- [ ] 随机数行:在 `变量.当前` 增加「名称.随机数」隐藏行;用掉队首就改行;只剩 1 个时补数。
- [ ] 稳定前缀、可变表和对话的上下文顺序。
- [ ] 写大总结、清空旧楼层及二次确认。
- [ ] 固定输入解析:裸写 OOC / 上帝视角、`#行动#`、引号对话。
8. **最终验收**
- [ ] 普通楼层与 0 层模式端到端测试。
- [ ] 重 roll、续作、run 快照、分支和拓扑持久化测试。
- [ ] 全量测试、lint、构建基线与迁移文档。
Binary file not shown.
@@ -0,0 +1,128 @@
# 提示缓存核对:记账是否可信、首填为何仍≈0
> 状态:核对结论。承接 `docs/briefs/token-cost-report-2026-10-06.md` §1.5 / §4.P2。
> 记录:2026-10-07。
> 疑问:若 OpenRouter 上 DeepSeek 缓存能挂住,是否说明「首填几乎不吃缓存」是本地记账错了,或粘滞/TTL 才是主因?
## 1. 结论(先看这个)
1. **缓存记账可信。** 我们只是把 OpenRouter 的 `usage.prompt_tokens_details.cached_tokens`(及 DeepSeek 的 `prompt_cache_hit_tokens`)原样写入 `~/.writing-agent/stats`;不是自估、不是漏字段。
2. **「首填≈0、retry 高」是上游真值,不是漏记。** 同轮对照里,绝大多数是 fill 几乎无缓存、fill-retry 立刻上到八成上下。若解析坏了,两边应同假。
3. **DeepSeek 探测成功,不能推翻 Gemini 首填账。** 花销报告窗口主模型是 `google/gemini-3.*-flash`;DeepSeek 只证明「同 baseUrl + 同解析链路」在另一模型上能读到非零缓存。
4. **粘滞/`session_id` 不是当前主账缺口。** 同会话内 fill→retry 已能吃到缓存,说明粘到了同一上游且前缀可复用;贵在 **每一轮第一枪大前缀仍全价**,以及 **重试常态**。
5. **优化优先级不变:** 仍以 P0(fill 去工具循环、消灭 retry)为主;P2(跨 select/fill 稳定前缀)对 Gemini 有意义,但不是「先修记账」或「再抠 TTL」。
---
## 2. 记账链路(我们记了什么)
| 层 | 行为 |
|---|---|
| OpenRouter 响应 | `prompt_tokens` / `prompt_tokens_details.cached_tokens` / 可选 `cache_write_tokens` |
| `parseUsage`(`src/llm/client.ts`) | 读 `cached_tokens`;DeepSeek 另认 `prompt_cache_hit_tokens` |
| stats jsonl | `cachedTokens` / `cacheWriteTokens` 等落盘 |
| 报告命中率 | `cached / prompt`(按请求平均或合计) |
单元测试覆盖 OpenAI details、DeepSeek hit/miss、cache write。探测脚本与生产走同一 `complete` → `parseUsage`。
**原始对照(2026-10-07,Gemini 探测):** 直接 `fetch` OpenRouter,两次固定大前缀请求的 `usage` 均为:
```json
"prompt_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 0
}
```
落盘若记成 0,与上游一致。
---
## 3. 生产窗口再切一刀(缓存行为)
范围:`~/.writing-agent/stats` 中 2026-10-03、10-04、10-06、10-07 的 `dictate_agent`(与花销报告同一量级窗口,略含 10-07)。
| step | 请求 | 有缓存占比 | 均命中率 | 中位命中 |
|---|---:|---:|---:|---:|
| select | 79 | 1.3% | ~0.8% | 0% |
| **fill** | 79 | **1.3%** | **~0.5%** | **0%** |
| **fill-retry** | 83 | **84.3%** | **~71.9%** | **~85%** |
| reply | 30 | 0% | 0% | 0% |
同轮 `fill` + 至少一次 `fill-retry` 的配对约 **45**:
| 模式 | 对数 |
|---|---:|
| 首填≈0 且 retry 命中 >40% | **34** |
| 两者都≈0 | 11 |
| 首填命中 >20% | **0** |
样本:同轮 `fill` `cached=0`、随后 `fill-retry` `prompt≈90k / cached≈82k`(命中约 91%)。这只能解释为:**上游在 retry 上报了高缓存,我们照记。**
模型侧:窗口几乎全是 `google/gemini-3.7-flash` / `gemini-3.8-flash`;与花销报告一致。
---
## 4. 探测:DeepSeek vs Gemini(同 OpenRouter)
核对方式:固定大前缀连打 OpenRouter,读 `usage.prompt_tokens_details.cached_tokens`(脚本已删,结论仍有效)。
### 4.1 `deepseek-v4-pro`
| 调用 | prompt | cached | 命中 |
|---|---:|---:|---:|
| A#1 | 10602 | 7650 | 72.2% |
| A#2~A#4 | 10602 | 9743 | 91.9% |
| 等 60s 后 B#1~B#2 | 10602 | 9743 | 91.9% |
→ 隐式缓存可用;短间隔爬升;**约 1 分钟间隔未掉光**。说明「同 baseUrl + 粘滞 + 记账」这条链对 DeepSeek 成立。
### 4.2 `google/gemini-3.8-flash`
| 调用 | prompt | cached | 命中 |
|---|---:|---:|---:|
| A#1~A#3(间隔 5s) | ~12.7k~15k | **0** | **0%** |
原始 `usage` 同上,`cached_tokens=0` 且 `cache_write_tokens=0`。
→ **不是我们没记上**,是这次探测形态下上游就报 0。与生产里「仅当 fill 刚跑过、retry 几乎同前缀」才高频命中,并不矛盾:retry 复用的是**刚发过的整包 fill 前缀**;探测的「新会话 + 纯文本 system」未复现那种命中。
OpenRouter 文档对 Gemini 同时提到隐式缓存与 `cache_control` 显式断点;本仓库**未打** `cache_control`。生产 retry 仍能命中,说明至少部分 Gemini 路由在「极近、极同前缀」下会报 `cached_tokens>0`;**不能**因此假设跨 select→fill、或跨轮首填也会自动吃到折扣。
---
## 5. 对花销报告结论的修正与保留
| 命题 | 判定 |
|---|---|
| 首填缓存几乎失效 | **保留**(记账复核后仍成立) |
| 贵主要因为 fill 大前缀 ×(1+retry) | **保留** |
| 怀疑「其实有缓存但没记」 | **否定** |
| 怀疑「DeepSeek 能缓存 ⇒ 当前账单不该贵」 | **否定**(账单模型是 Gemini;且 DeepSeek≠本窗口主路径) |
| 怀疑「再抠 TTL / session 就能救首填」 | **弱化**:同会话 retry 已证明粘滞可用;缺口在 **select≠fill 前缀** 与 **每轮第一枪**,不是「完全没粘住」 |
| P2 共享稳定前缀 | **仍值得做**(对 Gemini 跨步命中),但收益次于 P0 去循环 |
| 换 DeepSeek 指望隐式缓存 | **可作实验**,质量/工具/价格需另验;不能单靠本次探测定案 |
相对花销直觉(未缓存=1、缓存≈0.25):retry 已打折,**未打折的是几乎每次的首填大包**。消灭 retry 或把首填变小,都比「怀疑仪表」更贴账单。
---
## 6. 建议(本核对后的落地顺序)
与 `token-cost-report-2026-10-06.md` §5 对齐,本文件只钉缓存相关验收:
| 步 | 动作 | 验收 |
|---|---|---|
| A | **不改记账**;需要时用原始 `usage` 或 `/generation` 抽查 | 抽查与 jsonl `cachedTokens` 一致 |
| B | P0:fill 改为一次 structured,去掉写工具循环 | 每轮调用 p50→2;fill-retry 占比 &lt;10%(主收益) |
| C | P2:select/fill 共用不变字节前缀,易变块置尾 | 同会话第 2 轮起 **fill 首次** `cached/prompt` 明显上升(Gemini 目标仍可订 ≥40%,以实测为准) |
| D | 若坚持 Gemini 显式缓存 | 单独评估 OpenRouter `cache_control`;先小流量探测,再决定是否进主路径 |
| E | 可选:用 DeepSeek 等隐式缓存友好模型做同书对照周 | 比的是 **有效单价 × 质量**,不是单看 hit% |
**明确不做:** 为「修缓存」去动 `parseUsage` 估命中;用 `provider.order` 换 `session_id`;把探测脚本的 DeepSeek 命中率写进创作账单当成已省钱。
---
## 7. 一句话答复原疑问
> 缓存记录是正确的。DeepSeek 能挂缓存,只说明链路和仪表没坏;当前创作窗口里 Gemini **首填几乎不打折是上游如实返回**,retry 高命中反而证明记对了。钱仍主要花在「每轮第一枪大前缀 + 填写真的在重试」,优先去 Agent 循环,而不是先怀疑统计或死磕 TTL。
+219
View File
@@ -0,0 +1,219 @@
# 创作对话 Token 花销报告与优化建议
> 状态:体验核对结论。数据来自本机 `~/.writing-agent/stats`(2026-10-02~10-06),对照 `docs/briefs/creation-round-cost.md` 旧基线与当前 `src/dictate/round.ts` 实现。
> 记录:2026-10-06。
> 诉求:对话不要继续走「自由 Agent 工具循环」,花销过大。
## 1. 结论(先看这个)
1. **主战场仍是创作落盘,不是游玩。** 本窗口 `dictate_agent` 约占总 prompt 的 **99%+**;游玩 Worker 几乎可忽略。
2. **已经不是旧版「读甲写甲再读乙」自由 Agent**,而是 `select → 程序注入 → fill 工具循环(可 retry)→ 偶发 lean reply`。但 **fill 仍是 agent 形态**(`toolChoice: required` + 多工具 + 最多 4 次重试),这是当前贵的原因。
3. **一句用户话平均约 3.3 次模型请求**,中位 3、P90 5、最多 6。其中 **60% 的轮次至少 1 次 fill-retry**,约 **29% 要 2 次及以上重试**。
4. **相对花销(未缓存=1、缓存=0.25、输出=4)**:fill **53%**、fill-retry **28%**、select **15%**、reply **4%**。去 Agent 的收益应打在 **fill 一次写完、消灭重试、砍历史**,而不是再抠游玩。
5. **首填缓存几乎失效**:fill 首次命中率约 **1%**,retry 才爬到约 **70%**。大前缀每一轮至少全价付一遍。
---
## 2. 本窗口花销账
统计范围:本地 jsonl,2026-10-02、10-03、10-04、10-06(10-05 无落盘)。共 **298** 条用量记录。
### 2.1 按 caller
| 路径 | 请求 | prompt | 其中缓存 | 命中率 | completion |
|---|---:|---:|---:|---:|---:|
| `dictate_agent` | 279 | **636.7 万** | 202.6 万 | 31.8% | 57.9 万 |
| `worker:*` | 13 | 2.5 万 | ~0.1 万 | — | 0.3 万 |
| `main_agent` | 6 | 0.06 万 | — | — | — |
创作 : 游玩(prompt)≈ **250 : 1**(本窗口游玩样本很少;方向与旧基线「创作远大于游玩」一致)。
### 2.2 按日(仅创作)
| 日 | 请求 | prompt(百万) | 缓存(百万) | completion(千) |
|---|---:|---:|---:|---:|
| 10-02 | 37 | 0.30 | 0.04 | 117 |
| 10-03 | 86 | 2.25 | 0.76 | 187 |
| 10-04 | 23 | 0.31 | 0.07 | 45 |
| 10-06 | 133 | 3.50 | 1.15 | 229 |
### 2.3 创作按 step(关键)
| step | 请求 | prompt 合计 | **单次均 prompt** | 缓存命中 | completion |
|---|---:|---:|---:|---:|---:|
| **fill-retry** | 89 | 284.1 万 | **31.9k** | **70.1%** | 16.4 万 |
| **fill** | 82 | 242.5 万 | **29.6k** | **1.1%** | 33.7 万 |
| select | 85 | 89.6 万 | 10.5k | 0.9% | 5.0 万 |
| reply | 23 | 20.5 万 | 8.9k | 0% | 2.7 万 |
要点:
- retry 请求数 **多于** 首次 fill(89 > 82)→ 重试是常态,不是边角。
- 首次 fill 几乎不吃缓存;钱主要花在「每一轮第一枪大前缀」。
- select 已不带产物正文(实现正确),但仍带全量更早对话,均约 10k prompt。
- lean reply 只覆盖约 **28%** 有 fill 的轮次;其余多在 fill 同请求里 `reply_user`(设计意图),或失败后走 prose 回退。
### 2.4 按用户轮次(`turnId`)
| 指标 | 值 |
|---|---:|
| 轮次数 | 85 |
| 每轮模型调用 avg / p50 / p90 / max | **3.28 / 3 / 5 / 6** |
| 每轮 prompt 合计 avg / p50 / p90 / max | **7.5 万 / 6.0 万 / 14.9 万 / 25.9 万** |
| 无 retry | 34(40%) |
| ≥1 次 retry | 51(60%) |
| ≥2 次 retry | 25(29%) |
### 2.5 上下文块(`segmentChars`,字符,非 token)
| step | n | rules | history | **products** | injected | tail | 均 prompt tok |
|---|---:|---:|---:|---:|---:|---:|---:|
| fill | 82 | ~2.1k | ~9.3k | **~23.0k** | ~5.6k | ~5.0k | 29.6k |
| fill-retry | 89 | ~2.1k | ~8.7k | **~21.9k** | ~6.3k | ~4.9k | 31.9k |
| select | 85 | ~0.3k | ~9.0k | **0** | 0 | ~4.9k | 10.5k |
| reply | 23 | ~0.5k | 0 | ~6.7k(仅本轮) | 0 | ~6.3k | 8.9k |
近期 fill 样本(10-06):products 已到 **4.3~5.0 万字**,history **2.6~3.2 万字**,单次 prompt **5.5~6.9 万 token**,且 `cached=0`。
### 2.6 模型
| 模型 | 请求 | prompt(百万) | 命中率 |
|---|---:|---:|---:|
| `google/gemini-3.8-flash` | 171 | 4.13 | 29.6% |
| `google/gemini-3.7-flash` | 102 | 2.17 | 36.4% |
| `openai/gpt-6.1-sol` | 6 | 0.07 | 16.1% |
金额粗算(按 Flash 量级:未缓存输入 $0.15/M、缓存 $0.0375/M、输出 $0.60/M,**仅作数量级**):本窗口创作约 **$1.1**,约 **$0.013/用户轮**。体感「贵」主要来自 **轮次×大前缀×重试**,以及若换成更贵模型会线性放大;不是输出写爆。
### 2.7 与旧基线对照
旧账(`creation-round-cost.md`,约 9/20 起、创作 416 次 / prompt 1198 万):自由工具循环、先 `read_module` 再 `insert`。
| | 旧自由 Agent | 本窗口(已 planned) |
|---|---|---|
| 形状 | 读/写交织、工具定义常驻 | select + 注入 + fill 循环 |
| 同轮请求倍数 | 常「每节点×2」再加回复 | 中位 **3**(已封顶感更好) |
| 产物进选题 | 会进(旧整包) | **已去掉** |
| 填写大头 | 全产物 + 全对话 | **仍是全产物 + 近乎全对话** |
| 首填缓存 | 依赖粘滞 | **仍≈0**(select/fill 前缀形状不同) |
**程序已省下「读模块那一轮」和选题侧产物**;**未省下「大前缀×(1+重试)」**。用户觉得「还是 Agent、还是贵」,与数据一致。
---
## 3. 当前链路(体验视角)
```text
用户一句
→ select(小上下文,强制 select_nodes) ≈1 次,~10k prompt
→ 程序注入本轮节点写法 / 库正文(确定性,不计模型)
→ fill(大上下文,强制工具:insert/delete/变量/映射 + reply_user)
└─ 未过则 append 工具结果,再 fill-retry(最多约 4)
→ 若填完却未 reply_user → lean reply(只带本轮产物) ≈9k
```
与「不要 Agent」的落差:
| 已做到 | 仍像 Agent |
|---|---|
| 选题与填写拆开 | fill 仍多工具 + required |
| 规格由程序注入,不再 `read_module` | 校验失败靠模型再跑一整轮大前缀 |
| 回复可瘦身 | 变量/映射/删除仍走 tool loop |
| `session_id` 粘滞已接 | select→fill 系统提示与正文布局不同,首填缓存打不穿 |
---
## 4. 优化建议(按性价比)
### P0 — 对话填写去 Agent(最贴合诉求)
**目标形状:一句用户话 = 2 次模型请求(澄清时 1 次),禁止 fill 工具循环。**
1. **select**:保持现状(或改为纯 JSON structured output,去掉 tools 税)。只出 `module_id[]` / 澄清题。
2. **fill**:改为 **一次 structured complete**:`{ products: [...], reply, declares?, deletes? }`,程序本地切开落盘与校验。
3. **失败只重写失败 tag**:上下文 = 规则摘要 + 该 tag 注入规格 + 相关产物白名单 + 错误信息;**不要**再塞全黑板 + 全历史重跑。
4. **变量/映射**:优先由程序从 structured 字段落盘,或极窄工具面;不要与长正文 insert 共用大循环。
预期(相对本窗口相对花销):
- 去掉 fill-retry 常态 → 相对花销约 **-25%~35%**
- 首填仍大,但每轮从 3.3 枪降到 ~2 枪 → 再 **-20%~30%**(视澄清/失败率)
- 合计数量级:**创作输入花销降到现在的一半左右**(前提:一次交多份成功率够高)
与相位机约束不冲突:仍在同一次 `running` 内、用户不手选节点、方案不是执行序。
### P1 — 砍 history(实现便宜、收益稳)
现状:select/fill 的「更早对话」均值约 9k 字,会话后期到 **3 万字+**,占填写真份额。
建议:
- select:方案进度 + **最近 2~4 轮** + 末句(旧 brief 已论证「只留末句会误选」,不能只留一句)。
- fill:同样只留最近对话;产物冲突靠产物正文,不靠早期闲聊。
- 清空对话能力保持;长记忆另走总结产物,不靠无限 chat。
预期:fill/select 各再 **-10%~20%** prompt;会话越长收益越大。
### P2 — 稳首填缓存前缀
`session_id` 只保证同上游;**select 与 fill 的 system + 前部布局不一致 → 首填命中≈0**。
建议(不改业务语义):
- 抽出 **跨 select/fill 不变的规则前缀**(身份、写入规则骨架),两边共用同一字节前缀。
- 易变块(方案进度、checklist、注入、末句)固定在末尾(现已部分如此)。
- 工具定义:若 fill 改 structured,则工具 JSON 不再每轮跟着大请求。
预期:在粘滞有效时,同一会话后续轮次的「规则+稳定壳」可从全价变折扣;**不降低发送体积,但降低未缓存账单**。对 OpenRouter+Gemini 块粒度(约 4k)敏感,前缀差几百 token 可能整块失效。
### P3 — 产物相关性(大头,风险高)
products 占 fill 字符大头(样本约一半以上)。旧 brief 已说明:误裁会导致冲突设定。
建议分两档:
1. **安全档**:按本轮 `module_id` 维护「必带 tag」白名单(依赖边写在 catalog);其余只带 **标题+一行摘要+hash**。
2. **激进档**:检索/摘要。必须配套:写入前冲突扫描、失败则自动补全文重试(否则质量回退会被当成「省钱事故」)。
预期:做好可再砍 fill **30%~50%**;做不好会表现为反复 rewrite,把 P0 收益吃掉。
### P4 — 游玩与总管
本窗口不必优先。游玩保持「一次生成、非创作 tool loop」。OpenRouter 上游玩缓存曾为 0,靠 `session_id` 已具备前提;前缀顺序现已正确。总管 `main_agent` 可忽略。
### 明确不做 / 慎做
| 项 | 原因 |
|---|---|
| 改回用户手选节点以省一轮 | 与产品「自然语言推进」冲突 |
| 相位机按固定工序跑完全部节点 | 与「方案不是执行序」冲突 |
| 选题带全产物 | 已证明浪费;会毁掉 select 的小请求优势 |
| 用 `provider.order` 代替 `session_id` | 会关掉粘滞 |
| 只砍输出 | 输出约是 prompt 的 3%~9%,不是主账 |
---
## 5. 建议落地顺序(可验收)
| 步 | 改动 | 验收指标(同书同对话长度) |
|---|---|---|
| A | fill 改为一次 structured,去掉 write tool loop | 每轮模型调用 p50→**2**;fill-retry 调用占比 **&lt;10%** |
| B | select/fill history 窗口 | fill `segmentChars.history` 中位下降 **≥50%** |
| C | 共享稳定 system 前缀 | 同会话第 2 轮起 fill 首次 `cached/prompt` **≥40%**(Gemini/OpenRouter) |
| D | 产物白名单/摘要 | fill 均 prompt **再降 ≥25%**,且 insert 拒绝率不升高 |
体验验收(主观):
- 用户感知仍是「说一句 → 落几块 → 一段综合回复」,无「工具来回思考很久」的 Agent 感。
- 澄清轮仍可 1 次请求结束(仅选题提问)。
- 失败时只看到「某几块重写」,而不是整轮长时间空转。
---
## 6. 和「不要 Agent」的直接答复
- **创作对话:应该停止以 Agent tool loop 作为填写主路径。** 选题可以极窄工具或 JSON;填写应程序注入规格 + 一次结构化交卷 + 本地校验。
- **游玩:已经不是创作那种 Agent**;继续单次 Worker 生成即可。
- **总管 Agent:不是当前账单**;可维持。
若只做一件事:做 **P0(fill 去循环)**。它同时回应「体感像 Agent」和「token 太大」两件事。
+132
View File
@@ -0,0 +1,132 @@
这份文档是自包含的。接收方看不到代码仓库、聊天记录和作品正文。请只根据本文做事。
请你做两件事,都要写成我们可以逐条核对的文字:
1. 列出业界在「长上下文 Agent、提示缓存、工具循环」上的常见优化方案。每条写清:做法、它节省的是请求次数、未缓存输入、缓存折扣价还是输出,以及它依赖什么前提(例如上游必须支持前缀缓存、模型必须能一次返回多个工具)。
2. 针对下面描述的这个程序,给出你建议采用的组合方案。说明每一步落在哪一次模型请求、上下文里留哪些块、块的顺序、相位机的哪一段执行。不要假装读过仓库。标出你不确定、需要我们核对的点。
我们会用仓库和现有规则检查你的方案。与本文第三节冲突的做法请单独标出,不要藏进推荐里。
一、这是什么程序
这是一个写作代理,分两段使用。
创作期:用户用自然语言描述想要的作品,不手动点选节点。模型决定这一轮要落实哪些「能力」,把结果写成黑板上的产物。产物是规格(舞台、规则、变量、正文如何呈现、开场),不是游玩回合的正文。聊天只做确认和追问。清空对话后产物还在,产物才是创作期的持久记忆。
游玩期:另一套 Worker 按已经写好的规格往下演。一次用户输入通常对应一次生成,不是创作那种多步工具循环。
主要渠道是 OpenRouter 上的 Gemini 与 OpenAI 兼容模型。也有直连的单一上游做对照。费用近似为:
请求次数 ×(未缓存输入 × 全价 + 缓存输入 × 折扣价 + 输出 × 输出价)
缓存命中不是免费的,只是更便宜。输出大约是 prompt 的 3% 到 9%,单价更高,但当前主账仍是输入。最近约三天(2026-09-28 10:35 到 2026-10-01 10:35,东八区)创作落盘 416 次请求、prompt 1199 万 token,游玩 29 次、prompt 80 万。创作大约是游玩的 14 倍。优化主战场是创作。
二、会有哪些上下文
下面这些块都会进模型,但不是每一次请求全有。
创作落盘,一句用户话开始时拼一次,工具循环中途只在末尾追加:
- 系统提示:身份、写入规则、推进原则。尚无本局方案时附整份能力短目录(何时用、何时不用、档位)。已有方案时改为方案进度:每个能力一行,已落或未落、档位、理由、缺口。不含产物正文。样本里系统提示 6695 字,其中方案进度 4392 字。
- 一条用户消息:全部已有产物,按相对顺序,然后全量对话。样本 25 份产物共 60518 字(约 69%),全量对话 19975 字(约 23%)。没有一份产物独大,最大的正文组成 8879 字,其次变量规则 6005、实现机制 4569,其余多为两三千字。会话中段产物已经约 5.6 万字。
- 工具定义:每次都带,约 4917 字,一轮内不变。工具有读取能力、读取库条目、准备开场、写入产物、删除、声明变量、声明映射、清空对话。
- 预设:包在系统侧的文风或格式片段。当前选用预设启用正文约 362 字,可忽略。换长预设时它变成稳定前缀,应放在易变内容之前。
- 工具结果:循环后半才出现。读取能力的返回体是该能力的任务、原则、产物规格、评分和库目录,一份约 5000 字。库条目正文同理,目录里只有摘要。这些全文一开始不在上下文里。
游玩一次生成:
- 预设与该 Worker 的系统说明(相对稳定)。
- 槽位人设。
- 静态规格投影(舞台、规则、叙事、正文组成等,开局后很少改)。
- 对话历史(每轮变长,按投影裁剪)。
- 变量当前值与本轮用户输入(每轮都变)。
总管工具循环(决定何时调用哪个 Worker)另有一套 message 历史。最近三天的账几乎不在这条上,优化时不要把它和创作落盘混成同一次请求。
能力的完整写法存放在各自的提示文件里,不在系统目录里。不把所有能力的完整提示放进第一次请求。完整写法只在已经确定本轮要写哪几项之后进入上下文。
三、相位机之内,程序可以动哪里
运行分两层。
业务阶段:创作(设计规格)然后游玩。这是作品生命周期,不是每一次模型调用。
运行相位:idle、running、waiting_user、done、error。相位机是纯函数:给定会话和事件,决定谁在场、哪些工具可用、何时必须等用户、产物何时算数。它不决定「下一步叫哪个能力」。下一步由 running 里的模型工具循环决定。
一句用户话是硬事件:相位进入 running,这一轮的模型调用次数从零计。创作落盘在这次 running 里跑工具循环,上限大约三十多步。循环开始时拼好开头,之后只追加助手工具调用和工具结果。模型不再欠请求、或写出给用户的回复之后,相位回到 waiting_user,等待下一句。
因此:
- 减少「读一份、写一份、再读下一份」必须发生在同一次 running 里,也就是两次用户话之间。
- 不能把相位机改成按固定工序自动跑完全部节点。用户不手选节点,方案也不是执行顺序。
- Worker 执行时总管暂停。游玩的那一次生成是 Worker 自己的请求,上下文按该 Worker 的投影拼,不复用创作工具循环的消息历史。
- 程序可以在两次模型请求之间插入确定性步骤:注入选中能力的当前提示、核对版本哈希、逐条校验、只重试失败的那一条。这些不是相位变化。
四、缓存优化
目标是稳定前缀留在同一上游的提示缓存里。不追求两次请求的整包字节相同。Agent 每一轮消息都会变。
已经接上:OpenRouter 请求体带本局 session_id(最长 256 字)。直连其它渠道不带,避免未知字段被拒绝。有 session_id 后,粘滞从该会话第一次成功请求就开始,不必先看到缓存命中,也不再因为第一条用户消息被重写就换上游。不要发送 provider.order,它会关掉粘滞。
接上之前的测量(不要把「再做一遍粘滞」当成主体):
- 创作工具循环间隔约 7 到 15 秒。Gemini 上同一次循环内缓存从约 3800 token 按大约 4000 一块爬到 7600、11500、15000,整段 prompt 约 50% 到 68% 命中。每个 burst 的第一下经常是 0。中途偶尔整段掉回 0,是换了上游。
- 游玩在 OpenRouter 上 28 次、prompt 1.8 万到 3.4 万,间隔约 5 到 22 分钟,缓存为 0。直连单一上游时,间隔 8 到 30 分钟仍常见命中 80% 到 91%。有一轮从约 1.1 万掉到 6656 并停住,说明前缀中段改过,只保住开头。
- Gemini 隐式缓存大约 4000 token 一块记账。前缀差几百 token,账单上可以差一整块。
session_id 只保证落到同一上游。命中到第几个 token,取决于前缀从哪一字开始和上一次不同。
五、上下文编排顺序
原则:越少改的越靠前,每轮都变的放在最后。一处改动只作废它后面的缓存。
游玩已按此排列:人设与静态规格、对话历史、变量与本轮输入。排序本身在直连上游上有效;OpenRouter 上曾经是 0,是因为当时没有粘住上游,不是因为顺序反了。
创作当前顺序是:系统提示(含会随落盘改写的方案进度)、然后一条用户消息里先全部产物再全量对话、工具定义在消息之外、工具结果接在末尾。产物任何一份被覆盖,如果它不在整包的末尾,它后面的产物和对话都会离开缓存。工具定义一轮内稳定,适合留在前缀可缓存的区域。方案进度放在系统提示里,方案一改,系统提示之后全部重计。
选择与填写若拆成两次请求,稳定的能力目录应在选择请求的前部,方案进度、上一问和本轮用户输入在末尾。填写请求里,少改的产物在前,本轮新注入的节点提示和最近对话在后。节点提示每轮都会换,不能放在全部产物之前,否则一换节点,后面整包产物都按未缓存计。
六、上下文发送哪些
用同一本书量过,不发请求。当前一次创作请求的 messages 约 88218 字,末次实际 prompt 约 46897 token(大约 2 字 1 token)。
- 选择若只带方案进度加用户最后一句:约 4522 字。再加助手上一问仍然很小。产物正文不要放进选择。只看用户这句原文不够,否则回答「第二个」或修改已有地图时会对错节点,已写过的能力会被再选。
- 填写若仍带全部产物,对话只留最近约两轮(样本 1770 字):约 6.2 万字。砍掉全量对话大约只省两成。
- 再注入一份节点提示约 5000 字。一次注入两三份,是在产物之上加一两成,而且只发送一次。
所以「少发对话」省得有限。「少发哪些产物」才碰到大头,但填写时不带相关产物就会和已有设定冲突。哪些产物是这份能力的材料、哪些只是其它规格,本文没有一份可执行的白名单。常见的相关性裁剪、摘要、检索如果要用,必须说明误裁后怎样发现。
模型在看见该能力当前版本的任务、原则、规格、评分之前,写出来的正文不算数。程序用这些段落的哈希做版本。同一轮生成里先调用读取、同时把正文交上去,不算看见过写法。不要求先读的只有 narrative、variable-context、context-order、worker-spec、refine 和标记为自动的程序步骤。库条目正文同样要先看见再写进产物,默认 1 到 2 条。
七、程序上的优化,仍在同一次 running 里
现在的倍数来自工具循环,不是来自相位来回切换。
同一项内容能力至少两次模型请求:第一次凭短说明选定并读取,第二次看见写法之后写入。提示要求一轮通常写这句已经说清的 2 到 3 个能力。模型常一次只返回一个工具,于是变成读甲、写甲、读乙、写乙,最后再单独回用户一句。驱动程序其实会在同一步执行该次响应里的全部工具,日志里偶发一次写两份。
讨论过、尚未定案的程序形状,供你对照业界做法,不是已经同意的设计:
- 同一次 running 里两次模型请求。第一次只输出能力 id 和库条目 id。程序注入对应提示并记下版本。第二次一次交回多份产物和一段给用户的回复。某一条校验失败只重写那一条。
- 若模型一次只能调用一个工具,用一次结构化输出携带多条产物,程序在本地切开。这只去掉写入侧的重复前缀。规格说明仍须在这次生成之前就在上下文里。
- 写开场前有一次程序准备:编译信息可见范围、游玩执行方式、上下文排序和运行配置。这一步不应再变成一次让模型自由发挥的长上下文请求。
必须仍然成立的行为:用户不手选节点;方案不是执行顺序;没说清的「必须」和「有必要」本轮不写,先问 2 到 3 个选项;同轮多份产物只给一次综合回复,不把完整 JSON 贴进回复;变量和映射不走普通写入。
八、请你列出的常见方案,以及针对我们的组合
常见方案请覆盖至少这四类,每类不要只写一句口号。我们没给仓库,所以请把业界做法写全,包括你最后不推荐的。我们会删掉不适合的。
1. 缓存:粘滞路由、前缀稳定性、缓存块粒度、显式缓存断点、把变化赶出前缀。session_id 已经有了,请写它解决不了的部分。
2. 编排顺序:稳定段、历史、本轮输入的常见排序,以及中段一改、后段全失效时业界怎么处理。
3. 发送哪些:全文产物、摘要、按能力取材料、检索、滑动窗口、把目录和正文拆开。请写误删材料的后果。
4. 程序:一次多工具、结构化输出一次交多份、选择与填写拆请求、把规格预注入从而取消读取轮、限制工具循环次数。请写哪种依赖模型能力,哪种只依赖程序。
然后给出你建议我们采用的组合:一句用户话几次请求、每次上下文块的顺序、预计相对现在少发送几次大前缀、产物那约 6 万字还在不在填写请求里。故意不做的项写原因。和第三节、第六节冲突的地方单独列出,不要写进推荐步骤。
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+84
View File
@@ -0,0 +1,84 @@
# 从启动到开玩
解压或下载完成后,按下面的顺序即可:双击启动、打开浏览器、写一张最简单的卡,然后游玩一句。
需要先安装 [Node.js](https://nodejs.org/)(勾选安装时附带的 npm)。第一次启动会自动安装依赖,请等黑窗口跑完。
## 1. 双击 `start.bat`
在文件夹里双击 `start.bat`。会弹出一个黑窗口,并自动打开浏览器。地址是:
http://localhost:23337
窗口不要关。关窗口或按 `Ctrl+C` 就是停止软件。
![启动后的黑窗口](01-start.png)
想用桌面窗口而不是浏览器,改双击 `start-app.bat`。后面的操作一样。
## 2. 第一次要填模型接口
点右上角 **设置**,再点 **新增**。填上这四项后点 **保存并选用**:
- 名称:自己认得就行,例如 DeepSeek
- Base URL:接口地址,例如 `https://api.deepseek.com/v1`
- API Key:你的密钥
- 型号:这一行要用的模型名,例如 `deepseek-chat`
填完型号后也可以点 **嗅探**,从列表里点一个型号。
![新增 API](03-api.png)
项目根目录如果已经有填好的 `.env`,启动时会自动带上,这一步可以跳过。
## 3. 新建作品
还没有任何作品时,打开页面就会弹出 **新建作品**。输入名称,点 **创建**。以后再开新的,点左侧 **+**。
![填写作品名](04-new-book.png)
这里用「雨天小书店」做例子。
## 4. 写一张最简单的卡
在底部输入框里原样贴上这段,点发送(或按 Enter):
```text
请写一张最简单的可玩卡,写完就停。
题材:雨天小书店。玩家是进来躲雨的客人,店员是一只戴蓝色围巾的猫。
只要短对话。不要数值、地图、复杂世界观。
开场只要两三句:雨还在下,猫店员抬起头。
```
![把这段话放进输入框](05-prompt.png)
等它写完。中间的回复会说明卡已经好了,右侧 **已持久化** 是落下来的几张卡片。
![写好的卡](06-card.png)
写完它如果还追问,回复「都不用再加,写完开场就可以开玩」。
## 5. 保存并开玩
右上角出现 **保存定稿** 时,点它,给这份稿起个名字。没有这个按钮时,点输入框右侧的保存图标,效果相同。
然后点左侧的作品名,在弹出的菜单里选 **游玩**。
会先看到开场白:
![进入游玩后的开场](07-play.png)
在底部写你要做的事,再发送。例如:
```text
我收起伞,问店员今天适合躲雨看的书。
```
它会接下去写这一轮:
![说完一句之后](08-play-reply.png)
之后就一直这样:你说一句,它写一轮。右上角 **保存游玩** 可以把进度存下来。
想换你在故事里的名字,到 **设置 → 用户角色** 里新增一个角色并选用。创作时如果希望开场先写占位,把角色名写成 `@玩家`。
+2 -3
View File
@@ -133,7 +133,6 @@ type ImportedGenerationParameters = {
openai_max_context?: number; openai_max_context?: number;
openai_max_tokens?: number; openai_max_tokens?: number;
stream_openai?: boolean; stream_openai?: boolean;
reasoning_effort?: string;
verbosity?: string; verbosity?: string;
seed?: number; seed?: number;
n?: number; n?: number;
@@ -147,7 +146,8 @@ type ImportedGenerationParameters = {
temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、repetition_penalty。 temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、repetition_penalty。
请求容量与行为参数 请求容量与行为参数
openai_max_context、openai_max_tokens、stream_openai、reasoning_effort、verbosity、seed、n。 openai_max_context、openai_max_tokens、stream_openai、verbosity、seed、n。
思考强度不进预设,只在 API 配置里设置。
``` ```
不同供应商不一定支持全部字段。导入后应先保存原始字段,再由 LLM adapter 决定哪些字段可以发送。 不同供应商不一定支持全部字段。导入后应先保存原始字段,再由 LLM adapter 决定哪些字段可以发送。
@@ -200,7 +200,6 @@ type GenerationParameters = {
maxContextTokens?: number; maxContextTokens?: number;
maxOutputTokens?: number; maxOutputTokens?: number;
stream?: boolean; stream?: boolean;
reasoningEffort?: string;
verbosity?: string; verbosity?: string;
seed?: number; seed?: number;
variants?: number; variants?: number;
+1 -1
View File
@@ -135,7 +135,7 @@
| `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) | | `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) |
| `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 | | `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 |
| `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 | | `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 |
| `opening-setup`(技能) | **开场白与开场变量** | 工序路径:1~多条候选 swipe。对话落盘用 `设计.开场白[#短码]` 增殖;游玩辅助 Tab「开场」点选 | | `opening-setup`(技能) | **开场白与开场变量** | 可增殖节点:每条 `设计.开场白#短码`。游玩时开场消息左右切换,选中的一条作为 0 层 |
| `protagonist`(技能) | **主角设定** | `设计.主角设定[#短码]`;与开场白同短码即绑定该开局。挂在当前选用的用户角色卡下 | | `protagonist`(技能) | **主角设定** | `设计.主角设定[#短码]`;与开场白同短码即绑定该开局。挂在当前选用的用户角色卡下 |
| `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 | | `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 |
| `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 | | `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 |
+455 -8
View File
@@ -8,6 +8,7 @@
"name": "writing-agent", "name": "writing-agent",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"msedge-tts": "^2.0.9",
"yaml": "^2.9.0" "yaml": "^2.9.0"
}, },
"devDependencies": { "devDependencies": {
@@ -1046,6 +1047,18 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1056,6 +1069,44 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/boolean": { "node_modules/boolean": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -1065,6 +1116,30 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-crc32": { "node_modules/buffer-crc32": {
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -1114,6 +1189,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1154,11 +1242,22 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -1259,6 +1358,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-node": { "node_modules/detect-node": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -1267,6 +1375,20 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron": { "node_modules/electron": {
"version": "35.7.5", "version": "35.7.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
@@ -1310,9 +1432,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -1321,9 +1441,7 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -1335,6 +1453,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-error": { "node_modules/es6-error": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -1450,6 +1595,42 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -1480,6 +1661,52 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": { "node_modules/get-stream": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -1551,9 +1778,7 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -1608,6 +1833,45 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -1629,6 +1893,54 @@
"node": ">=10.19.0" "node": ">=10.19.0"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -1705,6 +2017,36 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -1719,9 +2061,25 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/msedge-tts": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/msedge-tts/-/msedge-tts-2.0.9.tgz",
"integrity": "sha512-Qw9pphrz/GTGee+XbXrmwXkDWsR6BVdKyDJKDZBC1RnmBMrZJ8VfMTC44j0v9dIptmLMkmLOuBtQX6LHuodcDw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"axios": "^1.11.0",
"buffer": "^6.0.3",
"isomorphic-ws": "^5.0.0",
"stream-browserify": "^3.0.0",
"ws": "^8.21.3"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1855,6 +2213,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -1879,6 +2246,20 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve-alpn": { "node_modules/resolve-alpn": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
@@ -1963,6 +2344,26 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2037,6 +2438,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/stream-browserify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
"integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.4",
"readable-stream": "^3.5.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/sumchecker": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -2158,6 +2578,12 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2761,6 +3187,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+4 -1
View File
@@ -13,7 +13,9 @@
"phase-script": "tsx src/cli/phase-demo.ts --script --auto", "phase-script": "tsx src/cli/phase-demo.ts --script --auto",
"web": "tsx src/server/web-server.ts", "web": "tsx src/server/web-server.ts",
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts", "web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
"electron": "electron ." "electron": "electron .",
"libraries:restore": "tsx src/cli/restore-library-seeds.ts",
"libraries:export": "tsx src/cli/restore-library-seeds.ts --export"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
@@ -23,6 +25,7 @@
"vitest": "^2.1.9" "vitest": "^2.1.9"
}, },
"dependencies": { "dependencies": {
"msedge-tts": "^2.0.9",
"yaml": "^2.9.0" "yaml": "^2.9.0"
} }
} }
+18
View File
@@ -0,0 +1,18 @@
# 库种子(git 备份)
本目录保存偏好库、文风库的可提交种子,便于数据丢失后恢复。
- `preferences.json` / `style-packs.json`:与用户目录中同名文件同形(`version: 1` + `entries`)。
- 运行时库仍在用户数据目录;本目录只作仓库备份与恢复源。
恢复(合并写入,不覆盖已有同 id 正文):
```bash
npx tsx src/cli/restore-library-seeds.ts
```
导出当前用户库到本目录(覆盖种子文件,便于更新 git 备份):
```bash
npx tsx src/cli/restore-library-seeds.ts --export
```
+19
View File
@@ -0,0 +1,19 @@
{
"version": 1,
"entries": [
{
"id": "starter-no-user-ntr-victim",
"content": "用户代入角色不做被 NTR 的一方(恋人/伴侣与第三者发生性或背叛性感情,用户角色受害或被欺瞒)。用户角色 NTR 别人不受此限。",
"status": "active",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
{
"id": "starter-default-male-hetero-yuri-ok",
"content": "禁止对男同性恋内容的直接描写,减少或忽略间接描写。默认用户角色为男性,情爱与性吸引朝向女性;女同性恋内容可写。",
"status": "active",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"entries": []
}
@@ -1,9 +1,5 @@
# 美学纲领与交互范式 # 美学纲领与交互范式
> **技能标准形范例**(其它中段技能对齐本结构)。
> 程序只切割下方 **fence 块**(`meta` / `opening` / `task` / …);`##` 标题仅供人读。
> 写法:`docs/world-simulator-modules.md`;产物公共头:`docs/context-fragment-design.md`。
## meta ## meta
```meta ```meta
@@ -11,81 +7,74 @@ name: 美学纲领与交互范式
id: aesthetics-interaction id: aesthetics-interaction
artifact: 设计.美学纲领与交互范式 artifact: 设计.美学纲领与交互范式
declaration: > declaration: >
钉清用户会在意的体验承诺:站位、反复感到什么、轮转何时停。询问相近故合为一步。 提取用户最想反复获得的核心体验与让它成立的承重内核,写成给后续能力读的美学纲领;
同时钉清交互范式:人称视角、模型可代写什么、用户保留什么、后果由谁裁定、何时停笔。
when: | when: |
还不能回答这三件:代入还是旁观?最想反复感到什么?轮转到哪必须停等用户? 用户要的是叙事、角色扮演或持续互动,但还不能明确说出:最想反复获得什么体验、自己站在什么位置、轮转到哪必须停等用户。
无上游,通常作第一步。 用户主动改变核心体验、代入站位或关键权限时也应重写。
when_not: | when_not: |
这三件已有等价定稿、用户未要求重谈 → 不重复排入。 本局不依赖体验契约的纯转换、资料整理或机械格式任务,可不使用。
只改文风 → 「叙事指南」(工序编排:「叙事指南与故事推进」);只改推进或输入用法 → 「故事推进」(工序编排同上);只改每轮看什么 → 「回复呈现」;只改排版/壳 → 「正文组成」。 只改遣词和具体写法 → 叙事指南;只改推进与输入用法 → 故事推进;只改舞台详情或硬法则 → 对应能力。
只改舞台、规则、真值、槽位 → 对应下游技能。与「给玩家什么体验」无关 → 不排。
boundary: | boundary: |
本技能:体验是什么、怎么发生在用户身上、轮转怎么停。产出供后续编排阅读的开头节点。 本能力只交三块:美学纲领(体验是什么、怎样发生、呈现尺度)、背景与规则的承重内核、交互范式(视角、描写权限、后果控制、停笔)。
叙事指南:把感觉落成遣词/笔墨/禁忌(对话落盘)——本步只钉感觉与轮转。工序编排二者并入「叙事指南与故事推进」。 背景与规则只提取承重内核,不扩写百科;舞台细节归舞台骨架,可执行硬法则归实现机制;具体遣词句式归叙事指南。
故事推进:怎么往前、输入用法(对话落盘)——本步只钉感觉与轮转。
舞台骨架 / 具体实例 / 生成规则:舞台与可执行规则——本步不定。
实现机制 / 变量* / 回复呈现 / 正文组成 / 游玩拓扑 / 上下文投影排序 / 细化终稿:落到可运行结构——本步不定。
feeds: design_only feeds: design_only
``` ```
## opening ## opening
```opening ```opening
先用几句话抓住你要的「变造世界」和体验核心(想到什么写什么,不必整齐): 先用几句话抓住你要的世界和体验核心(想到什么写什么,不必整齐):
1. 原型世界是什么?变在哪里? 1. 原型世界是什么?变在哪里?
例:丧尸世界,但只有我不会被感染;现代都市,但灵异是真的;也可以只说「现代都市」,变点稍后补。 例:丧尸世界,但只有我不会被感染;现代都市,但灵异是真的;也可以只说「现代都市」,变点稍后补。
2. 你代入吗? 2. 你代入吗?希望自己站在什么位置?
· 完全代入(我就是那个人,TA 的遭遇≈我的遭遇) · 完全代入(我就是那个人,TA 的遭遇≈我的遭遇)
· 部分代入 / 跟着体验(有距离) · 部分代入 / 跟着体验(有距离)
· 操控或旁观(角色是棋子/观察对象) · 操控或旁观(角色是棋子/观察对象)
· 其它(请写明) · 其它(请写明)
3. 最想反复感受到的是什么? 3. 你最想在这局里反复获得什么体验?
一句话即可——权力、求生紧张、关系拉扯、解谜、荣耀、禁忌刺激……用你自己的词。 一句话即可——权力、求生紧张、关系拉扯、解谜、荣耀、禁忌刺激……用你自己的词。
``` ```
## task ## task
```task ```task
与用户一起钉清一份「体验契约」:给玩家什么感受、如何参与、正文如何呈现、何时等待用户。 从用户材料中提取一份体验契约,写入 `设计.美学纲领与交互范式`。它是后续所有能力取舍的依据,也会挂给主世界层与转述层直接阅读。产物回答三件事:
产物必须是 **context-fragment.v1** JSON,写入 tag「设计.美学纲领与交互范式」。 1. 美学纲领:用户最想反复获得什么体验,它通过什么关系、处境、感官或选择反复发生,呈现时的氛围与尺度。
业务字段全部放在「正文」内(三柱为**对象**,不要写成一段散文);须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。 2. 背景与规则:删掉就会让这份体验不成立的最小承重前提,以及它如何约束人物、选择与后果。
由运行时按模型能力做 schema/tool 投递,不要再口头包一层 outputs 字符串。 3. 交互范式:用户与所扮演角色的关系、人称与视角、模型对 user 每类行为能代写到哪一步、世界后果由谁裁定、何时必须停笔等用户。
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。 保留用户自己的重点、用词和尺度。材料不足时少写或写「未定」,把真正影响体验或权限的缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。黑板「用户.需求」里已有的句子当作补充材料采纳,不代替本步引导作答。
``` ```
## principles ## principles
```principles ```principles
1. 核心是用户想要的体验;其余询问都是为了碰到这个核心。 1. 核心是用户想要的体验;其余字段都是为了让它稳定地反复发生。完善设定时回头核对原本的核心体验,不让它被设定冲淡。
2. 信息不足时宁可少写,不要强行填充。 2. 核心体验不是题材、剧情简介或情绪标签:写用户如何参与、体验怎样反复发生。同一设定不同用户要的可以完全不同(角斗士:荣耀 / 自由 / 血与酒),找到这个人要的那一个,而不是补全世界观。
3. 做好随时修改的准备;探测时大胆,用户可以说不。 3. 变造世界先抓「原型 + 变在哪里」;核心体验往往来自变点。背景与规则只写承重内核:关键关系、社会前提、世界规则或作用机制。
4. 完善设定时不要忘掉原本的核心体验。 4. 用户常说不清想要什么:展示优于提问,大胆优于保守;用两种站位或两种感觉的短场景让用户选或改;负面信号同样有效。用户可以说不,随时准备整份改写。
5. 用户常说不清想要什么 → 展示优于提问、大胆优于保守、去道德化/常态化、具体化;负面信号同样有效。 5. 用户要的内容去道德化、常态化处理:按用户给的尺度写进「边界与尺度」,写清服务体验的用法与真正的禁忌,不替用户加或减尺度。
6. 变造世界:先抓「原型 + 变在哪里」;核心体验往往来自变种,不要写成设定百科。 6. 交互范式写可执行权限,不写口号。描写权限按行为类别逐项写:哪些模型可直接代写、哪些只写到起势就停、哪些必须留给用户;「用户输入后」写模型对一句输入是只确认、补细节还是连带推出后果。
7. 同一设定不同用户要的东西可以完全不同(角斗士:荣耀 / 自由 / 血与酒)——找到核心与禁忌,不是补全世界观。 7. 交互范式要保护美学纲领:权限、视角和停笔点的设置要让核心体验更容易发生,两者矛盾时以美学纲领为准改范式。
8. 本步是上下文片段,不是执行单元清单;禁止输出 Worker 列表或最终插入 order。 8. 本步不扩写世界百科,不制定完整硬机制,不细化遣词句式,不决定故事推进方法,不输出执行单元列表或插入 order。
9. 用户改口导致体验内核改变时整份重写,不在旧契约后追加互相冲突的补丁。
``` ```
## probe ## probe
```probe ```probe
本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。 本步最先要钉清三件:站位(代入还是旁观、镜头看哪、爽从哪来)、核心感觉(反复感受到什么)、轮转停点(何时等用户)。三件写进 `体验锚定`;开场白写入前程序会检查它们。
用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉。已答清的不重问。 用户首句、默认问题首答或黑板「用户.需求」里已有的直接采纳,已答清的不重问。
缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。 缺其中一件:回复里只问这一件,给 2~3 个带短场景的建议选项,不问开放式「你还想要什么」。三件已清:再挑一个最影响体验的薄弱点(视角、某类描写权限、尺度)给选项;都够用就不再追问。
用户讲人物、舞台、规则:只抽取能改这三件的一句写入正文,其余写入「开放问题」,本步不追问细节。 用户讲到人物、舞台、规则细节:只抽取能改变体验、站位或权限的那一句写进正文,其余留给对应能力,本步不追问细节。
用两种站位或两种感觉的短场景让用户选或改,不追问开放式「你还想要什么」。
``` ```
## output ## output
@@ -94,136 +83,111 @@ feeds: design_only
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "美学纲领与交互范式", "技能": "美学纲领与交互范式",
"brief": "一句话点题:用户最想反复感受到什么", "brief": "一句话点出用户最想反复获得的核心体验",
"体验锚定": {
"站位": "用户与所扮演角色/作品的关系:代入、部分代入、旁观或操控",
"核心感觉": "最想反复获得的体验",
"轮转停点": "何时必须停笔等待用户"
},
"mount": ["world-simulator", "narrator"], "mount": ["world-simulator", "narrator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"设定逻辑": { "美学纲领": {
"变造": { "原型": "…", "变点": "…" }, "体验内核": "给后续能力和游玩层读的纲领正文,可多段:用户要反复感到什么、它靠什么关系或处境成立、从哪一刻开始、怎样被反复兑现",
"参与方式": { "发生方式": "体验通过哪些关系、处境、感官或选择反复发生",
"用户与user关系": { "结论": "…", "完备度": "…%", "依据": "…" }, "角色人文": "关键人物关系中的权力、尊严、亲疏或人性张力;无则省略",
"焦点位置": { "结论": "…", "完备度": "…%", "依据": "…" }, "呈现要点": {
"满足来源": { "结论": "…", "完备度": "…%", "依据": "…" } "氛围基调": "整体质感与情绪温度",
}, "描写焦点": "注意力放在哪里:哪些瞬间、反应、细节值得放慢写;具体遣词交给叙事指南",
"内容维度": { "边界与尺度": "色情/暴力/血腥等按用户给的尺度:服务体验的用法与真正的禁忌",
"核心感觉": { "完备度": "…%", "已知": "…", "待探": "无|…" }, "其它要点": ["反差、日常化、节奏偏好等短条;无则省略"]
"人物与关系": { "完备度": "…%", "已知": "…", "待探": "无|…" },
"身体与感官": { "完备度": "…%", "已知": "…", "待探": "无|…" },
"背景与规则": { "完备度": "…%", "已知": "…", "待探": "无|…" },
"意义与主题": { "完备度": "…%", "已知": "…", "待探": "无|…" }
},
"特殊概念": "无|短列",
"区域化诊断": {
"是否需要": false,
"判断依据": "…",
"区域识别": [],
"边界类型": "无|空间|阶段|…",
"转换机制": "…"
},
"复杂性识别": {
"内容复杂性": "简单|可结构化复杂|…",
"体验者位置复杂性": "简单|…",
"判断依据": "…"
},
"完备度判断": {
"核心状态": "核心明确|仍混沌|…",
"判断依据": "…",
"薄弱点": "…"
} }
}, },
"背景与规则": {
"原型与变点": "熟悉的原型世界,以及变在哪里;无变造则省略",
"核心内核": "让体验成立的最小承重前提",
"作用方式": "它如何约束人物、选择与后果,怎样持续制造该体验",
"非必要设定": "明确本步不展开的装饰信息;无则省略"
},
"交互范式": { "交互范式": {
"前置配置": { "前置配置": {
"用户与user关系": "…", "用户与user关系": "完全代入 / 部分代入 / 旁观 / 操控,及其含义",
"人称": "第二人称称「你」|…", "人称": "如:第二人称称「你」",
"元叙事": "不得跳出故事直接问卷;决策用情节呈现|…" "元叙事": "如:不跳出故事直接问卷,决策用情节呈现"
}, },
"叙事视角": { "叙事视角": {
"主要视角": "…", "主要视角": "镜头跟谁、看什么",
"视角切换": "禁止|例外…", "视角切换": "禁止,或允许的例外",
"可写内心": "仅 user|…", "可写内心": "可以写谁的内心;如仅 user 或仅 NPC 外显",
"不呈现的信息": "…" "不呈现的信息": "user 不该知道、正文不能透露的内容"
}, },
"描写权限": { "描写权限": {
"思考与抉择": "何时可写 / 何时必须停等用户", "思考与抉择": "关键决定是否只能由用户做,模型写到哪里停",
"言语": "…", "言语": "模型能否替 user 说话、说到什么程度",
"主动行动": "…", "主动行动": "模型能否替 user 发起行动",
"情绪与身体反应": "…", "情绪与身体反应": "模型可补写的生理与情绪反应",
"习惯与既有技能": "…", "习惯与既有技能": "模型可按既有设定自动执行的部分",
"外在状态": "…", "外在状态": "外貌、伤势、衣着等可否直接描写",
"用户输入后": "只确认 / 补细节 / 连带后果…" "用户输入后": "对用户一句输入:只确认 / 补细节 / 连带推出后果"
}, },
"后果与叙事控制": { "后果与叙事控制": {
"行为后果判定": "…", "行为后果判定": "谁裁定 user 行为的成败与代价",
"世界对user的影响": "…", "世界对user的影响": "NPC 与世界如何主动作用于 user",
"时间与场景": "…", "时间与场景": "模型可否推进时间、切换场景,跨度多大",
"引入新内容": "…", "引入新内容": "模型可否主动引入新人物、事件、威胁",
"设计困境": "…" "设计困境": "是否主动给 user 制造两难;怎样的两难服务体验"
}, },
"区域差异": "无|按区域写权限差异(与设定逻辑.区域化诊断对齐)" "停笔时机": "必须等用户输入的明确时机"
},
"美学纲领": {
"体验内核": "给用户读的纲领正文(可多段;写清要反复感到什么、权力/关系如何成立)",
"呈现要点": {
"氛围基调": "…",
"边界与尺度": "色情/暴力/血腥等:服务体验的用法与禁忌",
"其它要点": ["权力日常化|反差|…"]
}
} }
}, }
"自评": {
"维度": [
{ "名": "交互范式", "分数": 8, "说明": "权限边界是否锁住核心体验" },
{ "名": "美学纲领", "分数": 8, "说明": "体验内核是否点题、有质感" },
{ "名": "整体协调", "分数": 8, "说明": "范式是否在保护纲领" }
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为贴近你要的质感,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A(可带短场景)", "选项B", "其它(请写明)"],
"示例": "可选:一句示范句或短钩子"
}
]
},
"开放问题": []
} }
``` ```
硬规则(公共三段 + 本技正文): 硬规则:
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 输出合法 JSON。`体验锚定` 三项必填短句:开场白写入前程序按它判定体验是否已锚定,缺项会拒绝写入开场白。
2. **公共三段**:`正文`=产物主体;`自评`=打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。 2. `正文` 只含 `美学纲领`、`背景与规则`、`交互范式` 三块,每块是对象,不写成一段散文。未知字段写「未定」或省略,不编造百科。
3. **正文深度**:三柱(设定逻辑 / 交互范式 / 美学纲领)为对象。柱下到「条目」为止:条目是短字段、string[],或诊断三元组 `{结论, 依据, 完备度}` / `{已知, 待探, 完备度}`。诊断里的结论/依据/已知/待探必须是字符串。禁止把整柱写成一段 text;禁止在诊断或短字段里再套对象。 3. 不在产物里写 `自评`、`追问`、`开放问题`、完备度、评分依据或用户回复;缺口交给 `self_score.gap`。
4. **本技三柱**:`设定逻辑`(变造 + 参与方式 + 内容维度 + 区域/复杂性/完备度)+ `交互范式`(前置配置/视角/权限/后果)+ `美学纲领`(体验内核 + 呈现要点)。未知省略或「未定」,禁止编造百科。 4. `mount` 固定包含 `world-simulator` 与 `narrator`;不写数字 `order`,不输出执行单元列表。
5. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
6. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。 ## score
7. summary:`美学纲领与交互范式 · {brief 缩略}`。
```score
美学纲领:
- 5:准确提取用户最想反复获得的体验,写清它怎样反复发生、从哪一刻成立,呈现焦点与尺度贴合用户原话;后续能力可据此取舍。
- 3:大方向正确,但仍偏题材或情绪概括,或体验如何反复发生、尺度如何把握尚不明确。
- 1:误读用户重点,只有剧情简介、设定标签或泛化氛围。
交互范式:
- 5:站位、人称视角、逐类描写权限、后果裁定与停笔时机都可执行,且在保护核心体验。
- 3:有站位与停笔,但描写权限笼统,或某类行为(言语、行动、反应)谁来写仍不清。
- 1:只有口号式描述,或权限设置与核心体验矛盾。
背景与规则:
- 5:找到让核心体验成立的最小内核——关键关系、背景前提或作用规则,并说明它怎样持续制造该体验。
- 3:找到相关前提,但内核过宽、因果不清,或混有较多装饰设定。
- 1:没找到承重内核;只有百科堆砌,或规则与核心体验无关。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问 三段外壳? - [ ] `体验锚定` 三项(站位、核心感觉、轮转停点)都已写出?
- [ ] 正文三柱是对象(非一段散文)?设定逻辑里诊断有结论/依据/完备度?自评有交互/美学/协调维度? - [ ] 美学纲领写的是用户反复获得什么、怎样发生,而非题材或剧情简介?体验内核能直接给游玩层读?
- [ ] 未写 order、未输出执行单元列表? - [ ] 背景与规则是删掉就会破坏体验的最小内核?
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定? - [ ] 交互范式逐类写清描写权限、后果裁定和停笔时机,并且在保护美学纲领?
- [ ] 交互范式权限与美学纲领互相保护、不矛盾? - [ ] 未扩写舞台百科、硬机制、具体文风或推进方案?
- [ ] 追问只打真正薄弱点,带建议选项?已答过则题目为空、准备验收? - [ ] 产物正文不含评分、诊断、追问和开放问题?
- [ ] 有没有为「完整」强行填充百科或偏离用户核心?
``` ```
## examples ## examples
```examples ```examples
好: 好:
- brief 点题「垄断生存资源的绝对掌控感」;正文三柱为对象(设定逻辑含诊断三元组、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。 - brief 点题「垄断生存资源的绝对掌控感」;美学纲领的体验内核写清掌控感从哪一刻成立、靠什么反复兑现,边界与尺度照用户原话;交互范式写明 user 的抉择只由用户做、模型可补身体反应与习惯动作、NPC 的屈服与反抗由模型裁定、每次 NPC 给出回应后停笔。
- 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳,也不要把三柱收成一段 text。
坏: 坏:
- 只有散文纲领,无 JSON / 无自评 / 无追问结构 - 只有一段散文纲领,三块没有拆成对象。
- 三柱是 sections[].text 一段话,验收卡无法切开诊断与契约 - 百科式背景堆砌,看不出体验靠哪条前提成立。
- 百科式背景堆砌,无满足来源与权限边界 - 交互范式只写「尊重玩家选择」,没说言语、行动、反应各由谁写。
- 输出 workers[] 或 order 表 - 在正文里写评分、追问或「待确认」清单。
- 追问空洞「你想要什么体验?」无选项
``` ```
@@ -4,6 +4,8 @@
# name = 固定中文名(流程 JSON 的 name;同能力可多次时靠 step.id 区分) # name = 固定中文名(流程 JSON 的 name;同能力可多次时靠 step.id 区分)
# declaration = 给人看/兜底的短声明(规划选型以 prompt.md ```meta 为准并覆盖本表) # declaration = 给人看/兜底的短声明(规划选型以 prompt.md ```meta 为准并覆盖本表)
# artifact = 执行期产物 tag(程序映射;不写进流程 JSON) # artifact = 执行期产物 tag(程序映射;不写进流程 JSON)
# layer = 默认产物层(intermediate / final)
# mount = 默认游玩挂载目标;具体字段仍由投影器裁剪
# repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。 # repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。
# 回头修改已完成节点不是 repeatable:用户在图上点该节点重进,不要排 mode=revise。 # 回头修改已完成节点不是 repeatable:用户在图上点该节点重进,不要排 mode=revise。
# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿 # closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿
@@ -29,9 +31,21 @@
# 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。 # 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。
modules: modules:
# —— 本局决策(仅对话落盘) ——
- id: creation-plan
name: 本局创作方案
intake: dictate
layer: intermediate
declaration: >-
先抽出用户要的核心体验,再主动找出有帮助的内容能力并分档,
形成本局专用创作方法;指导后续取舍,但不规定固定流程
artifact: 设计.本局创作方案
# —— 体验契约 —— # —— 体验契约 ——
- id: aesthetics-interaction - id: aesthetics-interaction
name: 美学纲领与交互范式 name: 美学纲领与交互范式
layer: final
mount: [world-simulator, narrator]
declaration: >- declaration: >-
钉清用户会在意的体验承诺:站位、反复感到什么、轮转何时停。 钉清用户会在意的体验承诺:站位、反复感到什么、轮转何时停。
询问相近故合为一步。 询问相近故合为一步。
@@ -40,63 +54,64 @@ modules:
- id: protagonist - id: protagonist
name: 主角设定 name: 主角设定
repeatable: true repeatable: true
layer: final
mount: [world-simulator]
declaration: >- declaration: >-
钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等)。 钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等);
代入有名片时排本步;可 设计.主角设定#短码 与开场白同槽绑定;禁止把名字/简介写进开场白正文。 自动写入时加性格。代入有名片时排本步,写各开场共通的部分;各开场不同的玩家角色写在开场白的 meta.用户角色,由程序同步为同短码主角设定;禁止把名字/简介写进开场白正文。
artifact: 设计.主角设定 artifact: 设计.主角设定
- id: user-requirements
name: 用户需求
libraries: [preferences]
declaration: >-
把跨局偏好库里对本局生效的硬约束(禁区/纠偏/默认取向)落成可执行条目;
不重做文风问卷,也不钉体验站位
artifact: 设计.用户需求
# —— 世界与内容 —— # —— 世界与内容 ——
- id: world-blueprint - id: world-blueprint
name: 舞台骨架 name: 舞台骨架
layer: final
mount: [world-simulator]
declaration: >- declaration: >-
本局舞台备案:划出玩家实际游玩会换区活动的主要区域并写细; 保存会在游玩中反复影响世界反馈的外部舞台:
尺度与详细度随体验变。代入后几乎不移动、只在一处待着则不排 熟悉基底、必要变造、核心运转和主要活动场所
artifact: 设计.舞台骨架 artifact: 设计.舞台骨架
- id: generation-rules - id: generation-rules
name: 生成规则 name: 生成规则
kind: prior-artifact kind: prior-artifact
repeatable: true repeatable: true
layer: final
mount: [world-simulator, auditor]
declaration: >- declaration: >-
要精准格式,或本局原创导致知识中无可参考对象、或难以在无指导情况下生成足够可信内容且故事里还要生成时:写出该对象族怎么生成; 为需要统一生成的一类对象写出合同:名称、用途、字段、池或提示、填写工作流;
创意字段用方向池,不堆枚举词;不填最终实例;可按对象反复 不填最终实例;可按对象反复。两份以上,或一份但字段多、需要先审工作流时使用
artifact: 设计.生成规则 artifact: 设计.生成规则
params: params:
- key: target - key: target
label: 生成对象 label: 生成对象
hint: 规划字段;编排器已知写哪一类就填。空则步内钉 hint: 规划字段;编排器已知写哪一类就填。空则步内钉
- key: rule_id - key: rule_id
label: 规则 id label: 规则名
hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants);改对象时禁止沿用旧 id hint: 中文短名,须与 target 对应(女租客→女租客);改对象时禁止沿用旧名
- key: lifecycle_intent - key: lifecycle_intent
label: 生命周期意图 label: 生命周期意图
hint: runtime_only | seed_only | seed_and_runtime;不明则步内钉 hint: 仅游玩期 | 只预生成 | 预生成并游玩期;不明则步内钉;只预生成=中间层,其余=最终层
- id: concrete-instances - id: concrete-instances
name: 具体实例 name: 具体实例
kind: prior-artifact kind: prior-artifact
repeatable: true repeatable: true
layer: final
mount: [world-simulator]
declaration: >- declaration: >-
已有预生成规则时:按该规则原样交出 records;不设计、不改合同;可按规则/批次反复 按已有生成规则填写一条记录,单独落成一份产物;
没有规则时只留下用户点名的一条字段很少的具名内容
artifact: 设计.具体实例 artifact: 设计.具体实例
params: params:
- key: rule_id - key: rule_id
label: 调用规则 label: 调用规则
hint: 规划字段;须来自已验收生成规则中 seed_only/seed_and_runtime 的 rule_id。空则步内钉 hint: 规划字段;填已有生成规则的中文规则名。无规则的单条极简路径可空
- key: batch_goal - key: batch_goal
label: 批次目标 label: 这一条
hint: 本批要锚定什么(如开局重要NPC) hint: 本条要锚定什么(如开局某位重要人物)
- key: count - key: count
label: 数量 label: 数量
hint: 同批时本批条数;规则「一次生成」为单条时忽略(本步只交 1 条) hint: 每条实例一份产物;一次写入多条记录时程序按条拆开
- id: narrative - id: narrative
name: 叙事指南与故事推进 name: 叙事指南与故事推进
@@ -111,99 +126,155 @@ modules:
name: 叙事指南 name: 叙事指南
intake: dictate intake: dictate
libraries: [style-packs] libraries: [style-packs]
layer: final
mount: [narrator, world-simulator]
declaration: >- declaration: >-
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与写法档; 从文风库选取本局写法(默认 1~2,确需可到 3),
不重做体验问卷,也不钉故事怎么往前 落成风格推荐与要求、笔墨落点、少量禁忌与可选写法档;不钉推进
artifact: 设计.叙事指南 artifact: 设计.叙事指南
- id: story-progression - id: story-progression
name: 故事推进 name: 故事推进
intake: dictate intake: dictate
libraries: [preferences]
layer: final
mount: [world-simulator, narrator]
declaration: >- declaration: >-
美学已有方向时:钉故事怎么往前、用户输入用法(大纲扩写或扮演停笔); 从偏好库选取约束,只选定一种用户输入用法,
不重做文风问卷 并写清每一轮里扩写(演本轮材料)与续写(材料后往下写)的定义、比例与 OOC 处理;不写遣词
artifact: 设计.故事推进 artifact: 设计.故事推进
# —— 机制与数据 —— # —— 机制与数据 ——
- id: mechanism - id: mechanism
name: 实现机制 name: 实现机制
layer: final
mount: [world-simulator]
declaration: >- declaration: >-
整体感觉已钉,故事里有必须当作既成事实的变造法则时: 保存世界中稳定成立的因果法则,供主世界层推演事件后果、
点出它们如何约束故事,不解释为何存在 剧情变化和人物反应;一次可写多条共同作用的机制
artifact: 设计.实现机制 artifact: 设计.实现机制
- id: topology - id: topology
name: 拓扑图谱 name: 拓扑图谱
intake: dictate
layer: final
mount: [world-simulator, auditor]
repeatable: true
declaration: >- declaration: >-
必须生成、但不适合走「生成规则→具体实例」时:用拓扑结构写出来 为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则;
(升级路径、地图、人物关系等) 一份产物一张图,可按地图、关系链、升级树等对象反复
artifact: 设计.拓扑图谱 artifact: 设计.拓扑图谱
- id: variable-design - id: variable-design
name: 变量设计与更新规则 name: 变量设计与更新规则
intake: dictate
layer: final
mount: [world-simulator, auditor]
declaration: >- declaration: >-
体验必须跨轮记住少数状态时:钉真值、谁可写、如何变、维护语句, 定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射,
以及按值换文案的映射索引 供程序选择简单状态工具或复杂维护副 Worker
artifact: 设计.变量设计与更新规则 artifact: 设计.变量设计与更新规则
- id: variable-context - id: variable-context
name: 变量控制上下文 name: 信息可见范围
declaration: >- declaration: >-
已有真值或映射时:钉谁看见什么(主世界层/转述/旁观)、未解锁不注入; 程序根据变量、拓扑和秘密声明整理谁能看到什么、何时可以看到;
无信息差则可不排 无特殊信息差时直接采用默认范围
artifact: 设计.变量控制上下文 artifact: 设计.变量控制上下文
# —— 呈现 —— # —— 呈现 ——
- id: status-bar - id: status-bar
name: 回复呈现 name: 回复呈现
intake: dictate
layer: final
mount: [world-simulator, narrator]
declaration: >- declaration: >-
几乎每局都要:钉每轮终稿的必选/可选区域,以及各区域的简单内容规则 定义普通楼层每轮实际启用的语义区域、出现条件、权威来源与内容要求;
(字数、段落、选项怎么组成等);不排版 可含隐藏更新语义区,不决定前端排版和渲染
artifact: 设计.监控栏 artifact: 设计.监控栏
- id: zero-layer-status
name: 0层回复呈现
intake: dictate
layer: final
mount: [world-simulator, narrator]
declaration: >-
定义持久 0 层卡实际启用的语义槽位、更新触发、更新方式与权威来源;
不决定 HTML 和排版
artifact: 设计.0层回复呈现
- id: random-range - id: random-range
name: 随机范围整理 name: 随机范围整理
intake: dictate
layer: final
mount: [world-simulator]
declaration: >- declaration: >-
上游已有检定/对抗/掷骰需求时:汇总全部要用的随机项与取值范围(含表达式), 把已定的检定和抽签收成「变量.当前」里的隐藏行,行名用「名称.随机数」
供程序每轮插入备用随机数;排在正文组成之前
artifact: 设计.随机范围整理 artifact: 设计.随机范围整理
- id: reply-format - id: reply-format
name: 正文组成 name: 正文组成
intake: dictate
layer: final
mount: [world-simulator, narrator]
declaration: >- declaration: >-
把「回复呈现」已钉的内容展示给用户:选壳、美化排版,可写 CSS/HTML/JS; 把普通楼层「回复呈现」的语义区域落成每条消息使用的前端模板:
排在回复呈现之后、开场白之前;有随机表则一并接到隐藏备用区 定义区域位置、HTML/CSS/JS、数据绑定与特殊渲染
artifact: 设计.正文组成 artifact: 设计.正文组成
- id: zero-layer-reply-format
name: 0层正文组成
intake: dictate
layer: final
mount: [world-simulator, narrator]
declaration: >-
把 0 层语义槽位落成跨轮持久前端,定义固定外壳、槽位位置、
Tab 与结构化 slot_updates 渲染合同
artifact: 设计.0层正文组成
# —— 开局(创作偏晚) —— # —— 开局(创作偏晚) ——
- id: opening-setup - id: opening-setup
name: 开场白与开场变量 name: 开场白与开场变量
closer: true closer: true
repeatable: true
intake: dictate
layer: final
mount: [world-simulator, narrator]
declaration: >- declaration: >-
创作终节点:写出 1~多条开场(每条 = meta 用户角色 + 正文;须含字面 @玩家)并钉同真相初值; 普通楼层创作终节点,且可增殖:每条开场各占一个节点(设计.开场白#场景短码)。
选定后程序落库、结束创作并保存定稿 默认 1 条;还要入口就再增殖。全部保存。游玩时左右切换,选中的一条作为 0 层
artifact: 设计.开场白与开场变量 artifact: 设计.开场白
- id: zero-layer-opening-setup
name: 0层开场白与初态
closer: true
repeatable: true
intake: dictate
layer: final
mount: [world-simulator, narrator]
declaration: >-
0 层创作终节点,且可增殖:每条开场各占一个节点(设计.0层开场白与初态#场景短码),
自带持久卡第一屏、槽位初值和同真相运行初态。游玩时左右切换,选中的一条作为 0 层
artifact: 设计.0层开场白与初态
# —— 收成 / 钉声明(共用;世界模拟器按需) —— # —— 收成 / 钉声明(共用;世界模拟器按需) ——
- id: worker-spec - id: worker-spec
name: 游玩拓扑 name: 游玩执行方式
repeatable: false repeatable: false
declaration: >- declaration: >-
不能吃默认槽时才勾选:主世界层必开;有变量/表才开旁观;转述默认开; 程序根据前面已经确认的写作、维护和秘密隔离需求,整理本局需要启用的固定执行单元
强秘密才开视角;写手则大纲+章节;禁止发明执行单元与机遇槽
artifact: 设计.worker规格 artifact: 设计.worker规格
- id: context-order - id: context-order
name: 上下文投影排序 name: 上下文排序
repeatable: false repeatable: false
auto: true auto: true
declaration: >- declaration: >-
收成前给已启用槽排扁平投影序(含对话.历史); 程序按变化频率与重要性排列各执行单元的上下文,并选择完整、摘要或字段投影
不发明槽、不重写长文、不演情节选择题
artifact: 设计.上下文投影排序 artifact: 设计.上下文投影排序
- id: refine - id: refine
name: 细化终稿 name: 运行配置组装
declaration: >- declaration: >-
按已勾选槽(或默认槽)与投影排序表收成运行规格;不发明新执行单元 程序把执行方式、上下文排序、变量表和输出合同组装成可直接开玩的运行配置
artifact: 设计.worker集 artifact: 设计.worker集
@@ -1,9 +1,5 @@
# 具体实例 # 具体实例
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 本步极简:严格按已验收的「生成规则」执行,交出实际产物。
## meta ## meta
```meta ```meta
@@ -11,72 +7,52 @@ name: 具体实例
id: concrete-instances id: concrete-instances
artifact: 设计.具体实例 artifact: 设计.具体实例
declaration: > declaration: >
已有预生成规则时:按该规则原样交出 records;不设计、不改合同;可按规则/批次反复 按已有生成规则填写一条记录,单独落成一份产物。没有规则时,只留下用户点名的一条字段很少的具名内容。
when: | when: |
「设计.生成规则」里已有 `seed_only` 或 `seed_and_runtime` 的规则,且现在需要实际名片/记录(开局 NPC、种子事件等)。 已有生成规则,要按该规则写入一条种子或开局记录时使用。每条记录单独一份产物。
还没有规则时,只在用户明确要求留下一条字段很少的具名内容时使用。
when_not: | when_not: |
尚无规则或规则不完整 → 先「生成规则」。想改格式/方法/池 → 回「生成规则」,本步不改合同。 还没有规则,而对象有两份以上,或字段较多、需要先审字段和填写工作流 → 先写生成规则。
规则是 `runtime_only` → 不排,游玩期再生成。 内容不重要、游玩时现写就够 → 不落本产物。
只要规则不要预写实例 → 不排。 一张有节点和连接的结构图 → 拓扑图谱。
规则写明创作期不预填、只在游玩期生成 → 本步不写记录。
boundary: | boundary: |
本技能只做一件事:按指定规则生成 records。规则说怎么写、什么格式、如何用池、填到多厚,就照做。 本能力只交一条记录。有规则则严格按字段、池或提示、填写工作流填写;无规则则只写用户给出的少量简单字段。
生成规则:合同制定方——本步不重论证、不修改。 不重议合同,不写世界百科,不改变量公式。一次写入多条时,程序按记录拆成多份产物。
feeds: gm feeds: gm
``` ```
## opening ## opening
```opening ```opening
先认定本批按哪条规则生成: 先说这条记录是谁、依哪条生成规则。没有规则、又只是用户点名的一条简单内容,直接写这一条。
- 若【本步对象】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
- 若规划为空,从已验收生成规则里选出一条预生成规则。
读规则「一次生成」:同批则本步一次交齐互有关系的多条;单条则本步只交 1 条。有批次目标可一句带过。
``` ```
## task ## task
```task ```task
你正在执行「具体实例」。产物必须是 **context-fragment.v1** JSON,写入「设计.具体实例」。 写出一条具体实例,写入 `设计.具体实例#中文名`。一份产物只含一条记录。用户一次给出多条时,每条各写一份;若交来的是记录数组,程序会按条拆开。
本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。 有生成规则时:认定规则的中文名,按规则的字段、池或提示、填写工作流生成这一条;用户已写明的专名与结果只扩写不改写,未写字段从已有材料往下补满。无规则且只是一条字段很少的具名内容时:直接写用户给出的字段。
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【本步对象】与【原型建议】;在本步钉「写什么」。 规则的一次生成为同批时:一次 insert 写齐本批,`记录` 写成数组,每项一条记录并带上规则里的姓名或名称字段,程序按条拆成多份。先定各条如何互指、对立、同场,再填各条字段。
执行顺序: 材料足够便直接成稿。缺的是合同本身时,先回到生成规则,不在这里补合同。产物正文不放评分、诊断、追问或开放问题。
1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。
2. 条数:先读规则「一次生成」。
- `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。
- `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。
`batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。
3. 完全按规则的「生成与描写」+「产物格式」+「池」+「分级表现」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
池的用法以规则 `池型` 为准:
- **方向**:以方向为主。粒度是词条/方向时,字段值可以就是方向名。粒度是句子时,按该方向写开(方向名可作标签,后面仍要达到地板)。方向说明里的专名不是选项库,不要按专名点菜。
- **元素**:从 `条目` 里抽取或必选,写入绑定字段。
有 **配方**:按配方从各组件取方向,拼进目标字段那一格。
有 **分级表现**:实例只填档位短值;不要把整段表象复制进 record,除非 schema 单独设了表象字段。叙事时按规则展开。
无池的描写字段按 `粒度`/`地板`/`表现`/`工作流` 写到够厚。
4. 按规则自检(含地板:句子级不得只用一个词交差);不合格内部重做。已有产物则追加批次(除非用户要求替换)。
5. 输出 JSON;追问通常 `[]`。
summary:`具体实例 · {对象} · {n}条`
``` ```
## principles ## principles
```principles ```principles
1. 只按规则来:方法、配方、表现、分级表现、格式、池型、数量、一次生成、约束、地板——规则有什么用什么;没有的不发明。 1. 有规则:字段名、取值方式、工作流阶段与依赖都以规则为准;不增删字段,不改池。
2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。 2. 池选取按合理度选择,不是程序抽签;提示约束按那句话的范围写开。
3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。方向以方向为主;说明里的专名不是选项。 3. 按填写工作流推进:先骨架后血肉;阶段依赖与自检能核对的,生成后要能过。
4. 句子级描写必须达到该字段 `地板`;词条/档位/数字可以短。句子级不要只丢一个词交差;方向名可以当选项或标签。 4. 一份产物一条记录。单条规则一次写一条;多条各自成稿,用中文名称区分。
5. 要改合同 → 回「生成规则」,本步不加字段、不改枚举、不改池型。 5. 同批规则一次写齐本批:先对齐关系,再填字段;各条里互指的专名与关系必须一致(甲记乙为仇敌,乙也记甲,且说的是同一件事)。有关系的人在同一次里写完,不拆到不同轮。
``` 6. 句子级字段写到规则 `地板` 规定的可感知程度;词条、档位、数字可以短。句子级字段只丢一个词不算填完。
7. 分级字段只填档位短值(如「金丹」);表象留在规则的分级表现里,不复制进每条记录。
## probe 8. 无规则只写用户点名的那一条,字段保持很少;不借机发明完整人设合同。
9. 用户已写明的专名、关系、结果保持原样,只做厚度扩写;未写字段从这些材料延伸。
```probe 10. 给人读的键和专名用中文。材料里的英文名音译成中文,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
通常不追问。仅当 contextual 数量算不出、或同批「关系」写了但本批对象对不齐时,问一句。
用户要 schema 外内容 → 提示回「生成规则」。
``` ```
## output ## output
@@ -85,63 +61,53 @@ summary:`具体实例 · {对象} · {n}条`
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "具体实例", "技能": "具体实例",
"brief": "rule_id + 本批条数", "brief": "这一条是谁、依哪条规则",
"mount": ["world-simulator"], "mount": ["world-simulator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"rule_id": "与本步认定一致(规划有则对齐)", "规则": "所依生成规则的中文名;无规则时省略",
"本批数量": 3, "名称": "这条记录的中文名",
"批次条件": ["规划 batch_goal 等;无则 []"], "记录": {
"batch_id": "英文 kebab-case", "字段名": "按规则字段或极简内容填写的值"
"records": [ }
{ }
"键来自规则产物格式 schema": "值"
}
],
"增量说明": "新建|追加|替换某 batch_id"
},
"自评": {
"维度": [
{
"名": "合规模",
"分数": 8,
"说明": "是否按方法/配方/表现/单层格式/池型(方向为主,元素=抽取)/分级表现/地板生成;句子级是否达到粒度"
}
],
"薄弱点": "一句话;无则空字符串"
},
"追问": {
"导语": "",
"题目": []
},
"开放问题": []
} }
``` ```
硬规则: 硬规则:
1. 合法 JSON;含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`。 1. 输出合法 JSON。`记录` 是单个对象;规则为同批时写成至少 2 项的对象数组,每项带姓名或名称。`名称` 用中文,并与这条记录的主名称一致。
2. 正文键固定:`rule_id` / `本批数量` / `批次条件` / `batch_id` / `records` / `增量说明`。 2. 有规则时 `记录` 的键必须来自该规则字段,不得增删。
3. `records` 始终为数组;每条必须是规则约定的单层 JSON;`本批数量 === records.length`。规则「一次生成」为单条时长度为 1;为同批时 ≥2 且关系对齐。 3. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
4. 自评仅「合规模」一维。禁止改规则、写 `order`、输出执行单元列表。 4. 字段名和专名用中文;英文专名音译为中文。
5. summary:`具体实例 · {对象} · {n}条`。 5. 不在产物正文写自评、追问、开放问题或合同草稿。
6. `mount` 固定为 `["world-simulator"]`。
## score
```score
工作流符合度:
- 5:严格按规则填写工作流推进;字段、池选取、依赖与自检都落实;句子级字段达到地板;同批各条互指一致;无规则路径则字段简单够用且不越权发明合同。
- 3:大体按工作流,但有一处阶段跳跃、依赖未咬合,或某句子级字段明显低于地板。
- 1:忽略工作流;乱增字段;句子级只填一个词;同批互指对不上;或把用户已定内容改掉。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行? - [ ] 有规则则按字段与填写工作流执行;无规则则只是一条字段很少的具名内容?
- [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组? - [ ] 一份产物只有一条记录,名称是中文?
- [ ] records 单层、键值符合产物格式与全部约束?句子级是否达到地板?有配方是否组装进目标字段?方向是否以方向为主(专名不当选项库)? - [ ] 同批规则已一次写齐,各条互指的专名与关系一致?
- [ ] 自评为合规模?未改合同? - [ ] 句子级字段达到地板?分级字段只填档位短值?
- [ ] 记录键值合法,用户已定专名未改写,英文专名已音译?
- [ ] 未改合同?
- [ ] 产物正文不含评分、诊断、追问?
``` ```
## examples ## examples
```examples ```examples
好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。 好:规则一次生成=同批、关系=开局仇敌二人 → 一次写齐 2 条,甲的仇敌字段写乙与夺位之事,乙的写甲与同一件事,两条对得上。
好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。 坏:记录里加了规则没有的字段(规则无「性别」却写进去)。
好:方向「冷焰」作标签,能力描述按该方向写开到可感知;或粒度=方向时字段值就是「冷焰」。 坏:规则要求句子级的「气质」只填「阴郁」,没达到地板。
好:阶层字段填「金丹」,表象留在规则的分级表现里,不把整段对照复制进每条 record。 坏:同批的仇敌二人分两轮各写各的,互指的名字和事由对不上。
坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。
坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。
坏:句子级只填「阴郁」;或按方向说明里那个专名点菜交差。
``` ```
@@ -1,4 +1,4 @@
# 上下文投影排序 # 上下文排序
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> **先抄程序钉死的执行单元名册,再给每个已启用者分配上下文与顺序。** > **先抄程序钉死的执行单元名册,再给每个已启用者分配上下文与顺序。**
@@ -8,25 +8,18 @@
## meta ## meta
```meta ```meta
name: 上下文投影排序 name: 上下文排序
id: context-order id: context-order
artifact: 设计.上下文投影排序 artifact: 设计.上下文投影排序
declaration: > declaration: >
收成前给已启用槽排扁平投影序(含对话.历史);不发明槽、不重写长文、不演情节选择题 程序按变化频率与重要性排列各执行单元的上下文,并选择完整、摘要或字段投影
when: | when: |
游玩槽已确认(或可按默认),且主要上下文块已有可摘要产物,需要决定各执行单元看见什么、以何顺序插入。 落档或开玩时自动执行;执行方式与可用产物已经确定。
本步为〔程序步〕:上下文已多、即将收成时排入。
when_not: | when_not: |
体验/站位仍混沌 → 先「美学纲领与交互范式」。 没有任何创作产物时仍生成最小顺序,不向用户提问。
槽位未定且用户拒绝默认 → 先「游玩拓扑」。
上游上下文仍大量空洞、无法判断稳/变 → 先补缺口,勿空排。
只需勾选槽 → 「游玩拓扑」;上下文很少、只需合并终稿 → 「细化终稿」可按默认序退化。
boundary: | boundary: |
本技能:抄名册 → 凭各块 brief/挂载/稳变 给每槽 inserts[]。 变化频率决定缓存分组:稳定内容在前,历史居中,当前状态与本轮输入在后。
名册权威在程序(及已勾选的游玩拓扑);本步不增白名单外 id。 同一频率内,低重要性在前、高重要性靠近使用位置;本程序不改写上游内容。
中段技能可带 mount 与稳/变;最终 order 以本步为准。
细化终稿:把本步排序表收进运行规格 context_order;本步不输出完整 设计.worker集。
禁止把依赖产物里的游玩示例(迎上去/推开/反客为主 等)当成当前要问的题。
feeds: design_only feeds: design_only
``` ```
@@ -0,0 +1,113 @@
# 本局创作方案
## meta
```meta
name: 本局创作方案
id: creation-plan
artifact: 设计.本局创作方案
declaration: >
先抽出用户要的核心体验,再主动找出有帮助的内容能力并分档,形成本局专用创作方法;它指导取舍,不规定固定流程。
when: |
尚无本局创作方案,且用户已经给出有效创作方向;或用户改口、新增方向,导致现有方案明显不再成立。
when_not: |
只是在既定方向内补充局部材料,现有方法与能力分档仍成立时,不重复改写。
boundary: |
本能力只决定本局怎么做、哪些内容能力重要以及还缺什么;不代替任何能力生成正文,不安排 DAG,不进入游玩上下文。
feeds: design_only
```
## task
```task
根据用户当前材料生成或整份重写「本局创作方案」,写入 `设计.本局创作方案`。方案用于指导后续创作取舍:先定本局要兑现的体验,再形成针对这份体验的方法,并把程序提供的全部内容能力各分到一个档位。
先抽出核心体验,再扫能力目录找有帮助的能力,最后才写方法和分档。核心体验是用户想反复感到、扮演或达成的那一件事;从角色、限制、例子和题材里抽,即使用户没把它叫做体验。写进 `brief`。不要停在原话点到的题材词和能力名上。
然后用这份体验逐项看能力目录。用户没点名、但缺了会使体验立不住、持续不了或跑不起来的能力,标成「必须」或「有必要」,把还没选定的具体内容写进 `missing`。用户点到、却不承载这份体验的能力,降到「有一定效果」或「没有意义」。只写真实语义依赖;`items` 的排列不表示执行顺序,也不是本轮要写完的清单。缺口留给后续询问,不在生成方案的同一轮起草那些能力。
必须原样覆盖能力目录中的每个 `module_id` 各一次。目录外 id、本能力、机械工具节点和旧工序节点都不纳入。用户改变目标且旧方案不再成立时,按新的核心体验重写完整方案,不在旧 JSON 上追加补丁。
```
## principles
```principles
1. 配方必须针对抽出的核心体验:说明靠什么方法兑现它,不复述通用创作常识,也不复述用户点到的词表。
2. 四档按对这份体验和可运行性的贡献分,不按原话有没有出现这个能力:
- 必须:缺少后无法运行,或核心体验会崩。用户没点名、但体验直接压在上面的能力也放这里。
- 有必要:缺少仍可运行,但体验明显变弱。主动把有帮助、用户没提到的能力放这里。
- 有一定效果:对体验有增益,缺了核心仍在。可顺带询问一次。
- 没有意义:对这份核心体验没有帮助。不是「用户没提到」。
3. `why` 写该能力怎样服务抽出的核心体验;`missing` 只写仍会影响实施的具体缺口,没有则写空字符串。前三档里还没选定的做法,用能直接拿去问的具体选择写入 `missing`。
4. `instances` 只列已经能判断的具体实例需求;生成规则、具体实例等可反复能力可以列多项,其余通常为空数组。
5. 旧配方只能作为方法案例引用、混合或改写;公共规则不从案例复制进本局配方。案例里的档位倾向只供对照,与本局体验冲突时以本局体验为准。
6. 材料少时仍形成方案:从已有材料推断核心体验,不编造世界设定。推断出的有帮助能力照样上档,还没定的具体内容写进对应 `missing`。这些缺口在后续对话里按用户原话和已抽出的体验给 2~3 个选项询问,选定后再写对应能力。
```
## output
```output
{
"schema": "creation-plan.v1",
"技能": "本局创作方案",
"brief": "一句话写出抽出的核心体验,以及靠什么兑现它",
"recipe": {
"primary": "主要创作方法",
"secondary_traits": ["可混合的次要体验特征"],
"method": ["针对本局的做法,不是固定节点顺序"],
"references": ["采用或改造过的旧配方案例 id"]
},
"items": [
{
"module_id": "variable-design",
"tier": "必须",
"why": "任务时限与暴露度必须跨轮保存",
"missing": "暴露度达到哪一档会被识破尚未确定",
"instances": []
}
]
}
```
硬规则:
1. 输出合法 JSON,`schema` 固定为 `creation-plan.v1`,`技能` 固定为 `本局创作方案`。
2. `recipe.method` 描述本局做法,不写节点序列、阶段编号或统一流水线。
3. `items` 必须覆盖程序给出的全部能力各一次;`tier` 只能是四档之一。
4. `why`、`missing`、`instances` 每项都必须存在;没有缺口时 `missing` 写空字符串。
5. 产物中不写自评分、追问、回复话术或游玩正文。
## score
```score
能力覆盖:
- 5:全部应覆盖能力恰好一次,排除项正确,档位合法。
- 3:主体齐全,但有边界能力误分、重复或理由过于笼统。
- 1:明显漏项,或纳入机械工具与旧工序节点。
配方针对性:
- 5:`brief` 里的核心体验能解释主要方法;用户没点名但承载体验的能力已在「必须」或「有必要」,换一个作品就不成立。
- 3:体验可辨,但分档仍主要跟着原话点到的词走。
- 1:只有用户原话的复述或泛泛原则,没有主动找出有帮助的能力。
依赖克制:
- 5:只表达真实语义依赖,不暗示固定执行顺序。
- 3:大体灵活,但夹有弱顺序或阶段暗示。
- 1:恢复成统一流水线或 DAG。
缺口可行动性:
- 5:前三档缺口具体,涉及选择时能给出明确实例方向。
- 3:部分缺口笼统,仍需再次解释才能行动。
- 1:缺口缺失、虚构,或变成无差别问卷。
```
## checklist
```checklist
- [ ] brief 先写出抽出的核心体验,而不是复述用户点到的题材词?
- [ ] recipe 是兑现这份体验的本局方法,而不是固定节点流程?
- [ ] 用户没点名、但缺了体验会明显变差的能力,已在「必须」或「有必要」,并写了具体 missing?
- [ ] 「没有意义」表示对这份体验没有帮助,而不是「用户没提到」?
- [ ] 能力目录中的每个 module_id 恰好出现一次,且没有目录外 id?
- [ ] missing 具体且不重复 why,没有缺口时为空字符串?
- [ ] 产物不含评分、追问、回复话术或游玩正文?
```
@@ -1,9 +1,5 @@
# 生成规则 # 生成规则
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`;范例:`aesthetics-interaction` / `mechanism`。
> 正文高度自定义(描写文案、schema 字段、池随对象族而变);外壳与规则键必须严。
## meta ## meta
```meta ```meta
@@ -11,116 +7,52 @@ name: 生成规则
id: generation-rules id: generation-rules
artifact: 设计.生成规则 artifact: 设计.生成规则
declaration: > declaration: >
要精准格式,或本局原创听了会雾且故事里还要生成时:写出该对象族怎么生成(描写/格式/可选池); 为需要统一生成的一类对象写出合同:名称、用途、字段、池或提示约束、填写工作流;不填最终实例;可按对象反复。
创意字段用方向池给创作范围,不堆枚举词;不填最终实例;可按对象反复
when: | when: |
已有「美学纲领与交互范式」或等价整体感觉,且下面两条满足一条就排(可按对象族反复): 要落两份或以上同类对象时使用。
a. 生成物必须有精准格式。例:武功典籍要有名字、效果、阶层。 只落一份,但字段多、结构重要,或需要先审字段和填写工作流时也使用。
b. 本局原创、听了会一头雾水的东西,故事里还要随机/反复生成。例:人人能绽放的「心之彩」;当成本局原创的斩魄刀。 不因开场后是否继续按同一合同生成而改道。每个产物只写一条规则。
when_not: | when_not: |
整体感觉未钉 → 先「美学纲领与交互范式」。 只有一条、字段很少、也不承载玩法的具名内容,才不建规则。这种内容通常也不必落具体实例。
常识能推、也不需要精准格式(现实名车、西幻常见种族)→ 不排。 一张有节点和连接的结构(地图、升级路径、关系网)→ 拓扑图谱。
只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。 跨轮真值 → 变量设计;舞台格局与硬法则 → 舞台骨架 / 实现机制。
跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「故事推进」(工序编排:「叙事指南与故事推进」)。
boundary: | boundary: |
本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选池(方向池优先;元素池仅封闭集合)。 本能力只交生成合同:名称、用途、字段、池或提示、填写工作流。
具体实例:按规则填最终 records;本步不写最终实例(池≠实例)。 不填最终 records,不重写世界百科,不改变量公式与剧情推进。
舞台骨架 / 实现机制:上游真值只引用——`依据`/`硬约束`写短引用句或最短填写条款;社会格局、世界状况、既成法则正文不在本步重写。
缺上游真值时:本步仍只写填写合同时必须守的最短条款;格局/法则长文交给舞台骨架或实现机制,本步不代写百科、不发明「共通前提」迷你世界书。
变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。
随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。
旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);程序抽样只抽元素池;方向池由填写方按规则自创。
feeds: gm,auditor feeds: gm,auditor
``` ```
## opening ## opening
```opening ```opening
先认定本轮规则写哪一类内容: 先说这套规则生成什么、用在什么场合。只有一条字段很少的具名内容,通常不必建规则,也不必落具体实例。
- 若【本步对象】/【原型建议】已有对象提示,以此为起点;要改对象就在本步改。
- 若规划为空,先钉对象族,再谈规则。
然后谈:
1. 生成出来必须钉死哪些字段吗?(如武功典籍的名字、效果、阶层)没有就说没有。
2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西?
3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批,关系用一两句钉互指即可;彼此独立 → 一次一条。台上格局、世界律、前史不在这里展开。
5. 有没有必须全员一致、能数完的标签(阶层、强度档、武器种类)?名字、能力、气质、外貌这类写不完的,后面用「方向」划范围(一句话概括即可,少举例)。阶层这类光看名字不懂的,后面会写成能看见/听见的对照,不要只留阶名。
想到什么写什么;完整规则在正式产物里钉。
``` ```
## task ## task
```task ```task
你正在执行「生成规则」。产物必须是 **context-fragment.v1** JSON,写入「设计.生成规则」。 为单一对象族写出一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段与每项填充要求;描写类字段写明地板,标签看不懂的分级另写分级表现;需要封闭选取的字段绑池,更适合一句话约束的字段写提示;最后写填写工作流(含依赖、自检、反模式),供具体实例与游玩期动态生成共用。
本步写出的最终物是**规则**,不是实例。规则是创造这类东西的方法:空白表格 + 怎么填厚 + 必要时给方向。一条规则必须说清两件事,并可附第三件: 每个产物只含一条规则。材料足够便直接成稿;真正会改合同的缺口(生命周期、必填字段、单条/同批关系)交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
1. **生成与描写**:按什么顺序写、多方向如何拼进一格、每格填到多厚、分级如何被感知。
2. **产物格式**:生成结果必须遵循的可校验格式(schema)。描写字段要有粒度和地板。
3. **池**(可选,默认无):优先 **方向池**(方向名 + 一句概括;默认不举例,至多点 1 个专名);**元素池**只给能数完、必须全员一致的封闭集合。无则 `池: []`。
本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。
执行顺序:
1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。
2. 对照时机 a/b:必须有精准格式,或本局原创听了会雾且故事里还要生成。两条都不成立 → 结论「无需专门规则」,`rules: []`。
3. 两项成立时,生命周期三选一(规划里 `lifecycle_intent` 合理则采用):
- `runtime_only`:仅游玩期动态生成;不要求「具体实例」。
- `seed_only`:由「具体实例」预生成;规则用完可丢。
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
4. 钉「数量」与「一次生成」(两件不同的事):
- **数量**:这类对象总共要有几条(fixed / range / contextual)。
- **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`;彼此独立 → `单条`。产物形状始终是 `records[]`。
5. 写字段前先做设计判断(写入该 rule 的 `设计判断`):
- 列出 **不可穷举项**(姓名、绰号、能力概念、外貌手感等理论上写不完的)→ 这些用方向池或自由写,禁止做成条目枚举。
- 列出 **封闭集合**(阶层、强度档、有限类型名)→ 这些才用 `allowed_values` 或一个元素池。
- 标签本身看不懂的分级(境界、品阶、强度档、好感档等)→ 必须另写 `分级表现`,把每档写成可感知的详细表象,不要只留字母/阶名。
- 从本对象族真正用得上的面挑 3~6 个正交字段(是什么 / 能做什么 / 和谁关联 / 怎么被感知),不要摊通用名片。
- 外貌、服装、仪式等由几面拼成一格的 → 写 `配方`(多方向组装进同一个顶层字段),不要拆成嵌套对象,也不要为每一面各建元素池。
6. 写满规则:生成与描写(含配方、表现、工作流、反模式)+ 产物格式(**单层 JSON**)+ 池(需要才填)+ 需要时的分级表现。
每个描写类 string 字段必须有 `粒度`(句子)和 `地板`(填到什么才算够)。`示例形状` 必须达到地板,禁止用单标签当示例。
7. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);玩法接口不得漏(如 D&D 怪物的等级、攻击骰)。
8. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
9. 按自评四维打分并填追问;输出 JSON。禁止夹带最终实例 records。
若程序已发默认问题:禁止重复开场;在首答上补洞。
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
``` ```
## principles ## principles
```principles ```principles
1. 整条规则先问必要性:时机 a(精准格式)或时机 b(原创会雾且还要生成)是否成立?两条都不成立则跳过,勿硬造表。 1. 用途决定字段:每个字段都要能回答「对这个用途是否必要」;女 NPC 不写性别;药水要写清效果参数,不要只写外观;怪物这类要接战斗的,等级、攻击骰等玩法接口不能漏。
2. 一调用一对象族;格式或用途不同则分次编排。 2. 字段不多不少、互不重叠:资产与存款这类同义字段只留一个。
3. 规则 = 描写/生成方法 + 产物格式。池不是必填第三设计;默认 `池: []`。宁少池,勿为每个字段挂一套词表。 3. 取值分三类:自由生成;从池中选取(填写方按合理度选,不是伪随机程序);提示约束(如「现代都市中正常的职业」)。默认优先提示约束;只有能数完、必须全员一致的封闭集合(阶层、强度档、武器种类)或分阶段选型时才建池。
4. 属性逐个过筛:每个字段必须对本对象族有意义;禁塞通用名片字段,也禁漏掉玩法接口字段。 4. 姓名、绰号、能力概念、外貌、气质写不完:用自由或提示约束,不建池,也不在填充要求或提示里列一串可选值冒充穷举。要反复生成又怕雷同时,`提示` 可写 2~6 个「方向名:一句概括」划多样的范围;方向不挂作品名、不堆例句。
5. 产物格式 = **单层 JSON**:一条产物是一层扁平对象;值仅为标量或「标量数组」。 5. 描写类字段(外貌、能力现象、限制、互动手感等)写 `地板`:最低要写到读者能看见、听见或碰到什么,对手能抓住什么空隙。词条、档位、数字、骰子可以短,不写地板。规则里出现的示例必须达到地板,不用「气质:阴郁」「能力:时空斩」这种单标签当示例。
禁止嵌套 object、禁止 object 数组。此格式要对齐动态表格列、旁观者→主世界层生成、以及投影取字段。 6. 标签本身看不懂的分级(境界、品阶、强度档、好感档)另写 `分级表现`:每档一句概括 + 一段可感知表象,相邻档必须能分清。实例字段只填档位短值,叙事按此表展开。
类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。 7. 外貌、服装、仪式等几面拼成一格的字段,在该字段 `填充要求` 里写配方:用哪几面、每面写什么、怎么拼成一段。字段仍是一个扁平值,不拆嵌套对象,也不为每一面各建池。
6. 池分两种,先判断能不能数完: 8. 填写工作流按重要性推进:先锚定会约束后续的选项,再填血肉;阶段、依赖、自检、反模式都服务于「怎么填对」,写短写准。会反复生成的规则,自检清单要含去重:与已有记录不撞名、不撞核心特征。
- **方向池**(默认):选项理论上无限。给 4~8 个方向;每条以**方向名 + 一句概括**为主。默认不举例;需要手感时至多点 1 个专名,禁止把方向写成作品名/例句清单。填写以方向为主:方向名可以当选项或标签;句子级字段按该方向写开。程序不抽方向池。 9. 生命周期三选一:`仅游玩期`(创作期不预填)、`只预生成`(预填完即可丢掉规则)、`预生成并游玩期`(既写种子,开场后仍可再生成)。选哪一档不改变「要不要先写这份合同」。
- **元素池**:选项有限且必须全员一致(阶层、武器种类、强度档字母)。条目可穷举;旁观 `need_generate` 只抽这类池。 10. `只预生成` 为中间层;另两类为最终层,挂主世界层与旁观。
姓名、绰号、能力概念、外貌、气质(作描写时)禁止元素池,也禁止用 `allowed_values` 假穷举。同一封闭集合不要既写枚举又再建元素池。 11. `数量` 与 `一次生成` 是两件事:`数量` 是总共几条;`一次生成` 看记录之间有没有必须一起钉死的关系(互指、对立、同场),不看总数。有 → 同批,`关系` 只写谁对谁;彼此独立 → 单条。「总共 5 个」不等于同批。
7. 填值要有地板。骰子、是否可破坏可以短。外貌、能力现象、限制、互动手感必须写到可感知。`示例形状` 是地板的样板,不是「阴郁」这种标签。 12. 多张名片共享的台上格局、世界状况、既成法则归舞台骨架或实现机制:已有则在 `用途` 或 `填充要求` 里短引用;没有则只写填写时必须守的最短条款,缺口交 `self_score.gap`。不在规则里重抄「共通前提」长文,也不借 `关系` 写前史与社会律。
分级标签本身看不懂时:实例字段仍填短档位;详细对照写在 `分级表现`(每档 = 一句概括 + 一段可感知表象,相邻档必须能分清)。不要只丢「A / 金丹」让人猜。 13. 不输出最终实例记录。
8. 几面拼成一格用 **配方**:点名目标字段、用到哪些方向组件、怎么拼进这一格。表现类要点用 **表现**:一句概括 + 至多 1 条例子;例子是密度样板,不是选项库。 14. 给人读的键和专名用中文。材料里的英文名音译成中文。外壳的 schema、brief、mount 保持既有键。
9. 生命周期先行;未定前不假装可执行「具体实例」。
10. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系 → `同批`;彼此独立 → `单条`。`同批` 至少 2 条。不要把「总共要几个」和「这一次交几条」混成一件事。
11. **上游真值引用,不重述**:舞台格局、世界状况、既成法则已有 → `依据`/`硬约束`点名引用;没有 → 只写最短填写条款,并在追问或开放问题标「应落舞台骨架/实现机制」。禁止用「共通前提」类长文重写世界书。
12. **同批关系只钉互指**:`一次生成.关系`与字段间约束写清谁对谁(对立/亲属/席位互指等);不借关系段落写前史、社会律、权力百科。
13. 不写变量更新公式、不写剧情推进、不填最终实例。
```
## probe
```probe
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(只追互指,不追世界书);某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。
若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、台上格局/既成法则长文(→ 舞台骨架/实现机制)、再给哪个字段加一套枚举池。
``` ```
## output ## output
@@ -129,224 +61,139 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "生成规则", "技能": "生成规则",
"brief": "一句话:对象族 + 建立规则|无需专门规则 + 生命周期(若建立)+ 方向池|元素池|无池", "brief": "一句话:对象 + 用途 + 生命周期",
"mount": ["world-simulator", "auditor"], "mount": ["world-simulator", "auditor"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"本步参数": { "名称": "人话规则名,如「末世可征服 NPC」",
"target": "与本步认定的对象一致(规划有则对齐,步内改则以最新为准)", "用途": "这套规则在本局用来生成什么、服务哪类游玩反馈",
"rule_id": "已钉或本步拟定;跳过时可空字符串", "规则名": "中文短名,与名称对应且稳定",
"lifecycle_intent": "规划原值、本步钉死值或空" "生命周期": "仅游玩期|只预生成|预生成并游玩期",
"数量": {
"模式": "固定|区间|看情况",
"值": 1,
"最小": 1,
"最大": 1,
"决定规则": "contextual 时说明如何决定条数"
}, },
"必要性判断": { "一次生成": {
"生成对象": "本轮对象族", "模式": "单条|同批",
"对游玩的重要性": "时机 a:必须钉死哪些字段;无则写「不需精准格式」", "关系": "同批必填:本批必须一起钉死的互指;单条写彼此独立"
"基础生成的不足": "时机 b:为何听了会雾、现有常识推不出;能推则写清",
"结论": "建立专门规则|无需专门规则"
}, },
"rules": [ "字段": [
{ {
"rule_id": "英文 kebab-case", "名": "顶层字段名",
"对象": "该规则生成什么", "填充要求": "填什么、填到多厚、为何本用途需要它;几面拼一格的写配方(哪几面、各写什么)",
"生命周期": "runtime_only|seed_only|seed_and_runtime", "取值方式": "自由|池选取|提示约束",
"上下文策略": { "绑定池": "池选取时填写池名称;其它省略",
"预生成实例": true, "提示": "提示约束时写可执行的一句范围,或 2~6 个「方向名:一句概括」;其它省略",
"保留规则到游玩期": false, "地板": "描写类字段写:最低填到什么可感知程度;词条、档位、数字省略"
"保留实例到游玩期": true }
}, ],
"数量": { "池": [
"模式": "fixed|range|contextual", {
"值": 1, "名称": "与字段绑定的人话名",
"最小": 1, "说明": "何时从中选取、选取口径",
"最大": 1, "条目": ["封闭可选项;只列必须一致的有限集合"]
"决定规则": "contextual 时必填,可较长" }
}, ],
"一次生成": { "分级表现": [
"模式": "单条|同批", {
"关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」" "字段": "标签看不懂的分级字段名;无此类分级则省略整个键",
}, "档位": [
"设计判断": {
"不可穷举项": ["姓名", "能力概念", "外貌"],
"封闭集合": ["类型", "阶层"],
"正交字段依据": "一句话:为何是这几个字段而不是通用名片"
},
"生成与描写": {
"依据": ["可较长;引用上游产物或用户要求"],
"配方": [
{
"目标字段": "外貌",
"组件": ["轮廓方向", "质感方向"],
"组装": "各取一个方向,拼进外貌这一格;不要拆成嵌套对象"
}
],
"表现": [
{
"针对": "能力现象",
"概括": "写到旁人能看见或听见的变化,以及对手能抓住的空隙",
"例子": ["至多 1 条密度样板;没有就不写"]
}
],
"工作流": ["按配方取方向并组装", "封闭档位对照分级表现", "按地板写描写格"],
"方法": ["描写口径;方向为主(方向名可作选项/标签,句子级按该方向写开);元素池如何抽取"],
"硬约束": ["任何产物不得违反"],
"字段间约束": ["有就写;无则 []"],
"变化维度": ["允许随机或变化的部分;无则 []"],
"禁止项": ["不得出现的组合或内容"],
"去重规则": ["同批/跨批"],
"反模式": ["为名字/能力/外貌建枚举条目池", "描写字段只填一个词", "方向池堆作品名或例句当选项", "分级只写阶名没有表象"],
"校验": ["类型、枚举、边界、地板、配方是否组装进目标字段、分级表现是否可分档"]
},
"分级表现": [
{ {
"字段": "阶层", "值": "档位短值,与实例所填一致",
"说明": "实例只填档位值;叙事按本表展开。无此类分级则 []", "概括": "一句:这档能做到什么量级",
"档位": [ "表象": "看见、听见、碰到什么;须能和上下档分清"
{
"值": "档位名",
"概括": "一句:这档能做到什么量级",
"表象": "可感知的详细对照(看见/听见/碰到什么;须能和上下档分清)"
}
]
}
],
"产物格式": {
"格式": "单层JSON",
"批量时": "array of flat object(每条记录单层)|单条则一个 flat object",
"schema": {
"字段名": {
"type": "string|number|integer|boolean|enum|array",
"required": true,
"description": "语义 + 为何本对象族需要此字段;可较长",
"粒度": "词条|方向|句子",
"创作": "自由|约束|参考方向|必选元素",
"地板": "描写类必填:填到什么才算够;词条/数字可省略",
"allowed_values": [
{ "value": "仅封闭集合", "meaning": "含义" }
],
"minimum": 0,
"maximum": 100,
"items": { "type": "string|number|integer|boolean|enum" },
"min_items": 1,
"max_items": 3
}
},
"示例形状": {
"名称": "达到地板的名字,不是「某某」",
"阶层": "金丹",
"外貌": "按配方拼好的一段可感知外貌,不是「黑发高个」",
"能力描述": "写清现象与可被抓住的空隙;禁止一个词交差"
}
},
"池": [
{
"pool_id": "英文 kebab-case;同规则内唯一",
"名称": "人话名",
"池型": "方向|元素",
"绑定字段": "单层 schema 的顶层字段名",
"用途": "方向:方向为主的创作范围|元素:随机抽取或必选",
"说明": "该字段如何用本池;多池绑同一字段时看配方",
"方向": {
"方向名": "一句概括。默认到此为止;至多再点 1 个专名。仅池型=方向时使用"
},
"条目": [
{
"id": "可选稳定 id",
"内容": "仅池型=元素:可穷举的标签或短词",
"权重": 1,
"标签": ["可选分类标签"]
}
],
"抽取或组装规则": "方向池写「以方向为主;句子级按该方向写开」;多池同字段看配方;元素池写抽取方式,默认均匀随机"
} }
] ]
} }
], ],
"增量说明": "相对已有规则新增或修改了什么;首条可写「新建」" "填写工作流": {
}, "工作流": {
"自评": { "阶段1 骨架锚定": "先选哪些池/字段、定下什么内核",
"维度": [ "阶段2 灵魂注入": "下一步推什么,如何与上一阶段咬合",
{ "阶段3 血肉填充": "姓名、外貌等如何服务已定内核",
"名": "必要性", "阶段4 立场定调": "可选;最后定关系或立场类字段"
"分数": 8,
"说明": "时机 a 或 b 是否成立;跳过时理由是否充分"
}, },
{ "依赖关系": [
"名": "属性妥当", "字段A → 字段B:约束说明"
"分数": 8, ],
"说明": "逐字段:有无冗余或缺失玩法接口;跳过时写 N/A" "自检清单": [
}, "填完后必须能回答的关键问题"
{ ],
"名": "材料得当", "反模式": [
"分数": 8, {
"说明": "不可穷举项是否做成方向池/自由而非假穷举;方向是否以概括为主、例子极少;元素池是否仅封闭集合;该细的分级是否有表象;描写字段地板是否够密;跳过时写 N/A" "名": "短名",
}, "说明": "为什么错",
{ "反例": "可选一句"
"名": "格式准确", }
"分数": 8, ]
"说明": "是否严格单层 JSON、类型/必填可对接动态表格·旁观者生成·投影;跳过时写 N/A" }
} }
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为钉准生成规则,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A", "选项B", "其它(请写明)"],
"示例": "可选"
}
]
},
"开放问题": []
} }
``` ```
硬规则(公共三段 + 本技正文): 硬规则:
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 输出合法 JSON;每个产物只描述一条规则,禁止 `rules` 数组堆多条。
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/材料得当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。 2. `字段` 由用途推出;可省略的字段不写;重叠字段合并。
3. **正文键固定**:本步参数、必要性判断、rules、增量说明。 3. `池` 可 `[]`;池选取字段必须能对应到某个池;提示约束字段必须有 `提示`。姓名、绰号、能力概念、外貌、气质不绑池。
4. **每条 rule 必含**:设计判断、生成与描写、产物格式、数量、一次生成;**池可空数组**。`生成与描写.配方` / `表现` 无则 `[]`。标签看不懂的分级必须有 `分级表现`(每档概括+表象,相邻可分清);无此类则 `分级表现: []`。有池时 `绑定字段` 必须是单层 schema 的顶层键,且必须标 `池型`。多个方向池绑同一字段时配方必须写清怎么拼进该格。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。 4. `填写工作流` 必含 `工作流`;`依赖关系` / `自检清单` / `反模式` 为子字段,无则 `[]`,不要拆成正文顶层并列大段。
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。描写类 string 字段必须有 `粒度` 与 `地板`;`示例形状` 必须达到地板。 5. `一次生成` 为同批时关系必填且本批至少 2 条;为单条时一次只交 1 条。
6. **池型**:`方向` 必须有 `方向`(对象:方向名→一句概括,4~8 条;默认不举例,至多 1 个专名),`条目` 可 `[]` 或省略。`元素` 必须有可穷举 `条目`,不要写 `方向`。不可穷举项禁止 `池型=元素`,也禁止用 `allowed_values` 冒充。 6. 字段值为扁平可填内容;不要嵌套对象当字段值契约。几面拼一格的配方写在 `填充要求`。
7. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。 7. 描写类字段必须有 `地板`;规则里任何示例都要达到地板。标签看不懂的分级必须在 `分级表现` 写齐每档;没有此类分级则省略 `分级表现`。
8. 池≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。 8. 不在产物正文写自评、追问、开放问题或最终记录。
9. `mount` 默认主世界层 + 旁观维护(旁观只需合同键:rule_id/格式/必要性;描写长文可在投影时 summary);**禁止**写 `order`。summary 见 task。 9. `只预生成` 时程序记为中间层;`仅游玩期` / `预生成并游玩期` 记为最终层。
10. 字段名和专名用中文;英文专名音译为中文。
## score
```score
字段妥当:
- 5:字段由用途推出,不多不少、不重叠;该有的玩法/效果接口齐全,该省略的已省略;描写类字段有达到可感知的地板,难懂分级有分级表现。
- 3:大体可用,但有 1 个冗余、重叠或明显缺失字段,或个别描写字段地板含糊、某档分级只有阶名。
- 1:通用名片乱塞,或关键效果/接口字段缺失;描写字段与示例都是单标签。
池与提示:
- 5:只有封闭集合用池,不可穷举项用提示或自由;提示具体可执行,方向式提示以「方向名:一句概括」为主、不挂作品名。
- 3:大体合理,但个别字段误用/漏用池,或池过泛、提示含糊。
- 1:为姓名、气质、能力等建词池,大段假穷举,或本该一句话提示却做成巨型词表。
填写工作流:
- 5:阶段简洁有力,依赖与自检/反模式能引导按重要性推进,反复生成时含去重,不臃肿。
- 3:有阶段但顺序或依赖不清,或清单偏长却抓不住关键咬合。
- 1:无工作流,或与字段脱节,无法指导动态生成。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 名称与用途已写清?
- [ ] 自评为必要性 / 属性妥当 / 材料得当 / 格式准确? - [ ] 字段由用途推出,无冗余重叠、无关键缺失?
- [ ] 整条规则必要性有举证?不成立时 rules=[]? - [ ] 池与提示分工正确?姓名、绰号、能力、外貌、气质没有建池?
- [ ] 建立时:有设计判断(不可穷举 vs 封闭集合)?逐字段无冗余无漏接口?产物为单层 JSON?数量与一次生成已分清? - [ ] 描写类字段有地板,示例不是单标签?难懂分级有分级表现?
- [ ] 描写字段有粒度+地板,示例形状不是单标签? - [ ] 几面拼一格的字段在填充要求里写了配方?
- [ ] 该细的分级有概括+可感知表象,不是只留阶名? - [ ] 填写工作流含子字段:工作流 / 依赖关系 / 自检清单 / 反模式?反复生成时自检含去重?
- [ ] 多方向拼一格时有配方?表现是概括+极少例子? - [ ] 生命周期已钉?一次生成按关系定,而非按总数?
- [ ] 池:无则 [];有则标池型;方向池以概括为主、例子极少;元素池仅封闭集合;未把名字/能力做成枚举词表? - [ ] 共享格局只引用舞台骨架,没有重抄共通前提?
- [ ] 未夹带最终实例、变量公式、其它对象族、百科? - [ ] 未输出最终实例、百科长文、评分或追问?
``` ```
## examples ## examples
```examples ```examples
好: 好:
- 武功典籍:单层含 名字/效果/阶层。阶层短填 + `分级表现` 把每阶写成可感知对照;名字与效果用方向池(方向名+一句概括)。 - 武功典籍:字段为名字 / 效果 / 阶层。阶层池选取封闭档位,另写 `分级表现`,每阶一句概括 + 可感知表象;名字与效果用提示约束。
- 外貌:轮廓方向 × 质感方向,配方拼进 `外貌` 一格,不要 `{发色:{}}`。 - 女主规则:不设「性别」;池为 `[]`;内在冲突自由写,地板是「能在一场戏里演出来的两难」。
- 「心之彩」:颜色/性格用方向池(如「冷焰:静、刺、余烬不散」),不要列出 20 种色号,也不要堆作品名。 - D&D 怪物:名称、等级、攻击骰齐全;气质用提示约束,地板写到一个可观察的习性。
- D&D 怪物:名称/等级/攻击骰齐全;等级若只写数字看不懂,补分级表现;气质用方向池+句子地板。 - 外貌:`填充要求` 写配方「轮廓一句 + 质感一句 + 一个旁人会记住的细节」,拼成扁平的一段「外貌」。
- 女主规则:不设「性别」;池可空;内在冲突自由写并给地板。 - 互为仇敌的两名开局 NPC → 一次生成=同批,关系写谁夺了谁的什么;可互换的酒馆路人 → 单条;总共要 5 个但彼此无关 → 数量 5、一次生成单条。
- 互为仇敌的两名开局 NPC:一次生成=同批;可互换的酒馆路人:一次生成=单条。
- 现实名车、西幻常见种族 → 无需专门规则。
坏: 坏:
- 为气质/花招/绰号/能力各建一个 8~20 词的元素池,填出来全是标签。 - 为气质、绰号、能力各建一个 8~20 词的池,填出来全是标签。
- 方向池每条挂 3 个作品名或例句,变成假选项库。 - 提示里的方向每条挂作品名或例句,变成假选项库。
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层。 - 示例写「气质:阴郁」「能力:时空斩」,地板被示例教坏。
- `示例形状` 里 `能力描述: "时空斩"` 或 `气质: "阴郁"` —— 地板被示例教坏。 - 阶层只写「A」「金丹」,没有分级表现,叙事时无从下笔。
- 阶层/强度只有「A」「金丹」,没有表象,叙事时没法写。 - 把社会格局、前史写成「共通前提」长文塞进规则,而不是引用舞台骨架。
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。 - 女主规则塞「性别」;怪物规则只有名字和性格,没有等级、攻击骰。
- 有亲属关系的三人却标成单条;或把「总共要 5 个」写成同批但彼此无必须同场的关系。
- 把写好的 NPC 整份塞进池当最终实例。
- 把社会格局、前史、既成律写成「共通前提」长文塞进规则;应引用舞台/机制,或只留最短填写硬约束。
``` ```
@@ -1,8 +1,5 @@
# 实现机制 # 实现机制
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳对齐:`docs/context-fragment-design.md`;范例:`aesthetics-interaction`。
## meta ## meta
```meta ```meta
@@ -10,156 +7,47 @@ name: 实现机制
id: mechanism id: mechanism
artifact: 设计.实现机制 artifact: 设计.实现机制
declaration: > declaration: >
整体感觉已钉,故事里有必须当作既成事实的变造法则(科幻意义上的第一初设定)时:点出它们如何约束故事,不解释为何存在 保存世界中稳定成立的因果法则,供主世界层推演事件后果、剧情变化和人物反应;一次可写多条共同作用的机制。
when: | when: |
已有「美学纲领与交互范式」或等价整体感觉,且用户表述或美学卡里有必须当作既成事实的变造法则。 本局存在拿掉后会改变故事骨架的既成法则,且后续需要据此推演环境变化、事件后果或人物反应时使用。
例:现代都市但已繁荣一统、存在修炼体系;三体黑暗森林;机器人三定律。
特征:故事不必纠结它为什么存在,拿掉则故事骨架会换。
when_not: | when_not: |
整体感觉未钉 → 先「美学纲领与交互范式」。 只有熟悉常识,没有特殊因果法则 → 不使用。 「特殊」只是身份或开场处境、能被舞台骨架、具体实例或开场白覆盖(如现实世界里模拟一名普通大学生)→ 不使用。 地点、势力与社会格局 → 舞台骨架;多份人物或要先审字段的名片 → 生成规则,单条字段很少的具名内容才归具体实例;字段合同 → 生成规则。
没有特殊变造,或「特殊」只是身份/开场处境、能被「舞台骨架」「具体实例」「开场白与开场变量」覆盖(如现实世界模拟普通大学生)→ 不排。
boundary: | boundary: |
本技能:点名故事当作既成事实的变造法则,写清如何约束后续;不补起源、不写舞台百科、不写人物名片。 本能力交稳定因果,不预写唯一剧情,也不把所有人物钉成同一种反应。
美学纲领与交互范式:体验与轮转——本步不重定。 不解释起源,不扩写世界百科,不制定推进节奏、变量表或检定公式。
舞台骨架:环境与社会格局(谁在台上、状况如何转)——本步只钉缺了会换骨架的既成律。
具体实例 / 开场白与开场变量:名片与第一屏(身份特殊走它们)。
生成规则:可引用本步法则作填写硬约束;本步不写对象族 schema/池,也不把法则扩成名片填写圣经。
变量* / 叙事指南 / 故事推进:跨轮真值、遣词、推进(工序编排后二者合为「叙事指南与故事推进」)——本步只钉前提法则。
feeds: gm feeds: gm
``` ```
## opening ## opening
```opening ```opening
整体感觉已有方向时,把故事里「必须当作已经成立」的变造法则点出来(想到什么写什么;点名即可,不必解释它为什么存在)。 说说这个世界有哪些已经成立、会持续改变事件后果的法则。重点是它如何起作用,以及不同处境和人物会因此面对什么;多条机制可以一起写清。
1. 这局有哪些特殊变造,后续情节都得认它?
例:现代都市,但世界已繁荣一统;存在修炼体系;黑暗森林法则;机器人三定律。
2. 拿掉或改弱哪一条,故事骨架会换成另一个?
一句话——只点「缺了就不是这个故事」的。
3. 若只是某人身份特殊、开场处境特殊,先别写在这里(那是舞台 / 人物 / 开场白)。
``` ```
## task ## task
```task ```task
你正在执行「实现机制」。产物必须是 **context-fragment.v1** JSON,写入「设计.实现机制」。 把本局承重的既成因果法则整理为实现机制,写入 `设计.实现机制`。一个产物可以包含多条共同作用的机制;分别写清法则内容、生效方式、因果影响和推演口径,再说明机制之间如何共同作用。
本步钉的是故事的第一初设定:已被承认、不必解释来源、却会决定后续能不能这么写的变造法则。不是科学原理,不是舞台百科,也不是人物名片。 先在内部对每条候选做移除检验:拿掉后故事骨架是否改变。`缺失后果`只用于筛选,不得写进实际产物。机制应让主世界层能够推演事件怎样发展、不同人物可能怎样反应,但不得指定唯一剧情或统一人物反应。
从已确认的整体感觉出发,点出这些法则,写清它们如何约束故事、拿掉会怎样。用户没有这类特殊变造:覆盖检验写明「无特殊法则」,追问确认是否跳过;身份/开场类特殊交给「舞台骨架」「具体实例」「开场白与开场变量」。 材料足够便直接成稿;真正会改变法则内容的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
执行顺序:
1. 读依赖产物与用户表述,忠实提取整体感觉与已点名的变造;不重做美学/交互。
2. 思考阶段判断基准世界可能性范围(只校验,不写入产物)。
3. 列出必须当作既成事实的变造法则,检查拿掉后故事骨架是否会换。
4. 对每条候选做移除检验。
5. 只保留缺了会换骨架的法则;常识、装饰、人物名片、开场处境、后续技能展开对象不纳入。
6. 写入正文:依据体验、支撑点(每条法则)、支撑点关系、覆盖判断。
7. 填自评与追问(薄弱点才问;题目可空数组)。
8. 输出符合 output 契约的 JSON。
工作姿态——「点出已经成立之物」:不另造体验;不补起源史;不设设定菜单;不题材套件;可整理用户未命名的法则并请修正;二选一先问清再写。
依赖含混时不越权重做上一步。无法在本步解决的写入开放问题或追问。
若程序已发默认问题:禁止重复开场;在首答与依赖上补洞。
summary:`实现机制 · …`(点题主要支撑)。
``` ```
## principles ## principles
```principles ```principles
1. 从整体感觉正推到既成法则 1. 机制是稳定因果:写世界承认什么、何时生效、会带来什么后果。
始终保持:整体感觉 → 故事必须承认的变造法则 → 该法则如何约束后续。 2. 法则可以是超常前提、约束性公理,也可以是社会性或规范性的既成事实,如“这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气”“这个西幻世界里男尊女卑是常态”“这里以三国杀高手为尊”。同一设定有很多面向,只写后续推演必须承认的那一面。
题材标签不能代替「这条法则缺了故事会换」。 3. 身份特殊、开场处境特殊不是法则:能由舞台骨架、具体实例或开场白承担的(如现实世界里的普通大学生),不写成机制。
4. 内部使用移除检验筛选承重法则;只能回答“更丰富”或“以后也许有用”的候选不纳入。缺失后果不输出。
2. 法则不是完整设定 5. 内部按基准世界做最小充分继承,校验过程不写入产物:现实地球沿用现实物理、心理与社会规律;变造地球只在用户已表达或体验依赖处承认变造;具体作品世界优先最近一层作品设定,原作未定义处再参考其底层世界;类型世界只继承与当前体验有关的类型约定;从头创造以显式设定为准,未定义处用最少常识;混合或二创只处理与当前法则有关的继承与变造。
同一设定可有很多面向,本步只写「当作已经成立、后续必须认」的那一面。钉的就是这类既成事实整句,不顺着去补体系百科: 候选法则超出基准世界已知可能性时,只有用户材料已明示或体验确实依赖才写入,不暗中变造;拿不准是否在用户设想中,就不写进产物,把这处变造交给 `self_score.gap`。
这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气。 6. 因果影响写行动可能性、代价、资源与关系怎样改变;推演口径写推演情节和人物反应时必须考虑什么。
这个西幻世界里,男尊女卑是常态。 7. 人物反应保持开放:同一机制下,人物可因认知、利益、身份和性格不同而隐藏、合作、利用、抵抗或误判。
这里以三国杀高手为尊。 8. 多条机制可一次生成。分别写清后,再说明它们是前提、叠加、制衡还是反馈循环;没有关系则不硬连。
9. 机制不等于题材标签。“灵气复苏”“全民直播求生”必须拆成可推演的生效方式与因果链;题材标签也不能代替“这条法则缺了故事会换”。能指明它支撑美学纲领里哪段体验时,写进 `服务体验`。
3. 以缺失后果检验必要性 10. 一句话够用便停止;不补起源史、体系百科、人物卡、剧情大纲或推进节奏。
每条法则至少能回答:拿掉以后,故事骨架会换成哪一个?只能回答「更丰富」「以后可能用得上」的,不纳入。
4. 本步写的是第一初设定,不是人物或舞台
可以是:被直接承认的超常前提、已完成的世界变造(繁荣一统)、约束性公理(黑暗森林、三定律)、体系性硬规则(修炼境界)。
人物个性、身份特殊、开场处境、地理社会展开,交给舞台 / 实例 / 开场白。
5. 基准世界只作内部校验
思考阶段判断「什么可能存在」,不要写入产物。最小充分继承:
- 现实地球:默认现实物理、心理、社会规律。
- 变造地球:继承现实基础,只在用户已表达或体验确实依赖处承认变造。
- 具体作品世界:优先最近一层作品设定;原作未定义处再参考其底层世界。
- 类型世界:只继承与当前体验有关的类型约定,不带入类型全部常见元素。
- 从头创造:显式设定优先;未定义处用最少量常识基础。
- 混合或二创:只处理与当前法则有关的继承与变造,不整理完整谱系。
候选超出已知可能性时先确认变造是否本在用户设想中,不暗中加入。
6. 法则可以不解释来源
写清「它怎样约束故事」,不要求「宇宙为什么会有它」。
不为显得严谨而补起源、发明者、历史沿革或伪科学理论,除非「追查来源」本身就是这条法则要支撑的体验。
7. 按真实复杂度描述
一句话能说清就用一句话;否则才按过程、张力、层次、构成、循环或关系网展开。
多支点关系简单时用自然语言(共同支撑 / 前提 / 反衬 / 制衡);关系确实复杂才展开,不为形式制造图谱。
8. 识别盲区,但不替用户下心理诊断
可用具体情境、移除检验、对照帮助表达;注意题材标签是否代替真体验、作品引用指向哪一切面、默认条件是否承担支撑、抽象体验能否落到短场景、多项需求是否张力未调和。
不能把拒绝解释成隐藏欲望,不能把犹豫诊断为羞耻或自我欺骗。用户明确拒绝即边界;陈述与反馈不一致时只中性复述差异并请求确认。
9. 宁少勿多
支撑点无预设数量。主要支撑已覆盖、剩余只是常识/装饰/后续展开对象时立即停止。
不为显得完整而增加人物、势力、地点、规则或冲突。
```
## probe
```probe
只追问会改变核心支点、支撑切面或支撑关系的缺口。每轮 1~2 点。依赖产物或用户已答清的禁止重问。
优先方式:
1. 移除检验
暂时拿掉疑似支点,问核心感觉是否仍成立。
例:「如果他不是在主动隐瞒,而是真的无法识别异常,这种紧张感还是你要的吗?」
2. 短场景落地
抽象感觉 → 紧贴现有材料的短场景请修正。
例:「更接近哪种瞬间:证据已经出现却被他自行合理化,还是他其实察觉了但选择不追问?」
3. 支撑关系核对
已识别元素但作用不清 → 核对缺失后果。
例:「这个身份差距主要制造不可抗拒的吸引,还是让暴露后的代价变得更高?」
4. 张力辨认
两项体验可能由相反条件支撑 → 放进同一具体描述确认。
例:「你既要长期安全,又要濒临暴露;是否意味着真正需要的是『客观上有保护,但角色主观上始终无法确信安全』?」
5. 引用拆解
引用作品/类型 → 只问与支撑机制有关的切面。
例:「你提到这部作品,主要是想保留其中的信息差、人物关系,还是那个无法撤销的超常前提?」
提问给出可直接采用或微调的完整句子,不罗列大批名词选项。对照只用于暴露差异,不变成设定菜单。
能稳妥推断时:先写成暂定支点并复述,请用户校正;不要要求用户从零发明专业术语。
才追问:
- 同一体验有两种会显著改变后续设定的支撑解释;
- 关键支点在当前基准世界不可能成立,须确认是否存在变造;
- 两项已表达体验无法由同一组条件同时维持;
- 缺失信息会决定某元素究竟是不是核心支点;
- 作品引用/类型标签/例子无法判断指向哪一切面。
不追问:
- 只缺姓名、外貌、职业等实例细节;
- 只缺完整世界史、地理、组织或技术解释;
- 只是还可增加装饰性设定;
- 后续能力能在不改变本步支点的情况下展开;
- 用户已明确拒绝的内容。
``` ```
## output ## output
@@ -168,121 +56,99 @@ summary:`实现机制 · …`(点题主要支撑)。
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "实现机制", "技能": "实现机制",
"brief": "一句话:主要靠什么撑住核心体验", "brief": "一句话说明哪些稳定因果在改变本局世界",
"mount": ["world-simulator"], "mount": ["world-simulator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据的核心体验": [ "既成机制": [
"从依赖产物忠实提取的体验锚点;此处不重做美学纲领"
],
"支撑点": [
{ {
"名称": "简短、稳定、对人可读", "名称": "简短稳定的人话名",
"归属元素": "角色 / 关系 / 群体 / 世界条件 / 规则 / 情境…", "法则内容": "世界中稳定成立什么;可以是超常前提、公理,也可以是社会常态或规范",
"支撑切面": "该元素中真正相关的那一面", "服务体验": "一句话说明它支撑美学纲领里的哪段体验;无明确对应则省略",
"切面形态": { "生效方式": [
"结构": "一句话|过程|张力|层次|构成|循环|关系网", "对谁、在什么条件下、以什么方式起作用"
"概述": "最短充分描述", ],
"展开": [{ "部分": "仅必要时", "描述": "…" }] "因果影响": [
}, "它怎样改变行动可能性、代价、资源、关系或事件后果"
"对应体验": ["直接服务的体验锚点"], ],
"如何支撑": "条件 / 限制 / 对照 / 张力如何让体验成立", "推演口径": [
"缺失后果": "拿掉或改弱后具体损失什么(禁止只写「变差」)", "推演剧情发展和人物反应时必须结合哪些认知、利益、身份或处境"
"完备度": "…%" ],
"边界或例外": [
"确有必要才写;无则省略"
]
} }
], ],
"支撑点关系": [ "共同作用": [
{ {
"涉及": ["支撑点名称"], "涉及机制": ["机制 A", "机制 B"],
"关系": "前提|共同支撑|反衬|制衡|递进|循环|…", "关系": "前提|叠加|制衡|反馈循环",
"体验作用": "为何必须保留这段关系" "推演影响": "共同作用时会怎样改变事件与人物选择空间"
}
],
"覆盖检验": {
"主要支撑是否覆盖核心体验": "是|否|部分",
"说明": "还有哪块体验没有支点、或支点过密",
"宁少勿多说明": "为何到此停止"
}
},
"自评": {
"维度": [
{
"名": "覆盖度",
"分数": 8,
"说明": "能否完全覆盖用户已表达的规则/前提/硬条件(未漏掉用户点名的承重约束)"
},
{
"名": "充分度",
"分数": 8,
"说明": "支撑点合起来能否充分展示用户想要的核心体验(缺了是否撑不住感觉)"
},
{
"名": "克制度",
"分数": 8,
"说明": "是否精确到支撑切面;无多余百科、人物卡、起源史或题材默认拓展"
}
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为钉准支撑切面,还需确认:",
"题目": [
{
"问": "移除检验或短场景落地题…",
"建议选项": ["选项A(可带短场景)", "选项B", "其它(请写明)"],
"示例": "可选示范句"
} }
] ]
}, }
"开放问题": []
} }
``` ```
硬规则(公共三段 + 本技正文): 硬规则:
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 输出合法 JSON;`既成机制` 可含多条承重机制。
2. **公共三段**:`正文`=产物主体;`自评`=打分(**分数 0–10 十分制**);`追问`=导语+建议选项。禁止整份散文壳。 2. `缺失后果`、移除检验和被淘汰候选只用于内部推理,不得进入正文。
3. **本技正文**:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`。基准世界/推理过程/淘汰候选不得写入。 3. 每条机制必须写 `法则内容 / 生效方式 / 因果影响 / 推演口径`;`服务体验` 与 `边界或例外` 可选。
4. 每个支撑点须有归属元素、支撑切面、如何支撑、缺失后果;简单支点结构=「一句话」、展开=`[]`。 4. `共同作用` 只写真实存在的机制关系;无则 `[]`。
5. `mount` 默认主世界层(`world-simulator`);**禁止**写 `order`。承重规则进 GM,不进转述全文。 5. 推演口径提供因果依据,不指定唯一剧情,也不规定所有人物作同一种反应。
6. `稳变` 多为 `stable`(支点定稿后少改);若用户明确机制会阶段性改换可标 `semi`。 6. 不在产物正文写自评、追问、开放问题、起源百科或检定公式。
7. 不要输出角色卡、世界百科、剧情大纲、叙事文风、变量表、执行单元列表。 7. `mount` 固定为 `["world-simulator"]`。
8. summary:`实现机制 · {brief 缩略}`。
本步完成:主要支撑已覆盖;每点过「如何支撑/缺了怎样」;无暗中变造;剩余归常识或下游技能。 ## score
```score
骨架必要度:
- 5:每条都是拿掉后会改变故事骨架的承重法则,没有常识、装饰或下游细节。
- 3:主体承重,但夹有一条作用较弱或可交给其它能力的内容。
- 1:只有题材标签或装饰设定,或把身份、开场处境、暗中加入的变造写成法则。
因果清晰度:
- 5:生效条件与影响链清楚,能据此推演环境、事件和关系怎样变化。
- 3:法则方向正确,但生效方式或一段关键因果仍需猜测。
- 1:只说“存在某设定”,无法推出后果。
推演开放度:
- 5:能支持人物按不同认知、利益、身份和性格作出多种合理反应,而不失去法则约束。
- 3:提供了反应依据,但部分表述暗含单一路线。
- 1:直接预写唯一剧情,或把所有人物规定成同一种反应。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 每条候选已在内部通过移除检验,且缺失后果未写入产物?
- [ ] 依据的核心体验忠实来自上游,非本步新设计? - [ ] 法则内容、生效方式、因果影响与推演口径均可执行?
- [ ] 每个支撑点有归属元素、切面、如何支撑、缺失后果? - [ ] 多条机制的共同作用已写清,或明确无须关联?
- [ ] 自评含覆盖度 / 充分度 / 克制度? - [ ] 能支持不同人物作出不同但合理的反应?
- [ ] 未把身份/开场处境写成法则?未写成设定菜单、人物卡、起源史、order 表? - [ ] 未把身份或开场处境写成法则?超出基准世界的变造都来自用户材料或体验依赖?
- [ ] 宁少勿多;支撑点关系仅保留有体验作用的项(可空)? - [ ] 未写成题材标签、世界百科、剧情大纲或裁定表?
- [ ] 追问只打会改变支点的缺口,带建议选项? - [ ] 产物正文不含评分、诊断、追问?
``` ```
## examples ## examples
```examples ```examples
好: 灵气复苏:
- brief「现代都市认修炼境界与繁荣一统」;正文有依据体验 + 支撑点(修炼可越级、世界已一统)过移除检验;自评三维;无宗门百科。 - 灵气重新进入环境,生物可吸收并强化、异变;浓度、接触方式与修炼方法影响变化速度和可控程度。
- 公理支点「黑暗森林」:一句话钉「猜到即打击」;不写宇宙社会学论文。 - 因果上会改变资源价值和既有力量关系;推演人物时结合其认知、资源、身份得失与控制能力,不能默认人人都追求修炼。
- 整份落在 context-fragment.v1;支撑点切片示例:
{ 全民直播求生:
"名称": "黑暗森林", - 可拆为“强制进入求生场”“关键行为公开直播”“观众反馈可进入场内(若本局采用)”等多条机制。
"归属元素": "宇宙公理", - 公开评价与即时生存会共同改变选择代价;人物是否表演、隐藏或利用镜头,应由其是否在乎声誉、是否相信能返回现实等条件推出。
"支撑切面": "暴露坐标即招来打击,文明默认隐藏", - 观众若只能观看,就不能推演出打赏资源;边界会直接改变因果链。
"切面形态": { "结构": "一句话", "概述": "被承认的既成法则,不解释为何如此。", "展开": [] },
"对应体验": ["猜疑链下的生存紧张"], 好:
"如何支撑": "后续情节不能靠「对外广播求援」轻松收场。", - 社会常态类法则“这个西幻世界里男尊女卑是常态”:生效方式写继承、婚配、对外交涉由谁做主;因果影响写女性角色行动的额外代价与绕行渠道;推演口径写人物可能顺从、利用或反抗,不规定人人同一态度。
"缺失后果": "若可公开求援而不被打击,故事从藏匿改成外交。", - “这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气”:只写斗气是唯一超常力量、怎样决定地位与胜负,不展开境界百科。
"完备度": "90%" - 公理“黑暗森林”:一句话钉“暴露坐标即招来打击,文明默认隐藏”;后续情节不能靠对外广播求援收场,不写宇宙社会学论文。
}
坏: 坏:
- 设定菜单:「可选诅咒/寄生物/邪神…」 - 现实世界模拟普通大学生,把“主角是普通大学生”写成机制:这是身份与开场处境,交给具体实例或开场白;没有特殊法则就不使用本能力。
- 把普通大学生的身份特殊写成法则;起源史、人物卡、舞台百科 - 二创里暗中加入原作没有的力量体系(如在无超能力的原作里写入“觉醒异能”):用户未设想的变造不写入,拿不准时交给 `self_score.gap`。
- 只有散文支点、无 schema / 自评 / 追问 - 只写“灵气复苏”“末世”等题材标签,没有生效方式与因果链:主世界层无法据此推演任何后果。
``` ```
@@ -1,9 +1,5 @@
# 叙事指南 # 叙事指南
> 对话落盘技能。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 工序编排仍用「叙事指南与故事推进」;本步只钉怎么写,不钉故事怎么往前。
## meta ## meta
```meta ```meta
@@ -11,75 +7,50 @@ name: 叙事指南
id: narrative-guide id: narrative-guide
artifact: 设计.叙事指南 artifact: 设计.叙事指南
declaration: > declaration: >
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与写法档;不重做体验问卷,也不钉故事怎么往前 继承美学纲领与交互范式,从文风库选取本局写法,落成可执行的遣词要求、视角执行、段落形态、笔墨落点、少量禁忌与可选写法档;不钉故事怎么往前。
when: | when: |
已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么味道、笔墨落谁身上、场合换哪一档写法、绝对不能写什么。 已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么要求、视角怎么落笔、笔墨落谁身上、场合是否要换写法档、有哪些绝对不能写。
用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。 用户主动谈文风、措辞、叙述声音、写法档时也使用。
交互范式或故事推进已钉停笔/轮转,不构成不排本步的理由。
when_not: | when_not: |
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。 本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
美学未钉 → 先「美学纲领与交互范式」。 美学未钉 → 先「美学纲领与交互范式」。
用户只要感觉/站位、不要文风细则 → 不排,沿用美学。 用户明确说本局不用文风细则,且美学里已有可执行遣词方向 → 可不使用。
只谈故事怎么往前、用户输入用法 → 「故事推进」。 只谈推进、输入用法 → 「故事推进」。单轮字数与篇幅 → 「故事推进」;可见区域 → 「回复呈现」。版式 → 「正文组成」。
版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。
boundary: | boundary: |
本技能:一份 context-fragment,写清怎么写(遣词、笔墨、禁忌、写法档)。 本能力只交怎么写:选用文风、遣词要求(含叙述声音、视角执行、段落形态)、笔墨落点与代入取向、少量绝对禁忌、可选写法档。
故事推进:怎么往前、输入用法——本步不定。 推进、输入用法、单轮字数与篇幅归故事推进;体验契约、人称与叙事视角的定义归美学纲领与交互范式。
美学:感觉与轮转。生成规则/变量*:合同与表。
feeds: narrator,gm feeds: narrator,gm
``` ```
## opening ## opening
```opening ```opening
美学里的感觉已有方向时,请谈「正文怎么写」(想到什么写什么): 说说这局正文希望读起来像什么。可以直接点文风方向,或说不同场合要不要换一种写法。重点写“要怎么写”,只有真正碰不得的再提禁忌。
1. 像什么文风?遣词什么味道?笔墨花在谁身上?有什么绝对不能写?
2. 正文若有几种写法档要切换(官能 / 爽文 / 恐怖 / 情色这类),点名即可;没有就说没有。
``` ```
## task ## task
```task ```task
你正在执行「叙事指南」。产物必须是 **context-fragment.v1** JSON,写入「设计.叙事指南」。 把本局写法整理为叙事指南,写入 `设计.叙事指南`。先继承上游「美学纲领与交互范式」:把 `美学纲领.体验内核`、`呈现要点.描写焦点` 与 `呈现要点.边界与尺度` 改写成做法向句子;`交互范式` 已定的人称与叙事视角不重定义,只在 `遣词.视角` 写怎么执行。再用 `read_library_entry` 从绑定的文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合),按本局体验本局化,写成可执行的风格推荐与要求。
把美学里的感觉落成可执行的写法。作用对象是本局游玩正文(转述与主世界层都读这份)。 产物回答:选了哪些文风、各自用在什么场合;遣词、视角执行、段落形态与笔墨要怎么做;叙述声音与代入取向(推得出才写);只有少数绝对禁忌。除禁忌外,少写“不要怎样”,多写“要怎样”。不写示范段落,不写推进、输入用法或单轮字数。
执行顺序: 材料足够便直接成稿;真正会改写法的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
1. 若提示中有【文风库 · 可选用】且用户点名/对上某包:优先按该包落成「怎么写」,再按本局美学微调;不要重做文风问卷。
2. 否则继承美学体验与禁忌,改写成做法向句子。
3. 写纲领、遣词(含示范)、笔墨焦点、禁忌/不偏好。
遣词用户未提段落形态时,默认写入:完整长自然段叙述,不要单句成段(篇幅数字交给「回复呈现」)。
4. 情境备用 = 写法档(体裁/调性容器),不是具体桥段。有切换则每条写:名字=档名(如「官能小说」「网文爽文」「展示恐怖」「展示情色」),用法=该档怎么遣词、笔墨落哪、何时切进来。无切换则 `[]`。用户若报出「当众折辱」「独处一室」这类场合,升一档写进对应写法档,场合本身不单列。
5. 自评 + 追问。故事怎么往前、用户输入用法交给「故事推进」,本步不写。
summary:`叙事指南 · …`
``` ```
## principles ## principles
```principles ```principles
1. 感觉 vs 做法:美学写要什么爽;本步写怎么遣词、笔墨落哪。 1. 美学写要什么感受,本步写怎么遣词、笔墨落哪。已在美学写清的感受不重问,直接改写成做法:体验内核与描写焦点落成详写对象和写法要求;边界与尺度按美学给的用法写进写法要求或写法档,只把其中真正碰不得的收进禁忌。
2. 遣词与禁忌要可执行;绝对禁忌 ≠ 不偏好。 2. 文风库是参考,不是照抄:读取后改成本局可执行要求,并记下适用场合与微调。
用户未提及时:完整长自然段,不要单句成段。 3. 优先 1~2 种文风;确需第三种时,每种都必须有清晰切换场合,禁止无差别堆叠。
3. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。 4. 写法用“风格推荐 + 要求”表达:对象、节奏、注意力落点、句段形态都写成要做什么。示范段落不写。
4. 字数区间归「回复呈现」;推进与输入用法归「故事推进」。 5. 段落形态默认值:用户未提段落形态时,`遣词.写法要求` 写入「完整长自然段叙述,不要单句成段」;用户另定则按用户写。段落形态归本步,单轮字数与篇幅归故事推进。
``` 6. 视角只写执行:人称、主要视角、可写内心、不呈现的信息以交互范式为准,本步写落笔做法——镜头距离(贴身 / 中景 / 远观,何时拉近);内心写到哪(只写交互范式允许写内心的一方,其余人物用外显言行与身体反应表现);如何不漂人称(全文固定一种称呼,回忆、转述与内心独白也不换人称)。交互范式未定视角时按美学站位取最贴的写法,并在 gap 写明。
7. `叙述者` 写叙述声音:冷眼旁观、调侃、贴身低语等,以及它对人物的态度。`代入取向` 写笔墨替谁看、读者站在谁那边。两项都从美学站位与体验推出;推不出或与视角执行重复时省略。
## probe 8. 绝对禁忌只留真正碰不得的少数几条;其余偏好都改成正向做法。
9. 写法档是多文风/多调性容器:有切换才写,无切换整项省略。场合名要能指导何时切入,不是具体剧情桥段清单。用户报出「当众折辱」「独处一室」这类具体场合时,判断它要哪种写法,升一档写进对应写法档的适用场合与写法要求(感官密度、节奏、对话长短、详略),场合本身不单列;每档写清切入与切回。
```probe 10. 不写故事推进、输入用法、单轮字数与篇幅(归故事推进)或版式壳。
本步只钉三件:遣词可执行、笔墨落点、绝对禁忌(含写法档有无切换)。
已在美学写清的感觉改写成做法向句子,不重问「想感受到什么」。
用户未提段落形态时:写入长自然段默认并简短确认即可。
缺遣词/笔墨/禁忌其中一件:在产物「追问」写 1~2 点(建议选项 + 短句示例)。三件够验收:`追问.题目` 为 `[]`。
用户谈到推进、停笔、大纲扩写:写入「开放问题」,交给「故事推进」。
用户谈到变量增减、生成规则、舞台百科:只抽取能改遣词 / 禁忌的一句,其余写入「开放问题」。
用户提到要换写法:收成写法档写入「情境备用」(官能 / 爽文 / 恐怖 / 情色这类),不追问具体场合怎么摆;没提则 `[]`。
字数问「回复呈现」;本步不追问字数。
``` ```
## output ## output
@@ -88,74 +59,104 @@ summary:`叙事指南 · …`
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "叙事指南", "技能": "叙事指南",
"brief": "一句话:体裁 + 笔墨重心", "brief": "一句话:本局正文怎么写",
"mount": ["narrator", "world-simulator"], "mount": ["narrator", "world-simulator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据的体验": ["…"], "选用文风": [
"叙事纲领": { {
"核心定位": "…", "库条目": "style-packs 中的 id 或名称",
"叙事使命": "…", "适用场合": "默认全文 / 亲密场面 / 战斗反转 等",
"叙事追求": ["…"] "本局要求": "从该条目提炼并改写后的可执行写法要求"
}, }
"风格与遣词": { ],
"叙述者人格": "…", "遣词": {
"整体基调": "…", "整体取向": "本局叙述应呈现的风格方向",
"遣词造句": ["原则 - 做法 → 目的"], "叙述者": "可选:叙述声音,如冷眼旁观 / 调侃 / 贴身低语,及它对人物的态度;无则省略",
"示范": [{ "情境": "常态", "段落": "…" }] "风格推荐": ["优先采用的节奏、用词层级、句段形态"],
"写法要求": [
"完整长自然段叙述,不要单句成段(用户另定段落形态则改写)",
"要写到什么密度、如何处理对话与叙述、注意力放哪"
],
"视角": {
"镜头距离": "如:贴身跟随 user,冲突时拉近到手与眼神,转场时拉远一句带过",
"内心": "如:只写 user 的内心;NPC 只写外显言行与身体反应",
"人称": "如:全文第二人称「你」,回忆与内心独白不换人称"
}
}, },
"笔墨焦点": { "笔墨焦点": {
"观众与代入取向": "…", "代入取向": "可选:笔墨替谁看、读者站在谁那边;无则省略",
"详写优先": ["…"], "详写": ["应花笔墨的对象与感受"],
"可略写": ["…"] "可略": ["可一带而过的部分"]
}, },
"禁忌与不偏好": { "禁忌": ["仅少数绝对不能写的内容;无则省略"],
"绝对禁忌": ["…"], "写法档": [
"不偏好": ["…"]
},
"情境备用": [
{ {
"名字": "展示情色", "名字": "展示情色",
"用法": "切到身体与权力交换时:感官词加重、节奏放慢、对话变短;切回日常则收。" "适用场合": "切到身体与权力交换时;用户报的「独处一室」这类场合升到这一档",
"写法要求": "感官词加重、节奏放慢、对话变短;切回日常则收"
}, },
{ {
"名字": "网文爽文", "名字": "网文爽文",
"用法": "打脸/反转时:可复述对方原话、结果写满、过程可略。" "适用场合": "打脸、反转时;用户报的「当众折辱」之后的反击升到这一档",
"写法要求": "可复述对方原话、结果写满、过程可略;反转落定后切回默认文风"
} }
], ]
"内容与表达": { }
"视角执行": "…",
"段落形态": "默认:完整长自然段叙述,不要单句成段;用户另定则改写",
"场景详略映射": ["…"]
}
},
"自评": {
"维度": [
{ "名": "写法可执行", "分数": 8, "说明": "…" },
{ "名": "与美学对齐", "分数": 8, "说明": "…" }
],
"薄弱点": ""
},
"追问": { "导语": "", "题目": [] },
"开放问题": []
} }
``` ```
硬规则:合法 JSON;示范至少 1 段;禁忌与不偏好都要有;禁止 order;summary 见 task。 硬规则:
情境备用若有:名字必须是写法档(官能小说 / 网文爽文 / 展示恐怖 / 展示情色 同级),用法写该档的遣词与切入口;不是「当众折辱」「物资告急」这类具体桥段。无切换则 `[]`。 1. 输出合法 JSON;`选用文风` 默认 1~2 项,最多 3 项,每项必须有适用场合与本局要求。
不写「推进与决策」。 2. 不写示范段落、示例正文或仿写样章。
3. 除 `禁忌` 外,正文以正向要求为主;空话氛围词改成可执行做法。
4. 用户未定段落形态时,`遣词.写法要求` 必含「完整长自然段叙述,不要单句成段」。
5. `遣词.视角` 只写交互范式人称与视角的执行,不改人称、不扩大可写内心的范围。`叙述者`、`代入取向` 可选,无则省略。
6. `写法档` 无切换时整项省略;有则每档写清场合与写法要求;具体场合升档写入,不单列桥段。
7. 不在产物正文写自评、追问、开放问题,或推进、输入用法、单轮字数。
8. `mount` 固定包含 `narrator` 与 `world-simulator`。
## score
```score
遣词可执行:
- 5:风格推荐与要求具体到能直接落笔,无需再猜味道;视角执行与段落形态写清。
- 3:大方向清楚,但仍有几处停留在氛围概括,或视角执行含糊。
- 1:只有题材或情绪标签,无法指导遣词。
笔墨与禁忌:
- 5:详略落点清楚,贴合美学的描写焦点与边界尺度;绝对禁忌少而可执行;其余多为正向要求。
- 3:落点大体有,但禁忌偏多或详略仍含糊。
- 1:几乎不谈笔墨,或堆满否定句却无正向做法。
文风选用:
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚,具体场合已升档写入,无重复堆叠。
- 3:选用相关,但场合区分弱,或有一条不够贴。
- 1:未读库硬编,或多种文风无差别并列。
本局化:
- 5:库内容与美学感受已改成本局说法与要求,能看出针对本局体验的取舍。
- 3:有微调,但仍有整段近乎原文搬用,或重问了美学已写清的感受。
- 1:基本照抄库条目,或与本局体验脱节。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 遣词+示范+禁忌够用? - [ ] 已继承美学的体验、描写焦点与边界尺度,改写成做法?
- [ ] 情境备用若有,是否是写法档而非具体桥段?无切换是否为 `[]`? - [ ] 已按需读取文风库并本局化?
- [ ] 未把推进/输入用法写进本步? - [ ] 选用文风 1~2(最多 3),各有适用场合?
- [ ] 遣词是风格推荐与要求,不是示范段落?用户未定段落形态时已写长自然段默认?
- [ ] 视角只写执行,未改交互范式的人称与可写内心?
- [ ] 具体场合已升档写入写法档,未单列桥段?
- [ ] 除少数禁忌外,多为“要怎样”?
- [ ] 未写入推进、输入用法、单轮字数或版式?
- [ ] 产物正文不含评分、诊断、追问?
``` ```
## examples ## examples
```examples ```examples
好:一次钉清遣词 + 笔墨落点 + 禁忌;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。 好:美学的「掌控感」落成详写对方的迟疑与屈从、user 的动作从容简短;视角写「第二人称贴身镜头,只写你的内心,NPC 只写外显」;写法要求含长自然段默认;用户提的「当众折辱」升进「网文爽文」档,写清切入与切回。
坏:追问好感公式 / 事件池 / 大纲扩写还是扮演停笔;或把「当众折辱」「独处一室」当成情境备用。 坏:重新定义人称或扩大可写内心;把「当众折辱」「独处一室」列成桥段清单;写示范段落;在本步写单轮字数或推进方式。
``` ```
@@ -36,9 +36,10 @@ feeds: narrator,gm
1. 像什么文风?遣词什么味道?笔墨花在谁身上?有什么绝对不能写? 1. 像什么文风?遣词什么味道?笔墨花在谁身上?有什么绝对不能写?
2. 故事往前靠什么:场面自己推、等用户决策,还是无专门推进?正文若有几种写法档要切换(官能 / 爽文 / 恐怖 / 情色这类),点名即可;没有就说没有。 2. 故事往前靠什么:场面自己推、等用户决策,还是无专门推进?正文若有几种写法档要切换(官能 / 爽文 / 恐怖 / 情色这类),点名即可;没有就说没有。
3. 用户每轮输入怎么用?未提及时按体验二选一写入(可简短确认,不必当缺口追问): 3. 用户每轮输入怎么用?未提及时按体验三选一写入(可简短确认,不必当缺口追问):
- **大纲扩写**(写手/成稿向):输入=待扩写为事实的大纲;细化完全后写正文;不超出大纲未涉及的剧情,完整演绎大纲。 - **大纲扩写**(写手/成稿向):输入占比高=待演成场面的大纲。节拍上的人、事、话、结果不动;节拍之间补具体动作把处境演出来。拓写宜充分;续写段宜短。
- **扮演停笔**(代入扮演、等用户决策):用户本轮行为与对话作正文开头并扩写填满;不可写用户角色未给出的其它行为/对话;不可替用户角色做决定;正文停在其他角色等用户角色回应之刻。 - **续写**(代入扮演、等用户决策):输入往往较短;拓写跟已声明内容;续写多于大纲扩写、短于自动写入。
- **自动写入**(短指令驱动):输入=短需求(meta 或极简扮演);续写宜长;可按主角设定代写用户角色。
``` ```
## task ## task
@@ -57,9 +58,10 @@ mount:`narrator` 与 `world-simulator` 都挂同一份全文——不要为省
4. 情境备用 = 写法档(体裁/调性容器),不是具体桥段。有切换则每条写:名字=档名(如「官能小说」「网文爽文」「展示恐怖」「展示情色」),用法=该档怎么遣词、笔墨落哪、何时切进来。无切换则 `[]`。用户若报出「当众折辱」「独处一室」这类场合,升一档写进对应写法档,场合本身不单列。 4. 情境备用 = 写法档(体裁/调性容器),不是具体桥段。有切换则每条写:名字=档名(如「官能小说」「网文爽文」「展示恐怖」「展示情色」),用法=该档怎么遣词、笔墨落哪、何时切进来。无切换则 `[]`。用户若报出「当众折辱」「独处一室」这类场合,升一档写进对应写法档,场合本身不单列。
5. 写推进与决策(可写「无专门推进」);变量只写钩子。 5. 写推进与决策(可写「无专门推进」);变量只写钩子。
「用户输入用法」未提及时:按美学/交互范式选一种写入产物—— 「用户输入用法」未提及时:按美学/交互范式选一种写入产物——
- 写手/大纲成稿 → **大纲扩写**(输入=大纲→细化成正文;不越界、完整演绎); - 写手/大纲成稿 → **大纲扩写**;
- 代入扮演、轮转等用户决策 → **扮演停笔**(输入的行为与对话作正文开头并扩写填满;不写用户角色未给出的行为/对话;不替用户角色做决定;停在其他角色等回应之刻)。 - 代入扮演、轮转等用户决策 → **续写**;
两种都像则写进追问让用户点一次;不要混成半套规则。 - 短指令/meta、希望 AI 代演用户角色 → **自动写入**。
多种都像则写进追问让用户点一次;不要混成半套规则。产物须装载对应准则(见 principles)。
6. 自评 + 追问。 6. 自评 + 追问。
summary:`叙事指南与故事推进 · …` summary:`叙事指南与故事推进 · …`
@@ -72,9 +74,17 @@ summary:`叙事指南与故事推进 · …`
2. 遣词与禁忌要可执行;绝对禁忌 ≠ 不偏好。 2. 遣词与禁忌要可执行;绝对禁忌 ≠ 不偏好。
用户未提及时:完整长自然段,不要单句成段。 用户未提及时:完整长自然段,不要单句成段。
3. 推进可轻可无;表规格交给变量/生成规则。 3. 推进可轻可无;表规格交给变量/生成规则。
用户输入用法两套可选默认(未提及时择一,勿混用): 用户输入用法三选一(未提及时择一,勿混用)。差别在输入量、拓写/续写篇幅与是否代演用户角色。
- 大纲扩写:输入=大纲→扩写为事实→细化后正文;不发明大纲未涉及的剧情,完整演绎大纲。
- 扮演停笔:用户本轮行为与对话→正文开头并扩写填满;禁止描述用户角色未输入的其它行为/对话;禁止替用户角色做决定;正文停在其他角色需要用户角色回应之刻。 【拓写 / 续写准则】(大纲扩写、续写 必装;自动写入不装「禁代演」)
- 拓写(大纲扩写):输入是待演成场面的大纲,不是终稿句子。已写明的人、事、话、结果保持不变,每个节拍都要演到。节拍之间补具体动作、物件和当场的小纠正,把场合里已有的处境演出来;大纲没写这些动作也要写。例:大纲「我去相亲、坐了下来,她说要婚后自由出轨权,我拒绝了」——坐下前写先要一杯咖啡,又急忙改成两杯,显出局促;她的要求和拒绝的结果不动。只加形容词、比喻或气氛、不增加动作,不算拓写。另起新冲突、新目标,或改掉已定结果,才算出界。
- 拓写(续写):只写用户本轮已声明的行为与对话;可补该动作怎么发生、他人怎么接;不另写用户没声明的新决定。
- 续写:不可再写用户角色的实际行动/说话/带倾向抉择;最多被动占位(望去/让开);禁止替用户角色做决定。整轮停在其他角色等用户角色行动抉择之刻。
- 篇幅:大纲扩写→拓写充分、续写短;续写用法→续写多于大纲扩写、短于自动写入。
- **大纲扩写**:输入=大纲→按上列大纲拓写演成正文,节拍之间补具体动作;装载上列准则。主角设定偏好:客观为主、少性格。
- **续写**(旧称扮演停笔):行为与对话作拓写起点;装载上列准则;主角设定偏好同大纲扩写。
- **自动写入**:短 meta/极简扮演→长续写;允许按主角设定代写用户角色;倾向详钉性格。
4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。 4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。
5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进」,不要在本步执行时再拆技能。 5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进」,不要在本步执行时再拆技能。
6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。 6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。
@@ -88,7 +98,7 @@ summary:`叙事指南与故事推进 · …`
已在美学写清的感觉改写成做法向句子,不重问「想感受到什么」。 已在美学写清的感觉改写成做法向句子,不重问「想感受到什么」。
用户未提段落形态时:写入长自然段默认并简短确认即可。 用户未提段落形态时:写入长自然段默认并简短确认即可。
用户未提输入用法时:按体验选「大纲扩写」或「扮演停笔」写入产物并简短确认;两者都像才追问二选一,不必当成大缺口。 用户未提输入用法时:按体验选「大纲扩写」「续写」或「自动写入」写入产物并简短确认;多种都像才追问点选,不必当成大缺口。
缺遣词/笔墨/禁忌/推进其中一件:在产物「追问」写 1~2 点(建议选项 + 短句示例)。四件够验收:`追问.题目` 为 `[]`。推进方式不明则正文写「无专门推进」,不问好感公式、进度表或事件池。 缺遣词/笔墨/禁忌/推进其中一件:在产物「追问」写 1~2 点(建议选项 + 短句示例)。四件够验收:`追问.题目` 为 `[]`。推进方式不明则正文写「无专门推进」,不问好感公式、进度表或事件池。
@@ -141,10 +151,11 @@ summary:`叙事指南与故事推进 · …`
"推进与决策": { "推进与决策": {
"有无专门推进": "有|无", "有无专门推进": "有|无",
"驱动力来源": "…", "驱动力来源": "…",
"用户输入用法": "择一写入。大纲扩写:输入=待扩写为事实的大纲;细化后写正文;不超出大纲未涉及处,完整演绎。扮演停笔:用户本轮行为与对话作正文开头并扩写填满;不可写用户角色未给出的其它行为/对话;不可替用户角色做决定;停在其他角色等用户角色回应之刻。用户另定则改写", "用户输入用法": "择一写入完整规格。大纲扩写:节拍上的人事物结果不动,节拍之间补具体动作把处境演出来(相亲坐下前先点一杯又改两杯显局促;只加形容词不算拓写);续写段短。续写:拓写跟已声明行为、不另写新决定;续写多于大纲扩写。自动写入:短 meta/极简扮演→长续写;可按主角设定代演用户角色。用户另定则改写",
"叙事速度与详略": "…", "叙事速度与详略": "…",
"成败与难度": "…", "成败与难度": "…",
"变量门控钩子": [] "变量门控钩子": [],
"主角设定偏好": "大纲扩写|续写→客观为主、少性格;自动写入→详性格;无主角卡则空字符串"
}, },
"内容与表达": { "内容与表达": {
"视角执行": "…", "视角执行": "…",
@@ -180,6 +191,6 @@ summary:`叙事指南与故事推进 · …`
## examples ## examples
```examples ```examples
好:一次钉清遣词 + 笔墨落点 + 禁忌;推进写「等用户决策」或「无专门推进」;输入用法按体验钉「大纲扩写」或「扮演停笔」之一;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。 好:一次钉清遣词 + 笔墨落点 + 禁忌;推进写「等用户决策」或「无专门推进」;输入用法按体验钉「大纲扩写」「续写」或「自动写入」之一;大纲扩写写明节拍之间补具体动作、只加形容词不算拓写;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。
坏:拆成「文风技能」和「推进技能」两步;把两套输入用法混成半套;追问好感公式 / 事件池;或把「当众折辱」「独处一室」当成情境备用。 坏:拆成「文风技能」和「推进技能」两步;把多种输入用法混成半套;续写用法下仍代玩家做决定;自动写入却禁止一切扮演玩家;追问好感公式 / 事件池;或把「当众折辱」「独处一室」当成情境备用。
``` ```
@@ -1,230 +1,174 @@
# 开场白与开场变量 # 开场白与开场变量
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> **创作终节点**:规格与呈现契约已齐。写出 1~多条开场(每条 = meta 初值/角色 + present.v1 正文)供选定;选定后程序落库、关闭工作流、保存定稿。
> 不必再调磁盘 worker `opening-generator`(无本步时它仍可作兜底落库)。
## meta ## meta
```meta ```meta
name: 开场白与开场变量 name: 开场白与开场变量
id: opening-setup id: opening-setup
artifact: 设计.开场白与开场变量 artifact: 设计.开场白
declaration: > declaration: >
创作终节点:写出 1~多条开场(每条 = meta 开场变量初值 + 正文 present.v1,壳与正文组成一致;须含字面 @玩家);选定后落库、结束创作并保存定稿。用户代入名片走「主角设定」,勿把名字/简介写进开场正文。 普通楼层创作终节点,且可增殖:每条开场是一个节点,落成 `设计.开场白#场景短码`。
默认写 1 条;用户要多个入口时再增殖。每条自带 present.v1、同真相初态与这条开场的最少玩家角色事实。
全部保存。游玩时左右切换,选中的一条作为 0 层。
when: | when: |
收成链已齐(至少体验契约可引用,且「细化终稿」已产出或可立即收成运行规格),需要可开玩的第一段剧情与同真相初值。 回复呈现、正文组成和其它本局内容能力均已完成,且程序已依次编译信息可见范围、游玩执行方式、上下文排序与运行配置后,需要可开玩的第一屏时使用。
排在收成之后作最后一步。用户要进游玩但还没有第一屏 → 排。
when_not: | when_not: |
体验/呈现仍混沌 → 先「美学纲领与交互范式」与「回复呈现」「正文组成」等。 语义区域或前端模板未钉 → 先回复呈现、正文组成。
只要改文风不要开场 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。 `设计.worker集` 尚未由程序组装 → 先运行收口工具链。
只要改真值规则不要开场文 → 「变量设计与更新规则」。 只改文风或真值规则 → 叙事指南 / 变量设计。
运行规格未齐 → 先「细化终稿」。尚未选定开场前若要补前序节点(如 NPC)→ 插在本步之前,勿以「收口已排入」拒绝追加。 要做持久 0 层开场 → 0层开场白与初态,不在本步兼顾。
boundary: | boundary: |
本技能是创作终节点:1~多条开场。每条 = meta(开场变量初值)+ 正文(present.v1,shell 与「设计.正文组成」一致)。 本能力只写开场候选与各候选初态。不重开篇幅、文风、区域清单或前端模板。
用户代入名片优先「设计.主角设定」;本步可不写用户角色。旧稿若在 meta 带了用户角色仍可读,但不要再往开场正文夹名字/简介。 只填正文组成声明为模型生成的 blocks;状态与拓扑由程序按候选初态投影。
初值与该条叙述同真相。必须遵守已验收的叙事指南、回复呈现、正文组成、变量设计;不重做这些契约。正文不得只交一段散文。 随机数是变量表隐藏行:本步不写具体数字。0 层玩法不依赖随机;普通楼层在第一次正式回复时由程序补齐随机行。
具体实例:开场可引用已有实例名片,不在本步新造百科。 每条开场单独落盘。游玩时左右切换,选中的一条作为 0 层;创作里不把入口收成一道先选定的题。
选定后由程序写入 输出.开场白 / 运行.初始变量 / 运行.开场用户角色 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。 feeds: gm,narrator
feeds: narrator
``` ```
## opening ## opening
```opening ```opening
设定已齐时,用几句话定开场第一拍: 设定已齐时,用几句话说清第一拍:玩家进场时在哪、正发生什么、结束后显然能做什么。默认写一条开场。还要别的入口时,每条再增殖一个开场节点;进游玩后左右切换,选中的一条作为 0 层。
1. 玩家睁眼/进场时在哪、正发生什么?
2. 开场结束时,TA 显然可以做什么?
3. 此时各真值大概是多少?(能推断的直接说;推不出的标「要问」)
``` ```
## task ## task
```task ```task
你正在执行「开场白与开场变量」。这是创作最后一步。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。 读取程序已组装的 `设计.worker集` 和全部最终层产物,把每一张开场写成 `设计.开场白#场景短码`。这是普通楼层创作终节点;不得在本步首次整理执行单元或上下文顺序。
主产物:1~3 条可开玩开场(选定后落库结束创作)。每条必须是 **meta + 正文** 两截,不是一段散文。 position 必须带场景短码,例如 `设计.开场白#opening-cycle2`。改某一条就再 insert 同一个完整 tag。裸 `设计.开场白` 和整包 `设计.开场白与开场变量` 会和带短码的卡抢同一条写入,不要使用。
**正文 = 正文组成的输出格式(强制):** 默认只生成 1 条,仍然要带场景短码。用户要多个入口时,每条再 insert 一个新的短码。不要把多条入口收成聊天里的选择题,也不要把多条塞进同一次 insert。游玩时按这些短码左右切换,选中的一条作为 0 层,并整套换该条正文、初态与角色。
- 读「设计.正文组成」的 `呈现壳.shell_id` 与可见区域。
- 每条 `正文` 必须是 **present.v1 对象**(与游玩终稿、正文组成「示例.灌数」同形)。`shell` 必须等于该 shell_id。只填该壳已有区域;没有的键省略或空字符串。
- 用户主读写在 `blocks.body`;监控栏初值写在 `blocks.monitor`(短键值,勿长文);建议行动写在 `正文.meta.suggested_actions`。
- prose 壳也要包成 present.v1(`blocks.body`),不要只交裸 Markdown。
- 开场比一章短:愿点进游玩即可,不必凑游玩默认 1000~2000 字。
**meta = 这条开场的初始设定:** 每一张卡携带:
- `开场变量`:与该条叙述同真相的初值(名与变量设计一致)。顶层再抄一份推荐条的初值,供程序落库。 - 符合正文组成数据契约的 `present.v1`(只填模型生成区)
- `用户角色`:可选;优先已由「主角设定」钉好。若本条必须自带名片且尚无主角设定,可填名字+简介(程序会抬到主角设定节点)。禁止把名字/简介写进 `开场白全文` / 候选正文。 - 可选一句区分说明
- 这一条开场的玩家角色 `meta.用户角色`(名字 + 简介):不同开场的玩家可以不同,写在各自卡里;程序会同步成同短码的主角设定,游玩切到这条时替换 `@玩家`
- 与该条叙述同真相的变量初值:这张卡有要开场定下的变量才写 `开场变量`;没有就不写,卡仍然是 `设计.开场白#场景短码`
- 若本局有拓扑:当前位置/焦点与已揭示范围
**用户角色占位(产物规格,不是可选项):** 硬规格:
- `开场白全文`、每条候选 `正文`(含 `blocks.body` 及其它有字区域)里,凡指称用户扮演角色,第三人称点名处必须写字面量 `@玩家`(六个字符:`@` + `玩家`)。 1. 可见正文里凡指称玩家角色的名字位都写字面 `@玩家`,每条至少出现一次。「你」可以和 `@玩家` 并用(如「你(@玩家)站在闸机前」),但全文只有「你」、零个 `@玩家` 不合格;不写死具体姓名,也不用「用户」「主角」顶名字位。
- 每条候选正文至少出现一次 `@玩家`。禁止写死具体姓名(如林晚、阿泽);禁止用「用户」「主角」顶名字位。 2. `present.v1` 的区域只覆盖正文组成声明的模型生成 blocks;程序投影区留空给程序灌。
- 「你」可与 `@玩家` 并用;若全文只有「你」、零个 `@玩家`,视为不合格,改写后再交。 3. 不输出 `名称.随机数` 的具体数组;随机行由程序在第一次正式回复前或回复时补齐。
- 进游玩时程序把 `@玩家` 换成当前用户角色名;创作稿必须保留字面 `@玩家`。 4. 开场宜短,愿点进游玩即可,不抄故事推进的单轮篇幅预算当第一章。
5. 人物、地点、势力只引用已有产物里的;第一拍需要的路人只写身份,不在开场新造有名有姓的设定。
执行顺序: 材料足够便直接成稿;会改变第一拍成立的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
1. 读依赖:worker 集/美学/叙事/**正文组成(先抄 shell_id)**/监控栏/变量设计/舞台与实例/主角设定。
2. 写 1~3 条候选:每条 `meta`(开场变量;用户角色仅在缺主角设定时补)+ `正文`(present.v1)。同真相、第一拍可不同。
3. 推荐条的 present.v1 同时写入 `开场白全文`(对象,不是字符串)。
4. 顶层 `开场变量` = 推荐条 meta 里那份初值。冲突以叙述为准。
5. 交稿前自检:每条 `正文.shell` 与正文组成一致;JSON 搜索 `@玩家`;正文无「用户角色.名字」行;再自评 + 追问;输出 JSON。
缺上游契约时:能写则按已有材料写开场并标开放问题;不能成立第一拍则追问 1~2 点,勿交空壳。
summary:`开场白与开场变量 · …`(点题场景)。
``` ```
## principles ## principles
```principles ```principles
1. 开场为主,表为配套。 1. 开场为主,初态配套。叙述与该条变量、拓扑初态必须同真相;写完发现两者对不上时以叙述为准,改初值。
2. 正文 = present.v1,壳与「设计.正文组成」一致;文风跟叙事指南。尚未钉壳时用 prose 包一层 present.v1,并标开放问题。 2. 默认一条。多条是不同入口,每条一个 `设计.开场白#场景短码`,不是同文换皮,也不是先让用户选唯一开场。没有变量的卡省略 `开场变量`,短码照旧。
3. 同真相:该条 meta.开场变量 与该条叙述一致。 3. 玩家角色最少化:把这个角色交还给用户扮演。`用户角色.简介` 只写这条开场成立所必需的客观事实——身份、处境、与在场人物的已知关系,一到三句;不写性格、心理、动机、口癖、外貌细节,也不替玩家定态度或下一步。共通的主角设定已有的事实不重抄,只写这条开场独有或与它不同的部分。
4. 可行动:第一拍结束有下一步空间;写入 suggested_actions(该壳开行动时)。 4. 用户没给名字时不编名字,省略 `用户角色`,正文照用 `@玩家`;这条开场必需的身份事实写进叙述里让玩家看见。
5. 短:愿点进游玩,非第一章。 5. 只写模型生成区。状态芯片、地图等权威显示由程序投影,不在开场正文里重编一份。
6. 不剧透隐藏档。 6. 文风、视角和人称跟叙事指南与交互范式;不剧透隐藏档,不写成第一章,不在本步改前端模板。
7. 用户角色名位 = 字面 `@玩家`(见 task 规格);不合格则改写。 7. 本步结束后创作收口;不要再排后续内容能力。
``` ```
## probe ## probe
```probe ```probe
开场地点/冲突不清;某真值开场值推不出;壳区域是否都要填;候选是否漏写 `@玩家` 或写成了裸散文。一次 1~2 点。 每轮 1~2 点。优先确认地点与冲突、某真值开场值是否推得出、多条时入口差异是否成立、每条是否含 `@玩家`。不追问前端重设计或随机骰面。
``` ```
## output ## output
```output ```output
position: 设计.开场白#场景短码
content:
{ {
"schema": "context-fragment.v1", "schema": "present.v1",
"技能": "开场白与开场变量", "shell": "与正文组成一致",
"brief": "一句话:开场场景 + 关键初值", "blocks": {
"mount": ["narrator"], "body": "清晨,@玩家推开门。……",
"稳变": "stable", "其余模型生成区": "read_module 回包 reply_contract.model_blocks 里的每一项都要出现,类型按契约"
"正文": { },
"依据契约": [ "meta": {
"正文组成 / 叙事指南 / 监控栏 / 变量设计 等已遵循要点;写出所用 shell_id" "区分": "多条时写这一入口和其它条差在哪;仅一条可省略",
],
"用户角色": { "用户角色": {
"名字": "沈聿", "名字": "用户给过的名字;没给就省略整个用户角色",
"简介": "本地放贷人;与这对母女相识但不熟。" "简介": "一到三句客观事实:这条开场里玩家是谁、处境如何、与在场人物的已知关系"
}, },
"开场白全文": {
"schema": "present.v1",
"shell": "与设计.正文组成.呈现壳.shell_id 一致",
"blocks": {
"monitor": { "地点": "堂屋", "债额": "三两" },
"body": "清晨,@玩家推开堂屋门,债契还摊在桌上。……"
},
"meta": { "suggested_actions": ["问债", "先坐下"] }
},
"开场白候选": [
{
"meta": {
"用户角色": { "名字": "沈聿", "简介": "本地放贷人;与这对母女相识但不熟。" },
"开场变量": [
{ "名": "债额", "值": 3, "依据": "桌上债契" }
]
},
"正文": {
"schema": "present.v1",
"shell": "与开场白全文.shell 相同",
"blocks": {
"monitor": { "地点": "堂屋", "债额": "三两" },
"body": "清晨,@玩家推开堂屋门,债契还摊在桌上。……"
},
"meta": { "suggested_actions": ["问债", "先坐下"] }
}
}
],
"开场变量": [ "开场变量": [
{ {
"名": "与变量设计真值名一致", "名": "与变量设计真值名一致",
"值": "初值", "值": "初值",
"依据": "开场事实/设定推断" "依据": "开场事实"
} }
], ],
"隐藏段开场": "通常空;若正文组成钉了隐藏段且开场必须写出,写入 present.v1 blocks.hidden", "拓扑初态": [
"落库提示": {
"输出.开场白": "推荐条 正文(present.v1 JSON)",
"运行.初始变量": "该条 meta.开场变量 → 表格式",
"运行.开场用户角色": "该条 meta.用户角色 → 名字+简介",
"变量.当前": "与初始变量对齐"
},
"用户可行动空间": "开场结束后玩家显然能尝试什么"
},
"自评": {
"维度": [
{ {
"名": "契约符合", "topology_id": "有拓扑时填写",
"分数": 8, "当前位置": "节点 id",
"说明": "是否 present.v1、shell 与正文组成一致、叙事/变量同真相" "已揭示": ["节点或连接 id"]
},
{
"名": "同真相",
"分数": 8,
"说明": "开场叙述与开场变量是否一致"
},
{
"名": "可开玩",
"分数": 8,
"说明": "是否短、可感、有下一步行动空间"
},
{
"名": "玩家占位",
"分数": 8,
"说明": "每条候选是否含字面 @玩家、无写死姓名"
},
{
"名": "版式符合",
"分数": 8,
"说明": "是否 present.v1,shell 与正文组成一致,区域未超出该壳"
} }
], ],
"薄弱点": "一句话" "suggested_actions": ["若正文组成启用文字选项则给短建议;否则 []"]
}, }
"追问": {
"导语": "为钉准开场,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A", "选项B", "其它(请写明)"],
"示例": "可选"
}
]
},
"开放问题": []
} }
```
硬规则: 硬规则:
1. context-fragment.v1;自评含「玩家占位」与「版式符合」。 1. 每次 insert 只写一张卡。position 是 `设计.开场白#场景短码`,content 是这一张的 `present.v1`。
2. `开场白全文` 与每条候选 `正文` 必须是 present.v1 对象(含 `schema`/`shell`/`blocks.body`);`shell` 与「设计.正文组成」一致。禁止只交散文字符串。 2. 这张卡有初值才写 `meta.开场变量`;没有变量就省略该字段,position 仍带场景短码。
3. `开场白候选` 1~3 条,每条含 `meta`(开场变量与该条同真相;用户角色可选,优先主角设定)+ `正文`。顶层 `开场变量` 必填(推荐条初值)。 3. `present.v1` 只含正文组成允许模型生成的 blocks;可见正文须含字面 `@玩家`。
4. 每条正文(含 body)必须含字面 `@玩家`;不发明新真值名。 4. 不同卡可有不同用户角色与初态;切换时整套替换,不混用。
5. summary:`开场白与开场变量 · {brief 缩略}`。 5. 不输出随机数具体值,不重写前端,不把程序投影区填成模型编造状态。
6. 产物正文不含自评、追问或开放问题。
7. 玩家角色只写在 `meta.用户角色`,名字不进 blocks 正文;程序据此写入同短码的 `设计.主角设定#场景短码`,不要另 insert 一份。
```
## score
```score
开场必要:
- 5:第一拍清楚、有行动空间,短到愿意开玩。
- 3:能开玩,但入口或下一步略含糊。
- 1:像第一章摘要,或缺冲突与下一步。
前端符合:
- 5:present.v1 只填模型生成区,且与正文组成契约一致。
- 3:主体符合,有一个次要区域或空态处理含糊。
- 1:裸散文,或把程序投影区填成模型重编数据。
状态自洽:
- 5:每条候选的变量与拓扑初态都与该条叙述同真相,互不串味。
- 3:默认条自洽,备选条有一处初态含糊。
- 1:叙述与初值冲突,或多条共用一套却声称不同入口。
角色占位:
- 5:每条含字面 `@玩家`;`用户角色.简介` 只有这条开场必需的客观事实,玩家性格与决定留给用户。
- 3:占位正确,但简介多写了性格、动机或与共通主角设定重复的内容。
- 1:写死姓名、全文只有「你」没有 `@玩家`,或简介替玩家定了性格和态度。
收口可切换:
- 5:默认条明确,全部候选可保存;多条时入口差异成立。
- 3:能收口,但多条差异偏弱。
- 1:默认不清,或未要求多条却堆出重复候选。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 1~3 条候选,推荐条 正文 = `开场白全文`(均为 present.v1)? - [ ] position 是 `设计.开场白#场景短码`,没有另写裸 `设计.开场白` 或 `设计.开场白与开场变量`?
- [ ] `shell` 与「设计.正文组成」一致?只填该壳已有区域?不是裸散文? - [ ] 默认 1 条;仅用户要求时才再增殖别的短码?
- [ ] 每条候选可搜到 `@玩家`?无写死姓名? - [ ] 有初值才写 `开场变量`;没有变量就省略,卡名不变?
- [ ] 该条 meta 初值与叙述同真相?有行动空间? - [ ] 含 `@玩家`,present.v1 只含模型生成区?
- [ ] 未剧透隐藏档、未写成第一章? - [ ] `用户角色.简介` 只有必需客观事实,没有性格、心理和替玩家做的决定?
- [ ] 初态与这条叙述同真相,未填写随机数具体值?
- [ ] 未重开前端、篇幅或区域清单?
``` ```
## examples ## examples
```examples ```examples
好:正文为 present.v1,shell 与正文组成相同;body 里有字面 @玩家;meta 带开场变量;名片在主角设定。 - 单条:`设计.开场白#debt-hall`,堂屋债契,body 含 `@玩家`,开场变量写债额初值;状态栏由程序投影。
好:prose 壳也包成 {"schema":"present.v1","shell":"prose","blocks":{"body":"冷风灌进来时,@玩家还握着空水瓶。"}}。 - 用户要两条入口:`设计.开场白#lender` 与 `设计.开场白#office`,各带自己的初态。没有初值的那条省略开场变量。
好:「你(@玩家)站在闸机前,票夹是空的。」写在 blocks.body。 - 单条带玩家角色:`meta.用户角色` 为 `{"名字":"沈聿","简介":"本地放贷人,持有这家的债契;与这对母女相识但不熟。"}`,body 写「清晨,@玩家推开堂屋门,债契还摊在桌上。」
坏:`开场白全文` 只是一段字符串,没有 present.v1。 - 不合适:再写一份裸 `设计.开场白` 或 `设计.开场白与开场变量`;开场里填写 `敏捷.随机数=[…]`。
坏:shell 与正文组成不一致,或灌了该壳没有的区域。 - 不合适:全文只有「你推开门」,零个 `@玩家`。
坏:全文只有「你推开门」——零个 `@玩家`。 - 不合适:简介写「沉稳果决,内心渴望被认可,决定今天收回债款」——性格、心理和决定都该留给玩家。
坏:「林晚推开门」——写死姓名。
坏:在正文或开场白全文里写「用户角色.名字: …」——名片应落「主角设定」。
``` ```
@@ -1,9 +1,5 @@
# 主角设定 # 主角设定
> 技能文档。程序只切割下方 **fence 块**。
> 产物外壳:`docs/context-fragment-design.md`。
> 落盘后挂在当前选用的用户角色卡下(横线下方),不进全局列表。关掉这局就不加载。不改其它技能正文。
## meta ## meta
```meta ```meta
@@ -11,58 +7,44 @@ name: 主角设定
id: protagonist id: protagonist
artifact: 设计.主角设定 artifact: 设计.主角设定
declaration: > declaration: >
钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等)。落盘后挂在当前选用的用户角色卡下。 保存本局用户所扮演角色的身份名片,以及核心体验和已授权代演真正需要的最少信息。
when: | when: |
用户代入,且这局角色有名字、身份或金手指(快穿短局几乎总有一张脸),尚无「设计.主角设定」定稿。 用户代入一个有独立身份、背景、关系或特殊能力的角色,且这些信息会影响核心体验或世界反应。
LLM 获得较多推进权,而现有材料不足以稳定代演该角色时也使用。
when_not: | when_not: |
旁观或把角色当棋子、不要代入身份 → 不排。 旁观、无固定主角或纯作者视角时不使用。刻意无名的自我投射不因缺名字而强制使用。
只要体验感觉、不要名片 → 「美学纲领与交互范式」。 NPC 有多份或需要统一字段时归生成规则,要留下的单条再写具体实例;世界硬法则归实现机制;第一幕归开场白。
NPC 名片 → 「具体实例」。开场第一拍(叙事)→ 「开场白与开场变量」;名片本身仍走本步,勿写进开场白。
boundary: | boundary: |
本技能:@玩家 扮演的那一张名片(名字、背景、特殊设定)。 本能力只交用户角色名片和必要行为依据。体验与权限来自美学纲领,输入及推进方式来自故事推进,本步据此控制详略。
美学纲领与交互范式:体验与轮转——本步不重定。
实现机制:世界法则——金手指写在本步「它能做什么」,法则来源不写。
开场白:第一屏怎么开始——本步不定时间地点冲突;也不把名字/简介塞进开场正文。
feeds: gm feeds: gm
``` ```
## opening ## opening
```opening ```opening
这局你代入谁?(想到什么写什么) 说说这局里的“你”是谁。名字可以留作 `@玩家`;重点写会影响核心体验或世界反应的身份、背景、关系和特殊能力。只有需要 LLM 多代演时,才补性格与默认反应。
1. 名字:别人怎么叫你?不要写 @玩家。
2. 背景:一两句来历或处境即可,不写世界百科。
3. 特殊设定:身份、金手指、随身之物;没有就说没有。
``` ```
## task ## task
```task ```task
把用户原话收成 @玩家 扮演的那一张名片,写入 tag「设计.主角设定」。作用对象是本局黑板;程序读取后挂在当前选用的用户角色卡下,不写进全局列表。 把用户材料整理为本局用户角色名片,写入 `设计.主角设定`。先保留承载核心体验的身份、能力、关系与限制,再按 LLM 获得的推进权决定是否需要行为依据。
产物必须是 **context-fragment.v1** JSON。正文只含:名字、背景、特殊设定(身份 / 金手指 / 其它)。须含 schema / 技能 / brief;mount 主世界层;稳变多为 stable。 用户自己扮演或提供详细大纲时,身份背景够用即可,不替用户设计人格与选择。LLM 代演越多,性格、处事倾向和默认反应越需要具体可执行。产物正文不放评分、追问或诊断。
名字:本局称呼,进游玩替换 @玩家。禁止写成「@玩家」「用户」「主角」。缺名字 → 追问 1 题,不交半张卡。
背景:一两句。没有就写「未定」,不编百科。
特殊设定:有则写身份、金手指(能做什么、限制一句)、其它;没有就各写「无」。
一次对话、一次验收。不写世界、不写文风、不写开场第一拍。
``` ```
## principles ## principles
```principles ```principles
1. 一张脸:删掉仍知道「我是谁」的句子才落盘。 1. 只设计用户已明确给出、核心体验依赖或运行确实需要的内容。
2. 金手指写能力与限制,不写它为何存在。 2. 一张脸测试:逐句问「删掉这句,玩家还知道自己是谁吗?」删了仍知道的句子不落盘。
3. 材料不够:名字必须问出;背景和特殊设定可「未定」或「无」。 3. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
4. 规格走 JSON;追问一次 1 题。 4. 特殊能力写“能做什么 + 限制”;世界为何如此由实现机制处理。
``` 5. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
6. 身份与背景一两句;用户没给就写「未定」或省略,不代编来历。
## probe 7. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
8. 本能力只写各开场共通的玩家角色。不同开场白里玩家身份、处境不同的部分,由开场白写在自己的 `meta.用户角色`,程序会同步成 `设计.主角设定#场景短码`;本能力不为各开场另写 `设计.主角设定#短码`。
```probe 9. 不写 NPC 名片、世界百科、文风规则或开场事件。
只追问挡住成卡的缺口。缺名字:给 2 个称呼选项 + 其它。背景/金手指用户说没有 → 写「无」,不问第三轮。
``` ```
## output ## output
@@ -71,52 +53,63 @@ feeds: gm
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "主角设定", "技能": "主角设定",
"brief": "一句话:谁、带着什么特殊", "brief": "一句话说明此人是谁,以及什么设定最承载核心体验",
"mount": ["world-simulator"], "mount": ["world-simulator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"名字": "林晚", "称呼": "@玩家 或用户明确给出的固定名字",
"背景": "原剧情里被退婚的庶女,刚穿过来那天正被当众羞辱。", "身份与背景": "只写影响体验或世界反应的来历、处境与关系",
"特殊设定": { "特殊设定": [
"身份": "侯府庶女 / 原女主的垫脚石", {
"金手指": "能看见别人对她的好感数字;别人看不见。", "设定": "身份、能力、关系或随身之物",
"其它": "无" "作用": "它怎样承载核心体验或影响世界反应",
} "限制": "无则省略"
}, }
"自评": {
"维度": [
{ "名": "可代入", "分数": 8, "说明": "名字能替换 @玩家,身份清楚" },
{ "名": "够用", "分数": 8, "说明": "没有世界百科,特殊设定能约束正文" }
], ],
"薄弱点": "一句话" "行为依据": {
}, "适用条件": "仅在 LLM 获得相应代演权限时存在",
"追问": { "处事倾向": "可执行的一两句",
"导语": "名片还缺:", "默认反应": "不替用户决定关键选择"
"题目": [] }
}, }
"开放问题": []
} }
``` ```
硬规则: 硬规则:
1. 合法 JSON;必含 schema / 技能 / brief / 正文 / 自评 / 追问。 1. 输出合法 JSON;`行为依据` 不需要时整项省略。
2. 正文是对象,键为 名字 / 背景 / 特殊设定;特殊设定为对象(身份、金手指、其它),不是一段散文。 2. 不写产物内自评、追问、开放问题或完备度。
3. 名字不是 @玩家 / 用户 / 主角。 3. 不把核心体验所依赖的身份、能力或关系误删为“过度设计”。
4. 禁止写数字 order、禁止输出执行单元列表。 4. 不替用户补未授权的人格、目标、情绪和关键决定。
5. summary:`主角设定 · {名字}`。
## score
```score
宽松度:
- 5:只设计本局运行确实需要、核心体验依赖或用户已明确给出的部分;用户控制越多,留白越充分。
- 3:有少量未经确认、也非体验承重的性格或行为倾向,可能影响用户扮演但容易改写。
- 1:大量替用户设计人格、目标、情绪或行动逻辑,明显挤占用户控制权。
必要度:
- 5:核心体验所需的身份、能力、关系完整;代演信息量与 LLM 获得的推进权匹配。
- 3:缺一项会削弱核心体验或导致代演漂移的信息。
- 1:漏掉“我是谁、我凭什么获得该体验”的承重设定,或 LLM 需要代演却没有行为依据。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 核心体验依赖的身份、能力和关系都已保留?
- [ ] 名字可替换 @玩家?不是占位词? - [ ] 非必要、未经确认的性格与行为设计已删除?
- [ ] 背景一两句或「未定」?特殊设定有则写能力与限制,无则「无」? - [ ] 行为依据的详细度与 LLM 推进权匹配?
- [ ] 没有世界百科、没有开场第一拍? - [ ] 称呼允许固定名字或 `@玩家` 留白?
- [ ] 未写 NPC、世界百科、文风或开场事件?
``` ```
## examples ## examples
```examples ```examples
好:名字「林晚」;背景一句穿来当日处境;金手指「看见好感数字」+限制「别人看不见」。 - 好:称呼「林晚」;身份与背景一句穿来当日的处境;特殊设定「能看见别人对她的好感数字」,限制「别人看不见」。
坏:名字写成 @玩家;把侯府地理、朝堂势力写进背景;把开场羞辱戏全文写进名片。 - 坏:把侯府地理、朝堂势力写进背景。
- 坏:把开场羞辱戏全文写进名片。
- 坏:为宿舍开场和天台开场各写一张 `设计.主角设定#短码`;各开场不同的处境应写进对应开场白的 `meta.用户角色`。
``` ```
@@ -1,10 +1,5 @@
# 随机范围整理 # 随机范围整理
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 本步把上游已定的检定/对抗/掷骰需求,收成一份**程序可插入的随机值范围表**;不发明新裁定法则。
> 游玩期:程序按本表每轮往消息里塞备用随机数(前端折叠隐藏、用户看不见,发给 AI 时仍在上下文);主世界层直接用表中本次数,不再单独「机遇裁定」槽。
## meta ## meta
```meta ```meta
@@ -12,67 +7,50 @@ name: 随机范围整理
id: random-range id: random-range
artifact: 设计.随机范围整理 artifact: 设计.随机范围整理
declaration: > declaration: >
上游已有检定/对抗/掷骰需求时:汇总全部要用的随机项与取值范围(含表达式),供程序每轮插入备用随机数;排在正文组成之前 把已定的检定和抽签收成「变量.当前」里的隐藏行,行名用「名称.随机数」;
值是剩余数字,用掉一个就改这一行,只剩 1 个时补上。
when: | when: |
机制/生成规则/变量/叙事等上游已出现需要随机数的内容(对抗、检定、抽签、伤害区间等),且尚未收成一份范围表。 机制、变量或生成规则已出现必须用随机数才能裁定的点时使用。
排在「正文组成」之前(通常紧挨其前;回复呈现可并行或略前)。
when_not: | when_not: |
全程不靠随机数推进 → 不排。 全程不靠随机推进 → 不使用。
还在定「怎么裁定 / 公式本身」→ 先「实现机制」「生成规则」或「变量设计与更新规则」;本步只汇总已定需求。 公式本身还没钉死 → 先实现机制、生成规则或变量设计。
只谈前端怎么藏、选壳美化 → 「正文组成」。 按合理度从池里选,而不是抽签 → 不列入。
只谈每轮可见区域与字数 → 「回复呈现」。
boundary: | boundary: |
本技能:一份清单——每项随机用途、范围或表达式(如力量*5~*10、敏捷检定 1~100)、本轮建议备用条数。 不新建表,不发骰面。数字是普通隐藏变量,默认玩家看不见。
不写新裁定法则、不写骰子剧情;法则来自上游,此处只整理成程序可读范围。 0 层玩法不依赖随机。普通楼层在第一次正式回复时由程序补齐随机行,开场不必写出具体数字。
游玩拓扑:不再开「机遇裁定」槽;真随机靠本表 + 程序插入上下文。 feeds: gm
正文组成:负责把隐藏备用区接到壳/消息里——本步只交「要插什么数」。
feeds: gm,narrator
``` ```
## opening ## opening
```opening ```opening
把本局所有要用的随机数收成一张表(想到什么写什么): 本局哪些地方必须抽个数才能定?每项用哪一行保存剩余数字,以及怎么掷。
1. 上游里哪些地方要掷 / 检定 / 抽区间?(对抗、伤害、事件抽签……)
2. 每一项的取值范围或表达式?(例:敏捷检定 1~100;攻击伤害=力量×5~×10)
3. 每轮消息大概要预先塞几条备用?(够用即可,不要灌爆)
``` ```
## task ## task
```task ```task
你正在执行「随机范围整理」。产物必须是 **context-fragment.v1** JSON,写入「设计.随机范围整理」。 先扫实现机制、生成规则、变量设计、叙事与故事推进、美学,列出所有需要随机数才能裁定的点,再写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
本步只做一件事:把上游已定、游玩时会用到的随机需求,整理成程序可插入的范围表。 用掉第一个后,程序改写这一行,去掉那个数。改完后如果只剩 1 个,就按范围补到预备个数,不要等这一行空了。模型不自己编数字。
禁止重议「要不要检定」「公式对不对」——缺则追问上游或写进开放问题;本步不发明新法则。
执行顺序: 无随机需求则本步不排。公式还没在上游钉死时,把缺口交给 `self_score.gap`。产物正文不放评分和追问。
1. 扫机制 / 生成规则 / 变量 / 叙事 / 美学:列出所有「需要随机数才能裁定」的点。
2. 每项写清:用途短名、范围或表达式(相对变量/表字段时写清引用)、单位(整数 d100 / 闭区间等)。
3. 钉本轮建议备用条数(按项或全局);够本轮最坏情况即可。
4. 无随机需求则本步不应被排;若误进:正文写空清单并说明「无需随机」。
5. 自评 + 追问;输出 JSON。
summary:`随机范围整理 · {项数或主要用途}`。
``` ```
## principles ## principles
```principles ```principles
1. 只汇总,不定新法。范围与表达式必须能追溯到上游已验收产物。 1. 只登记上游已经成立的掷法,不新写法则。
2. 表达式写给人与程序都能读:`力量*5~*10`、`1~100`、`池权重抽 1`——忌散文。 2. 一行一项,一项只管一类用途,行名是「名称.随机数」,例如 `敏捷.随机数`。不要把「战斗」糊成一行却含命中、伤害、闪避几种掷法,拆成 `命中.随机数`、`伤害.随机数`、`闪避.随机数`。
3. 一项一类用途;不要把「战斗」糊成一条却含三种掷法。 3. 范围写成人与程序都能读的形式,如 `1~100`、`力量*5~10`,并注明单位(整数、闭区间等);不写「中等偏高」这类散文。
4. 备用条数服务「本轮上下文预插入」;宁少可补,勿一次塞上百。 4. 和别的隐藏变量一样存在现有表里:能改、有版本;不对玩家默认展示。
5. 不输出机遇裁定槽、不写 chance tool 调用说明。 5. 预备个数够连用即可,少了靠「只剩 1 个就补」,不要一次放很多。
``` ```
## probe ## probe
```probe ```probe
每轮 1~2 点:某项范围是否仍是散文;表达式引用的变量/表是否未在上游出现;有无漏掉已声明的检定。 每轮 1~2 点。范围是否还能算、行名是否会撞、有没有漏掉已经声明的检定。
不追问:壳怎么藏、CSS、文风、要不要主世界层。
全程明显无随机:确认不排即可,不必硬凑表。
``` ```
## output ## output
@@ -81,58 +59,64 @@ summary:`随机范围整理 · {项数或主要用途}`。
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "随机范围整理", "技能": "随机范围整理",
"brief": "一句话:收了哪些随机项", "brief": "登记了哪些隐藏随机行",
"mount": ["world-simulator", "narrator"], "mount": ["world-simulator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据": ["上游机制/生成规则/变量/叙事中与随机相关的要点"],
"插入意图": "程序按本表每轮往消息隐藏区插入备用随机数;裁决时只用上下文已有数,不另开机遇槽",
"项": [ "项": [
{ {
"id": "agility-check", "行名": "敏捷.随机数",
"用途": "敏捷检定", "用途": "敏捷检定",
"范围": "1~100", "范围": "1~100",
"表达式": null, "表达式": "无表达式时省略",
"来源": "实现机制·… / 变量·…", "单位": "整数,闭区间",
"每轮备用条数": 3 "依赖真值": [],
}, "预备个数": 3,
{ "来源": "上游短引用"
"id": "attack-damage",
"用途": "攻击伤害",
"范围": null,
"表达式": "力量*5~*10",
"来源": "变量设计·力量 / 机制·对抗",
"每轮备用条数": 2
} }
], ]
"全局备用上限": "可选;防止灌爆上下文的软上限" }
},
"自评": {
"维度": [
{ "名": "覆盖", "分数": 8, "说明": "上游随机点是否收齐" },
{ "名": "可执行", "分数": 8, "说明": "范围/表达式是否可直接插数" }
],
"薄弱点": ""
},
"追问": { "导语": "", "题目": [] },
"开放问题": []
} }
``` ```
硬规则:合法 JSON;`项` 可 `[]`(仅当误排且确认无随机);有项则每项须有用途 +(范围或表达式至少一者);禁止 order;summary 见 task。 硬规则:
1. 合法 JSON;有项则每项有行名、用途、预备个数,以及范围或表达式。
2. 行名以 `.随机数` 结尾,彼此不重复。
3. 不输出具体骰面,不另建表,不写票 id。
4. 产物正文不含自评和追问。
5. `mount` 为 `["world-simulator"]`。
## score
```score
覆盖度:
- 5:该随机的点都有独立行。
- 3:漏了一项次要抽签。
- 1:明显漏项,或没有随机却硬凑。
可执行性:
- 5:行名、范围或表达式、预备个数都能直接写成隐藏变量。
- 3:大体能写,有一处范围含糊。
- 1:只有用途,程序无法出数。
克制:
- 5:就是现有表上的隐藏行,用一个改一行,只剩 1 个再补。
- 3:行名或隐藏方式略绕。
- 1:另建存储、票证或让模型自己编数。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 每项都能对上上游,没有本步新发明的裁定? - [ ] 每项是「名称.随机数」,玩家默认不可见?
- [ ] 范围/表达式短且可插数? - [ ] 范围或表达式能算出数?
- [ ] 未要求机遇裁定槽或 chance tool? - [ ] 用掉一个就改这一行,只剩 1 个时补?
- [ ] 排在正文组成之前的意图是否写进 brief/插入意图? - [ ] 没有新表、票 id 或具体骰面?
``` ```
## examples ## examples
```examples ```examples
好:两项——敏捷 1~100(备用 3);攻击伤害 力量*5~*10(备用 2);插入意图写清隐藏备用区。 - `敏捷.随机数` 的值是 [47, 12, 83]。用了 47,这一行改成 [12, 83]。再使用一次后只剩 [83],随即补到 3 个。
坏:在本步新写一套战斗规则;或只写「战斗要掷骰」无范围;或指引去开 play_slots.chance。 - `攻击.随机数` 按力量算出区间,仍是同一张表里的另一行。
``` ```
@@ -1,4 +1,4 @@
# 细化终稿 # 运行配置组装
> 能力文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 能力文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物 tag:**`设计.worker集`**(JSON)。 > 产物 tag:**`设计.worker集`**(JSON)。
@@ -7,25 +7,18 @@
## meta ## meta
```meta ```meta
name: 细化终稿 name: 运行配置组装
id: refine id: refine
artifact: 设计.worker集 artifact: 设计.worker集
declaration: > declaration: >
按已勾选槽(或默认槽)与投影排序表收成运行规格;不发明新执行单元 程序把执行方式、上下文排序、变量表和输出合同组装成可直接开玩的运行配置
when: | when: |
至少体验契约可引用;槽已勾选或可按默认;若上下文块已多则宜先有投影排序表。 落档或开玩时自动执行;运行期需要统一的「设计.worker集」快照。
需要输出「设计.worker集」才能进游玩时排;编排应将流程导向 closed。
when_not: | when_not: |
体验站位未定 → 先「美学纲领与交互范式」。 前置内容尚未齐全时仍可生成最小可运行配置,并在后续落档时重新组装。
不能吃默认槽且尚未勾选 → 先「游玩拓扑」。
上下文块已多且稳/变未排序 → 先「上下文投影排序」。
只要改某一块设定长文 → 回头修改对应上游节点,不要用本步重写。
boundary: | boundary: |
本能力:合并上游 → 完整运行规格 JSON(interaction、play_slots、context_order/inserts、 本程序只合并已确认产物并执行校验,不润色正文、不补世界观,也不发明执行单元。
workers 由槽展开、resident_context、tables)。 普通楼层与 0 层使用同一组装器,由所选呈现产物决定输出合同。
游玩拓扑:槽位权威;上下文投影排序:插入序与投影位权威;本步不新增白名单外的 ref。
变量设计:side_effects / 真值吸入 tables;不创建 variable-update 执行单元。
进 play 由用户手动决定。
``` ```
## opening ## opening
@@ -1,10 +1,5 @@
# 正文组成 # 正文组成
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> **创作定位:把「回复呈现」已钉的内容展示给用户**(选壳、分区、用安全子集美化)。壳与【正文安全子集】由程序动态装载。
> 产物外壳:`docs/context-fragment-design.md`。
> 旧称:设计回复格式。
## meta ## meta
```meta ```meta
@@ -12,87 +7,65 @@ name: 正文组成
id: reply-format id: reply-format
artifact: 设计.正文组成 artifact: 设计.正文组成
declaration: > declaration: >
把「回复呈现」已钉的内容展示给用户:选壳、分区、按【正文安全子集】做折叠/表格等美化;有「随机范围整理」则把隐藏备用随机区接到消息;排在二者之后、开场白之前 把普通楼层「回复呈现」的语义区域落成每条消息使用的前端模板:
定义区域位置、HTML/CSS/JS、数据绑定与特殊渲染。
when: | when: |
已有「回复呈现」(或等价:每轮终稿看什么已钉)后排。几乎每局都要。 已有普通楼层「回复呈现」,需要决定每个语义区域在气泡内如何显示时使用。
若本局有随机需求:须已有「随机范围整理」(或确认无随机)后再排本步。 排在回复呈现之后、开场白与开场变量之前。
必须在「开场白与开场变量」之前。
when_not: | when_not: |
还不知道每轮给用户看什么 → 先「回复呈现」。 还不知道回复包含哪些语义内容 → 先回复呈现。
已定要检定/掷骰但范围表未收 → 先「随机范围整理」。 要设计持久 0 层卡、槽位更新或跨轮 Tab → 使用 0层正文组成,不在本节点兼顾。
不要排在开场白之后。几乎不跳过。
只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。只改呈现区域与内容规则(字数/选项槽/监控键)→ 「回复呈现」。
boundary: | boundary: |
本技能:前端展示——从【可选呈现壳】选壳、把回复呈现的各区域落到版式;正文语法以【正文安全子集】为准(折叠、表格等)。 回复呈现决定“有什么、何时出现、来自哪里”;本能力决定“在前端哪里、如何渲染”。
回复呈现:必选/可选区域 + 各区简单内容规则——本步不重议清单与规则,只负责怎么显示。 状态和拓扑数据由程序投影,本能力只写绑定与渲染器;不得让正文模型重写权威数据。
随机范围整理:要插哪些数、范围是什么——本步不改范围表,只负责怎么藏进消息。 隐藏维护区不进 DOM;表由旁观副 LLM 维护,主回复不建填表通道。
叙事指南:文风。变量设计:真值规则。开场白:第一屏——本步先定展示。 随机数是变量表隐藏行,也与本能力无关。
feeds: narrator 壳参考库只提供布局与交互方法参考,不是必须照抄的固定产品模板。
feeds: gm,narrator
``` ```
## opening ## opening
```opening ```opening
本局把「回复呈现」里已钉的内容展示给用户(选壳、分区、安全子集美化)。壳与安全子集由程序动态装载,不要背死。 把回复呈现里已启用的语义区域做成普通消息气泡内的前端。先决定阅读主次和区域落点,再按需设计 Tab、表格、拓扑图等渲染;隐藏维护区不显示。
1. 对照【可选呈现壳】:哪一个最适合放下已钉的那些东西?
2. 各项落到壳的哪一区?显示名叫什么?渲染提示用纯文本还是 Markdown?
3. 需要折叠/表格等时,对照【正文安全子集】(一般够用;html/css/js 默认空)。
4. 定稿时会用前面情节架空一幕灌进壳里,专供你看版式/美化(内容随便,不当正史)。
``` ```
## task ## task
```task ```task
你正在执行「正文组成」。产物必须是 **context-fragment.v1** JSON,写入「设计.正文组成」。 为普通楼层生成一份可复用前端,写入 `设计.正文组成`。每条助手消息仍各自形成一个气泡;程序把当轮 `present.v1` 灌入同一模板。
本步 = **前端展示**:按「回复呈现」的清单,从【可选呈现壳】里选一个,把内容落到区域。
正文美化优先【正文安全子集】(用户开 Markdown 渲染时生效)。日常折叠、表格等走子集即可;「渲染」里 html/css/js 默认空字符串。
禁止重议「看什么、正文多少字」;禁止自造目录没有的 shell_id;禁止发明子集没有的语法。
执行顺序: 执行顺序:
1. 读「回复呈现」与美学/叙事:清单是权威;不重开「看什么」问卷。 1. 读取回复呈现,只处理其中实际启用的语义区域。同时读美学纲领的呈现要点与叙事指南,作为配色、字体层级和纹样的依据;不照搬参考壳的样子。
2. **选 shell_id**(必须是【可选呈现壳】里的 id);说明为何这个壳放得下清单里的项。 2. 从【壳参考库】选一只与本局已启用区域最接近的壳。按该壳备注改写样本里的名称、栏目、配色和内部小节。本局没有的区从样本骨架删除并重排,不留空位。
3. 可见块:把清单各项挂到该壳已有区域;写显示名与职责;渲染提示选「纯文本」或「Markdown」。 3. 为每个用户可见区域定义 block、位置、显示名和渲染方式。模型生成区读取对应 block;程序投影区直接绑定权威数据。
4. 「渲染」html/css/js:默认全空;仅当用户点名要超出安全子集的交互时再议(本步通常不写)。 4. 隐藏 `state_ops` 等维护区列入非渲染处理,不创建 DOM 节点。
5. 隐藏段(若清单要求):定界与用途。 5. 写完整 HTML/CSS/JS,创作时一次生成并固定;游玩只灌数据,不再重写前端。每个可见区放一个空节点,`data-block` 等于数据契约里的 id。程序按契约把当轮内容填进这些节点;JS 只做拓扑图和本地切换,通过 `present.onData(cb)` 拿当轮数据;不要用 `textContent` 写 markdown/text 正文。根容器(如 `#app`)`width: 100%`,铺满宿主气泡/iframe,禁止整壳 `max-width` + `margin: 0 auto` 收成居中卡片;若要控阅读行长,只给正文柱设 `max-width`(可 `margin-inline: auto`)。气泡随内容增高,禁止 `100vh` 和整页或侧栏滚动条,滚动只留在外层对话。美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。地图所需的 SVG 从拓扑数据绘制,游玩模型不输出 SVG。
6. 填「呈现壳.微调」;分区必须与壳一致。 6. 给一份非正史 `present.v1` 预览数据,覆盖所有可见区域与空态。
7. **示例**:用前面已定情节架空一幕灌进 `present.v1`;body 可演示子集语法(如折叠/表格);字数以回复呈现为准。
8. 自评 + 追问;禁止写 context `order`。
summary:`正文组成 · 适配 {shell_id} · …`。 模板总量不超过 60 KB。材料足够便直接成稿;会改变阅读结构的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
``` ```
## principles ## principles
```principles ```principles
1. 创作 = 把「回复呈现」的清单展示出来 1. 语义区域必须全部有去处;未启用区域不补齐,隐藏维护区不渲染。
选壳、分区、显示名、安全子集语法都为那份清单服务。本步验收重点是「看起来对不对」。 2. 正文是主阅读面。辅助状态、线索、地图和文字选项按用途确定层级,不平均分割注意力。
「按意见修改」:以最新产物为底;用户要求按先后都有效,冲突时以较新的为准。用户说整体可以、或只提美化 → 不换 shell_id。换壳必须用户点名。 3. 程序投影区只消费权威 block:变量、拓扑和程序结果不要求模型重复填写。
4. HTML 只放稳定骨架,每个可见区一个 `data-block` 空节点。程序按数据契约灌数;`present.onData` 只留给拓扑和本地交互。markdown/text 正文禁止用 `textContent` 写入(会挤掉换行并露出 `**`);若本地必须写正文,用 `present.markdown` 再赋 `innerHTML`。缺 block 时按合同省略或显示空态。不要把气泡做成独立窗口。
2. 清单权威在「回复呈现」 5. 外壳铺满宿主可用宽度(`width: 100%`、背景与顶栏跟宽走)。行长只限正文柱,不限整壳;缺则按宿主全宽排,不另造居中窄卡片。
正文篇幅、要不要日期、展示哪些变量,以那份产物为准。本步只决定怎么显示。 6. Tab、折叠、缩放等纯本地交互可以使用;不得联网、提交表单、弹窗、顶层导航或向宿主发送用户行动。
7. Markdown 使用宿主提供的安全渲染能力;动态文字使用安全 API 或转义后写入,不能拼接未清洗 HTML。
3. 壳是底,子集是语法 8. 地图/关系图由 JS 根据结构化拓扑绘制;显示坐标只属于布局,不回写世界真相。
从【可选呈现壳】选底;Markdown 区只使用【正文安全子集】。不要发明新后端协议、目录没有的壳、或子集没有的语法。 9. 预览只用于验收前端,不写成开场白或正史。示例灌数不为好看新造人名、地名、势力,用已有产物里的;文笔及格即可。
10. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
4. 隐藏段服务维护,不污染阅读 11. 改稿以最新产物为底。用户只提美化时只改样式,`数据契约` 和 `区域落点` 保持不变;要换阅读结构必须用户明确提出。
5. 与转述、变量分工
文风归叙事指南;真值归变量设计;看什么归回复呈现;本步只交展示契约。
6. 示例 = 壳与美化预览,非正史
用已定材料随便架空一幕,把各区域/监控/行动坞撑满,让用户看见版式。
禁止把示例当成开场白或游玩 canon;禁止为写好看而新造设定;文笔及格即可,勿打磨成章。
``` ```
## probe ## probe
```probe ```probe
每轮 1~2 点。 每轮 1~2 点。优先确认阅读主面、辅助区域是否需要并列或折叠、拓扑采用何种可读形式。语义内容、篇幅、变量更新规则不在这里重议。
优先:壳能否放下回复呈现的各项;分区与显示名;渲染提示纯文本还是 Markdown;要不要子集里的折叠/表格。
不追问:重议字数/变量清单、事件池 schema、文风遣词、示例文笔好坏、本局任意 CSS/JS。
``` ```
## output ## output
@@ -101,168 +74,130 @@ summary:`正文组成 · 适配 {shell_id} · …`。
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "正文组成", "技能": "正文组成",
"brief": "一句话:选了哪壳、关键区怎么挂", "brief": "一句话说明普通消息的阅读结构与特殊渲染",
"mount": ["narrator"], "mount": ["world-simulator", "narrator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据的体验与呈现": { "参考壳": [
"回复呈现要点": "从设计.监控栏摘:必选区 + 字数等",
"美学或叙事可映到版式的一句": "可空"
},
"呈现壳": {
"shell_id": "必须是【可选呈现壳】里的 id",
"为何选它": "对照回复呈现要同时看见什么",
"微调": {
"monitor_fields": [],
"block_labels": {},
"show_suggested_actions": true,
"tone_chrome": "default|messenger|book|terminal",
"empty_states": {}
}
},
"版式隐喻": "可空;一句气质,不是新壳",
"可见块": [
{ {
"块id": "与回复呈现一致的短名", "id": "壳参考库中的方法 id",
"挂到区域": "monitor|header|body|footer|aside", "采用": "借用什么布局、阅读节奏或局部交互方法",
"显示名": "可空", "本局化": "为本局语义区域作了什么调整"
"职责": "一句",
"来源提示": "转述填|程序填|混合"
} }
], ],
"监控栏锚点": { "阅读结构": {
"启用": true, "主阅读面": "body",
"字段": ["与回复呈现一致;无则 []"] "辅助信息关系": "并列|折叠|标签页|正文前后|本局描述",
"响应式": "窄屏与宽屏如何保持正文优先"
}, },
"隐藏段": [ "数据契约": {
{ "schema": "present.v1",
"段id": "variable-delta", "blocks": {
"用途": "变量维护语句,供程序拆进变量.当前", "body": {
"定界或正则约定": "例:<<<VARS>>>...<<<END>>>", "类型": "markdown",
"用户界面": "默认隐藏|折叠可见|调试可见", "来源": "model_generated",
"模型必须输出": true, "职责": "主读正文",
"内容形状": "短键值/JSON行;非 chance toolcall", "空值": "error"
"示例": "可选"
}
],
"前端拆分与美化": {
"需要前端分区": "是|否",
"区域": [
{
"区域id": "monitor|header|body|footer|aside",
"对应块id": ["…"],
"意图": "固定顶栏|主滚动|折叠文末|…",
"渲染提示": "纯文本|Markdown"
}
],
"渲染": {
"html": "",
"css": "",
"js": ""
},
"说明": "Markdown 区语法见【正文安全子集】;渲染三字段默认空。无分区则写「单流渲染」(通常 prose)"
},
"拼装与终稿": {
"终稿tag": "通常 输出.用户展示",
"拼装方": "叙事转述一次写出各块|程序按块拼接|混合",
"推荐灌数": "分块壳优先 present.v1;prose 可为纯 Markdown(安全子集)",
"单轮正文篇幅": "以回复呈现的字数为准,本步不重钉",
"与正文关系": "转述读主世界「输出.用户展示」再填各块;可补残稿与版式,不改剧情;隐藏段可供旁观解析维护语句"
},
"示例": {
"性质": "验收预览|非正史|仅看壳与美化",
"依据": ["前面已定情节/设定要点,一两句即可"],
"用来说明": "这幕只为撑开版式(分区、监控、行动坞、显示名…),内容可糙",
"灌数": {
"schema": "present.v1",
"shell": "与正文.呈现壳.shell_id 一致",
"blocks": {
"monitor": "若该壳有 monitor:短键值;否则省略",
"header": "若有则短字;无则省略",
"body": "架空主读文;需要时可演示 :::details / 表格(短段即可)",
"aside": "若有则短线索/备注;无则省略",
"footer": "若有则可空或一句"
}, },
"meta": { "本局其它可见区域 id": {
"suggested_actions": ["若该壳开建议行动则给 2~3 个占位选项;否则 []"] "类型": "text|markdown|kv|list|table|topology",
"来源": "model_generated|program_projection",
"职责": "从回复呈现继承",
"空值": "omit|empty_state|error"
} }
},
"不渲染": [
{
"id": "内联维护区 id;无则省略",
"交给": "运行时维护解析器"
}
]
},
"区域落点": [
{
"block_id": "与回复呈现一致",
"位置": "DOM 区域或 Tab id",
"显示名": "需要标题时填写;不需要则空字符串",
"渲染": "safe_markdown|text|kv|list|table|topology_svg",
"交互": "无|本地切换|折叠|缩放",
"空态": "省略或显示什么"
}
],
"frontend": {
"html": "<div id=\"app\"><article data-block=\"body\"></article><section data-block=\"本局其它可见区域 id\"></section></div>",
"css": "完整样式;根容器 width:100%;正文优先;行长只限正文柱;含窄屏规则",
"js": "使用 present.onData(render) 的完整渲染代码"
},
"示例灌数": {
"schema": "present.v1",
"blocks": {
"body": "非正史短占位正文",
"本局其它可见区域 id": "与其类型相符的非正史数据"
} }
} }
}, }
"自评": {
"维度": [
{
"名": "可见完备",
"分数": 8,
"说明": "用户该看见的块是否齐、顺序是否服务体验"
},
{
"名": "清单落实",
"分数": 8,
"说明": "回复呈现的各项是否都落到壳区域"
},
{
"名": "可实现",
"分数": 8,
"说明": "shell_id 是否在【可选呈现壳】内;Markdown 是否只用【正文安全子集】"
},
{
"名": "版式可感",
"分数": 8,
"说明": "示例灌数是否按壳撑开各区,使人一眼能验收美化(不论文笔)"
}
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为钉准用户看见的版式,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A", "选项B", "其它(请写明)"],
"示例": "可选"
}
]
},
"开放问题": []
} }
``` ```
硬规则: 硬规则:
1. 合法 JSON;含公共三段外壳。 1. 输出合法 JSON;`数据契约.blocks` 必须覆盖回复呈现的全部用户可见区域,id 保持一致。
2. 正文须含 `呈现壳.shell_id`(须在【可选呈现壳】内)与 `微调`;可见块只挂壳已有区域。 2. 程序投影区不得改成模型生成;隐藏维护区只能进入 `不渲染`,不得出现在 DOM 或预览 blocks。
3. `可见块` 至少 1 条(通常含 body);禁止自造区域 id / 目录没有的壳。 3. `frontend.html/css/js` 均须给出可执行内容;合计不超过 60 KB。每个可见 block 对应一个 `data-block` 空节点,id 与契约一致。JS 可以调用 `present.onData` 画拓扑或做本地切换,但文本区由程序填充。根容器铺满宿主宽度;不要整壳 `max-width` 居中卡片;不要 `100vh`,不要整页或侧栏滚动条。
4. 正文须含 `示例`:`性质` 标明非正史;`灌数` 为 `present.v1`,`shell` 与本步 shell_id 一致;只填该壳已有区域。 4. HTML 不嵌入本轮数据;动态文字安全渲染。禁止网络、表单、弹窗、顶层导航和宿主行动回传。
5. 示例内容可糙:只为验收壳与美化;禁止当开场白/正史;禁止为示例新造设定。 5. topology 渲染读取结构化节点与连接;不得要求游玩模型输出 SVG。
6. 禁止程序 API schema、context `order`。Markdown 区只使用【正文安全子集】;渲染 html/css/js 默认空。 6. `示例灌数` 是非正史预览,schema 固定 `present.v1`,只含用户可见 blocks。
7. 自评:可见完备 / 清单落实 / 可实现 / 版式可感。 7. 不重定义语义区域、篇幅、文风、变量规则、随机数或 0 层槽位更新。
8. summary:`正文组成 · 适配 {shell_id} · {brief 缩略}`。 8. 产物正文不含自评、追问或开放问题。
9. 兼容旧称产物 tag「设计.回复格式」。 9. `mount` 固定为 `["world-simulator", "narrator"]`;兼容旧称 tag `设计.回复格式`。
## score
```score
区域落实:
- 5:每个可见语义区域都有 block、落点和渲染器;隐藏区明确不渲染。
- 3:主体完整,但有一个辅助区落点或空态含糊。
- 1:漏区,或擅自添加回复呈现没有的内容。
阅读结构:
- 5:正文始终是主阅读面;外壳铺满宿主宽度;辅助层级与响应式服务本局体验。
- 3:总体可读,但并列区略抢正文、窄屏不足,或整壳被限宽成居中卡片。
- 1:区域平均堆叠,阅读焦点混乱,或外壳明显窄于宿主。
权威绑定:
- 5:变量、拓扑和程序结果只读权威投影,模型生成区边界清楚。
- 3:主要绑定正确,但有一个权威字段仍可能被模型重复填写。
- 1:地图或状态每轮由模型重编。
实现完整:
- 5:HTML/CSS/JS 与 present.v1 合同闭合,空态、安全渲染和故障降级均可实现。
- 3:主体可运行,但有一处事件、空态或数据类型未闭合。
- 1:只有外观描述或空代码,无法执行。
本局化:
- 5:参考壳的方法被消化成本局布局,没有固定产品换皮感。
- 3:适配基本成立,但仍明显照搬单一参考。
- 1:只选一个壳名,没有根据语义区域设计。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 是把「回复呈现」的清单展示出来,而不是重议看什么? - [ ] 回复呈现的全部可见区域均有 block、落点与渲染方式?
- [ ] shell_id 在【可选呈现壳】内?可见块未超出该壳分区? - [ ] 隐藏维护区未进入 DOM 或预览?
- [ ] 清单各项已落到区域?字数未在本步另定一套? - [ ] 程序投影的变量和拓扑没有要求模型重写?
- [ ] Markdown 区语法落在【正文安全子集】内?渲染三字段默认空? - [ ] HTML/CSS/JS 完整,每个可见区有 `data-block` 空节点,总量不超 60 KB,根容器铺满宿主宽度,正文不用 textContent 灌 markdown,没有整壳居中窄卡片或独立滚动窗口?
- [ ] 有示例灌数(present.v1),壳一致、各区撑开,且标明非正史? - [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
- [ ] 未把示例写成开场白或打磨成长文? - [ ] 本地交互不联网、不回传行动,动态内容安全渲染?
- [ ] present.v1 预览覆盖可见区域并明确非正史?
``` ```
## examples ## examples
```examples ```examples
好: - 只有正文:生成克制的阅读模板,body 用安全 Markdown 渲染,不添加无用侧栏。
- 回复呈现要正文+好感:选 chat_monitor,好感进 monitor,正文进 body;渲染提示 Markdown。 - 正文 + 状态:状态 block 绑定程序投影,可放正文上方或紧凑侧区;正文模型不输出状态副本。
- 需要折叠说明:body 用 :::details 标题 … :::(见安全子集)。 - 正文 + 地图:JS 根据 topology block 绘制 SVG;节点名称安全转义,窄屏可切到列表降级。
- 需要对照表:body 用 Markdown 表格。 - 多个辅助区:确有必要时使用本地 Tab;切换只改变当前气泡显示,不向宿主发送消息。
- 回复呈现只要正文:选 prose;渲染三字段空。 - 武侠阅读:纸色、墨色、细线框和一条简单 SVG 纹样;只保留本局启用的区。
- 不合适:把隐藏 state_ops 做成折叠面板;只用 textContent 写正文导致不分段、露出 `**`;只写“机械风界面”却不给代码;让模型每轮输出地图 SVG。
坏:
- 本步重钉「正文 300 字」而回复呈现已经写了 800~1500。
- 不写 shell_id,只写「要好看的 UI」。
- 示例写成第一章正史,或为写好看新造未定设定。
- 示例 shell 与呈现壳不一致,或灌了壳没有的区域。
- 写本局任意 CSS/JS,却只是要折叠或表格。
``` ```
@@ -1,10 +1,5 @@
# 回复呈现 # 回复呈现
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 旧称:设计监控栏 / 设计状态栏。
> 本步钉:**每轮终稿有哪些区域(必选/可选)**,以及各区域的**简单内容规则**;不排版、不选壳。
## meta ## meta
```meta ```meta
@@ -12,81 +7,63 @@ name: 回复呈现
id: status-bar id: status-bar
artifact: 设计.监控栏 artifact: 设计.监控栏
declaration: > declaration: >
几乎每局都要:钉每轮终稿的必选/可选区域,以及各区域的简单内容规则(字数、段落、选项怎么组成等);不排版 定义普通楼层每轮回复实际启用的语义区域、出现条件、权威来源与内容要求;不决定前端排版和渲染。
when: | when: |
舞台/世界/变量等上游大致齐后排。几乎每局都要。 已有故事推进及本局需要展示的变量、拓扑或其它内容后使用;排在正文组成与开场白之前。
排在「正文组成」之前:先钉有什么区域、各区怎么写,再交给正文组成去展示。
when_not: | when_not: |
上游还没齐 → 先那些。 每轮只需普通正文且语义合同已由上游明确、无需额外区域时可使用最小正文区域,不扩展其它块。
只谈怎么排版、选壳、CSS/HTML/JS → 「正文组成」。 只讨论 HTML、CSS、Tab、分区或具体渲染 → 正文组成。
只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。 boundary: |
检定/掷骰范围汇总 → 「随机范围整理」。 本能力决定回复由哪些可见或隐藏语义区域组成、何时出现、内容来自哪里、怎样才算填对。
变量如何增减 → 「变量设计与更新规则」(本步只定展示哪些、展示规则)。 正文组成负责把这些区域放到前端的具体位置并实现 HTML/CSS/JS、Tab 和特殊渲染。
boundary: > 故事推进是单轮字数及扩写/续写预算的唯一来源;本能力不重复定义篇幅。
本技能:区域清单(必选/可选)+ 各区域简单内容规则(字数、自然段、选项槽位与提示口径等)。 变量设计与拓扑图谱定义权威数据和更新规则;本能力只决定回复是否包含其展示区或隐藏更新区。
正文组成:如何把这些区域展示给用户(壳、美化、CSS/HTML/JS)——本步不定版式。 feeds: gm,narrator
随机范围整理:隐藏备用随机数表——本步不收范围。
变量设计:真值与更新——本步可点名要展示的键与监控规则,不写增减公式。
feeds: narrator
``` ```
## opening ## opening
```opening ```opening
每轮最终回复要分哪些区域?(想到什么写什么) 每轮回复除正文外,还需要哪些语义内容?逐项说明它何时出现、由模型生成还是由程序投影,以及用户是否可见。若有地图或状态展示,请指出权威数据来源;若维护策略要求结构化隐藏更新,也把它列为隐藏区域。
1. 必选:正文——字数、段落形态?未提及时默认:约 1000~2000 字;完整长自然段,不要单句成段。
2. 要不要状态/变量监控(如开头日期、好感)?盯哪些键?
3. 要不要选项栏?若要:几个槽、各自什么气质(如 A 冒险 / B 稳重),生成选项时遵守什么提示?
4. 要不要可选块:小故事旁支、线索条等?没有就说没有。
``` ```
## task ## task
```task ```task
你正在执行「回复呈现」。产物必须是 **context-fragment.v1** JSON,写入「设计.监控栏」。 为普通楼层定义每轮回复的语义区域合同,写入 `设计.监控栏`。只写本局实际启用的区域;未启用的区域直接省略。
本步做两件事: 每个区域写清:
1. **区域**:每轮终稿有哪些块——哪些必选、哪些可选。 - 用途与用户可见性。
2. **简单规则**:每个启用区域怎么生成/填写(字数、自然段、选项槽与提示口径、监控键等)。 - 每轮出现或满足条件才出现。
- 来源是模型生成、程序权威投影,还是维护通道。
- 对应的上游引用与正向内容要求。
- 没有内容时是省略、给空态,还是仍须输出。
不选壳、不写 CSS/HTML/JS、不排版。展示交给「正文组成」。 正文区域只引用 `设计.故事推进` 的单轮篇幅与扩写/续写预算,不复制数值。状态、地图等权威内容使用程序投影,正文模型不重复生成。选项若启用,写清数量、各项的差异维度(如冒险/稳重)和每项的组成规则,让选项彼此不同;不声明点击交互。
默认必选:`正文`。用户未点名正文规格时,采用默认规则: 表维护由旁观副 LLM 固定承担;本步**不**建立主回复里的隐藏变量更新区(无 `state_ops` / maintenance_channel)。可见区只投影权威数据。
- 字数约 **1000~2000**;
- **完整长自然段叙述,不要单句成段**。
用户另有要求则以用户为准。
常见可选:`状态监控`(日期、变量芯片等)、`选项栏`、`小故事`(旁支短文)等——按体验增减,不要堆满。 材料足够便直接成稿;真正会改变区域合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
执行顺序:
1. 读美学/叙事/变量/舞台:继承体验与已有真值名。
2. 钉必选区域:至少含正文;写清内容规则。未提及时用默认(1000~2000 字;长自然段、勿单句成段)。
3. 逐个判定可选区域:要则写规则;不要则不进清单(可在「刻意不启用」说明)。
4. 若开选项栏:钉槽位数、各槽气质/组成规则、生成时的短提示口径(供转述遵守)。
5. 若开状态监控:点名键、形态、刷新意图;不写变量增减公式。
6. 自评 + 追问;输出 JSON。
summary:`回复呈现 · …`(点题必选+主要可选)。
``` ```
## principles ## principles
```principles ```principles
1. 区域 = 用户每轮会看见的内容槽;规则 = 槽里怎么写。两者都在本步钉死。 1. 语义区域回答“回复里有什么”,正文组成回答“在前端哪里、怎么显示”。
2. 正文默认必选。其它区域默认可选:有体验需要才开。 2. 只写实际启用区域。固定每轮出现用 `every_turn`;条件出现须给可判定条件。
3. 规则要可执行、宜短:字数、自然段、选项槽气质、监控键名——够转述与正文组成接上即可。 3. 模型生成区写内容要求;程序投影区写权威 ref 与字段选择。程序已有的数据不让模型复述一份。
正文规格用户未提及时:字数 1000~2000;完整长自然段,不要单句成段。 4. 正文篇幅、扩写和续写预算只引用故事推进;文风只引用叙事指南。
4. 选项栏的「选项由什么组成 / 各槽提示词」在本步规定,不推到叙事指南或正文组成。 5. 状态与拓扑的真相来自运行数据。可见区域只选择投影字段,不在这里修改变量或拓扑。
5. 不问「长什么样」。壳、分区、CSS/HTML/JS 交给「正文组成」。 6. 不在主回复建隐藏填表区;表由旁观副 LLM 维护。
6. 展示用的变量是子集;增减仍归变量设计。 7. 选项是文本建议。是否有交互、点击后做什么,只有正文组成和宿主前端真正实现时才声明。
8. 区域要求使用正向规格;没有特殊要求便保持短,不为完整感堆区域。
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;小故事是旁支短文,不推进主线,也不复述正文;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。
``` ```
## probe ## probe
```probe ```probe
每轮 1~2 点:必选是否只剩正文;某可选区开了却没规则;选项槽气质是否空;正文段落/字数是否离谱。 每轮 1~2 点。优先确认某区域是否真的需要、何时出现、由谁生成、权威数据来自哪里。排版、HTML、Tab 和具体渲染留给正文组成。
不追问:选哪个壳、CSS、文风遣词、变量更新公式。
``` ```
## output ## output
@@ -95,130 +72,144 @@ summary:`回复呈现 · …`(点题必选+主要可选)。
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "回复呈现", "技能": "回复呈现",
"brief": "一句话:必选哪些区 + 开了哪些可选区", "brief": "一句话说明每轮实际包含哪些语义区域及主要权威来源",
"mount": ["narrator"], "mount": ["world-simulator", "narrator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据": ["美学/叙事/变量/舞台里与「每轮看见什么」相关的要点"], "语义区域": [
"必选区域": [
{ {
"id": "body", "id": "body",
"名称": "正文", "名称": "正文",
"规则": { "用途": "承载本轮叙事正文",
"字数": "默认 1000~2000;用户另定则从其", "用户可见": true,
"自然段": "完整长自然段叙述,不要单句成段", "出现": {
"其它": "可选:人称、是否含对白密度等一句" "方式": "every_turn",
} "条件": ""
} },
], "内容类型": "markdown",
"可选区域": [ "区域种类": "正文",
"来源": {
"方式": "model_generated",
"ref": "设计.故事推进"
},
"内容要求": [
"遵守故事推进中的单轮篇幅与扩写/续写预算",
"遵守叙事指南;在本轮自然停笔点结束"
],
"空值处理": "error"
},
{ {
"id": "status", "id": "本局实际启用的其它区域 id",
"名称": "状态监控", "名称": "状态、地图、线索、文字选项或本局自定义名称",
"启用": true, "用途": "这一语义区域帮助用户获得什么信息",
"规则": { "用户可见": true,
"字段": [ "出现": {
{ "方式": "every_turn|conditional",
"名": "日期|好感|…", "条件": "conditional 时写程序或模型可判断的条件"
"来源": "真值键|投影|临场", },
"形态": "短文本|数字|枚举" "内容类型": "text|markdown|kv|list|table|topology|state_ops",
} "区域种类": "小故事|数据卡|结构|文字选项",
], "来源": {
"刷新": "每轮|事件后" "方式": "model_generated|program_projection|maintenance_channel",
} "ref": "上游产物、运行状态或维护策略的稳定引用",
"字段": ["程序投影时只选择实际需要的字段"],
"投递": "仅维护区使用:runtime_selects_tool_or_structured"
},
"内容要求": [
"正向说明该区域应该提供什么"
],
"空值处理": "omit|empty_state|error"
}, },
{ {
"id": "choices", "id": "choices",
"名称": "选项栏", "名称": "文字选项",
"启用": true, "用途": "给用户几条方向不同的行动参考",
"规则": { "用户可见": true,
"槽数": 2, "出现": {
"槽": [ "方式": "every_turn",
{ "条件": ""
"槽id": "A", },
"气质": "冒险", "内容类型": "list",
"组成": "推进风险/探索的行动一句", "区域种类": "文字选项",
"生成提示": "写给转述:本槽选项须偏冒险、短、可点" "来源": {
}, "方式": "model_generated",
{ "ref": "设计.故事推进"
"槽id": "B", },
"气质": "稳重", "选项": {
"组成": "保守/观察/缓和的行动一句", "数量": 2,
"生成提示": "写给转述:本槽选项须偏稳重、短、可点" "各项": [
} { "差异维度": "冒险", "组成": "一句推进风险或探索的具体行动" },
{ "差异维度": "稳重", "组成": "一句观察、保守或缓和局面的具体行动" }
] ]
} },
}, "内容要求": [
{ "每项是用户角色此刻能直接采取的一句行动,按各自差异维度写,彼此不重复"
"id": "side_story", ],
"名称": "小故事", "空值处理": "omit"
"启用": false,
"规则": {
"字数": "无则空",
"与正文关系": "旁支;不抢主线"
}
}
],
"刻意不启用": [
"本局不开的区域及原因"
]
},
"自评": {
"维度": [
{
"名": "区域清楚",
"分数": 8,
"说明": "必选/可选是否分明;下游能否直接按区展示与灌数"
},
{
"名": "规则可执行",
"分数": 8,
"说明": "字数/段落/选项槽/监控键是否够转述遵守"
}
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为钉准每轮区域与规则,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A", "选项B", "其它(请写明)"],
"示例": "可选"
} }
] ]
}, }
"开放问题": []
} }
``` ```
硬规则: 硬规则:
1. 合法 JSON;公共三段外壳。 1. 输出合法 JSON;`语义区域` 只含实际启用项,不写 `启用:false` 或未启用清单。
2. 正文须含:必选区域(至少正文)、可选区域、刻意不启用。 2. 普通楼层至少含 `body`;正文只引用故事推进,不复制目标字数、占比或字数预算。
3. 每个启用区域须有可执行的 `规则`;选项栏须含槽气质与生成提示。 3. 每个区域必须声明出现方式、内容类型、区域种类、来源、内容要求和空值处理。可见区的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
4. 禁止选壳、写 CSS/HTML/JS、发明前端分区。 4. `program_projection` 必须给稳定 ref;状态和拓扑数据不得标为临场或要求模型重新生成。
5. summary:`回复呈现 · {brief 缩略}`。 5. 不建立主回复隐藏填表区(无 `state_ops` / `maintenance_channel`);表由旁观副 LLM 维护。
6. 产物 tag 仍为「设计.监控栏」。 6. 文字选项须含 `选项`:数量,以及每项的差异维度和组成;不写可点击、按钮动作或回传协议。
7. 不选壳,不写布局、HTML、CSS、JS、Tab、SVG 或槽位替换;这些属于正文组成。
8. 产物正文不含自评、追问或开放问题。
9. `mount` 固定为 `["world-simulator", "narrator"]`;artifact tag 暂时保留 `设计.监控栏`。
## score
```score
区域必要性:
- 5:每个区域都服务明确体验或运行需要,没有为完整感添加空壳。
- 3:主体必要,但有一个区域价值偏弱或可并入其它区域。
- 1:堆满状态、线索、选项等区域,却说不清用途。
来源一致性:
- 5:模型生成、程序投影与维护通道区分准确;权威数据都有稳定 ref。
- 3:主要来源正确,但有一个字段仍可能被模型重复生成。
- 1:状态或拓扑没有权威来源,每轮靠正文模型重编。
内容可执行性:
- 5:出现条件、内容类型、正向要求和空值处理足以让下游直接生产。
- 3:区域可理解,但条件或填写要求仍有一处含糊。
- 1:只有区域名称,不知道何时出现或应填什么。
信息负担:
- 5:每轮只出现有用内容,条件区能省略,程序已有数据不重复占用模型输出。
- 3:整体克制,但有轻微重复或一个区域出现过频。
- 1:正文外长期堆积大量无关信息。
职责边界:
- 5:只定义语义内容,并把渲染、维护真相和篇幅分别交给正确节点。
- 3:主体边界正确,但夹有少量排版或重复篇幅要求。
- 1:在本步写 HTML、变量公式或重新定义推进篇幅。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 正文在必选区,且有字数/自然段等规则? - [ ] 只列实际启用区域,普通楼层含正文?
- [ ] 可选区逐项启用或刻意不启用? - [ ] 每个区域的出现条件、来源、要求与空值处理完整?
- [ ] 选项栏若开:槽气质与生成提示已写? - [ ] 状态、地图等权威数据均由程序投影,模型没有重复生成?
- [ ] 状态监控若开:字段与来源已写? - [ ] 内联维护的隐藏 state_ops 已声明运行期选路,副 Worker 路径未建立该区?
- [ ] 未写排版或 CSS/HTML/JS? - [ ] 正文只引用故事推进,没有重复字数和比例?
- [ ] 未写布局、HTML/CSS/JS、Tab、SVG 或交互动作?
``` ```
## examples ## examples
```examples ```examples
好: - 只有正文:只列 body,并引用故事推进和叙事指南。
- 必选正文默认 1000~2000 字、长自然段勿单句成段;可选状态监控=日期+好感;选项栏 A 冒险 / B 稳重,各有生成提示。 - 展示状态:新增 kv 区域,来源指向变量当前值及明确字段;内容由程序投影。
- 必选正文;不开选项、不开小故事;状态只盯体力。用户要求极短时字数可低于默认。 - 展示地图:新增 topology 区域,说明用户应看到哪些节点、连接或当前位置;具体 SVG、表格或 Tab 留给正文组成。
- 简单变量内联维护:新增用户不可见的 state_ops 语义区域;运行期优先投递为 tool call,工具不可用时进入结构化整包。
坏: - 文字选项:写数量、彼此差异和应提供的行动方向;不声明点击行为。
- 只写「要选项」却不定 A/B 气质与生成提示。 - 坏:只写「要选项」,不定数量、各项差异维度与组成,结果每轮几条选项大同小异。
- 正文无字数/段落规则。
- 在本步选壳或写 CSS。
``` ```
@@ -1,9 +1,5 @@
# 故事推进 # 故事推进
> 对话落盘技能。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 工序编排仍用「叙事指南与故事推进」;本步只钉怎么往前与用户输入用法,不钉遣词。
## meta ## meta
```meta ```meta
@@ -11,76 +7,66 @@ name: 故事推进
id: story-progression id: story-progression
artifact: 设计.故事推进 artifact: 设计.故事推进
declaration: > declaration: >
美学已有方向时:钉故事怎么往前、用户输入用法(大纲扩写或扮演停笔);不重做文风问卷 继承交互范式的描写权限与停笔时机,从偏好库选取本局约束,钉推进方式(可无专门推进),只选定一种用户输入用法;每一轮正文分成扩写(演本轮材料)与续写(材料后往下写),写清定义、字数分配与 OOC 处理;不写遣词。
when: | when: |
已有美学纲领(或等价),但尚未钉用户输入用法(大纲扩写 / 扮演停笔),或还不知道故事靠场面自己推、等用户、还是无专门推进。 已有美学纲领(或等价),但尚未钉死唯一的用户输入用法,或扩写/续写边界与比例仍不清,或还不知道故事靠场面自推、等用户决策还是无专门推进。
用户主动谈推进、节奏、停笔、大纲扩写也排。 用户主动谈推进、节奏、停笔、大纲扩写、续写、自动写入、单轮字数时也使用。
遣词未钉、叙事指南未落,不构成不排本步的理由。
when_not: | when_not: |
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。 本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
美学未钉 → 先「美学纲领与交互范式」。 美学未钉 → 先「美学纲领与交互范式」。
只改遣词、笔墨、写法档 → 「叙事指南」。 只改遣词、笔墨、写法档、段落形态 → 「叙事指南」。可见区域 → 「回复呈现」。
事件池 schema、真值增减 → 「生成规则」/「变量设计与更新规则」。 好感公式、进度表、事件池等表规格 → 「变量设计与更新规则」/「生成规则」。
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义或按局反转。
boundary: | boundary: |
本技能:一份 context-fragment,写清怎么往前 + 用户输入用法(大纲扩写或扮演停笔,勿混成半套)。 本能力只交推进合同:推进方式及可选的驱动力、成败与难度、变量钩子;唯一输入用法、单轮目标字数、扩写要求、续写要求、篇幅分配、OOC 处理策略。
叙事指南:遣词/笔墨/禁忌/写法档——本步不定。 单轮字数与篇幅只在本步定义;段落形态(长段不碎行)归叙事指南,可见区域归回复呈现。
美学 / 交互范式:感觉与轮转权限。本步把「输入怎么用」写成游玩可执行的一种。 叙事指南管怎么写;变量与生成规则管表与合同,本步只写钩子;主角详略只写偏好钩子。
生成规则/变量*:合同与表——推进只写钩子,不写公式。
feeds: gm,narrator feeds: gm,narrator
``` ```
## opening ## opening
```opening ```opening
美学里的轮转已有方向时,请谈「故事怎么往前」(想到什么写什么): 先选定这局唯一的输入用法:大纲扩写、续写,或自动写入。再说清楚单轮大约写多长;扩写(主路径不动,在用户已给的节拍之间补连接小动作,把场面写顺)和续写(从材料结束后往下写他人与世界反应)各做什么、各占多少。上帝视角/OOC 指令准备怎么处理。故事要不要专门往前推、用户会不会失败,也可以一并说;没想过就按体验先定。
1. 故事往前靠什么:场面自己推、等用户决策,还是无专门推进?
2. 用户每轮输入怎么用?未提及时按体验二选一写入(可简短确认,不必当缺口追问):
- **大纲扩写**(写手/成稿向):输入=待扩写为事实的大纲;细化完全后写正文;不超出大纲未涉及的剧情,完整演绎大纲。
- **扮演停笔**(代入扮演、等用户决策):用户本轮行为与对话作正文开头并扩写填满;不可写用户角色未给出的其它行为/对话;不可替用户角色做决定;正文停在其他角色等用户角色回应之刻。
``` ```
## task ## task
```task ```task
你正在执行「故事推进」。产物必须是 **context-fragment.v1** JSON,写入「设计.故事推进」。 把本局推进合同写入 `设计.故事推进`。先继承上游「美学纲领与交互范式」:把 `交互范式.描写权限`(思考与抉择、言语、主动行动、用户输入后)、`后果与叙事控制` 与 `停笔时机` 改写成推进做法;交互范式已写过停笔权限,也要落进本步的用法与续写要求,不因已有而省略。再用 `read_library_entry` 从绑定的偏好库读取最相关 1~2 条并本局化,然后钉推进方式(不明则写「无专门推进」),只选定一种用户输入用法。
钉清本局故事怎么往前、用户每轮输入怎么用。作用对象是主世界层与转述的游玩正文。 扩写与续写是每一轮正文里的两段工作,不是两种输入用法。按所选用法写清单轮目标字数、扩写做什么、续写做什么、两者各自篇幅预算,以及裸写(上帝视角/OOC)指令如何处理。
执行顺序: 禁止混用两种用法或写成半套规则。固定标记怎么识别交给程序;本步只写识别之后怎么服从与落笔。不写遣词、段落形态、示范段落或版式。
1. 继承美学/交互范式里的轮转(何时等用户),改写成推进做法。
2. 写推进与决策(可写「无专门推进」);变量只写钩子。
「用户输入用法」未提及时:按美学/交互范式选一种写入产物——
- 写手/大纲成稿 → **大纲扩写**(输入=大纲→细化成正文;不越界、完整演绎);
- 代入扮演、轮转等用户决策 → **扮演停笔**(输入的行为与对话作正文开头并扩写填满;不写用户角色未给出的行为/对话;不替用户角色做决定;停在其他角色等回应之刻)。
两种都像则写进追问让用户点一次;不要混成半套规则。
3. 自评 + 追问。遣词、笔墨、写法档交给「叙事指南」,本步不写。
summary:`故事推进 · …` 材料足够便直接成稿;多种用法都像、必须用户点选时,把缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
``` ```
## principles ## principles
```principles ```principles
1. 推进可轻可无;表规格交给变量/生成规则。 1. `用户输入用法` 与「扩写 / 续写」不是同一层。用法是本局选哪一种合同(三选一);扩写、续写是每一轮正文里先后做的两段工作。产物必须先按所选用法写清这两段各自做什么,再分配篇幅。
2. 用户输入用法两套可选默认(未提及时择一,勿混用): 2. 扩写:主路径不动,把用户本轮已经给出的材料演成流畅场面。人、事、话、结果保持不变;允许在节拍之间增加使动作接得上的小动作、感官和当场小纠正。只加形容词、比喻或气氛、不增加这些连接动作,不算扩写。另起新冲突、新目标、新决定,或改掉已定结果,算出界。
- 大纲扩写:输入=大纲→扩写为事实→细化后正文;不发明大纲未涉及的剧情,完整演绎大纲。 例:用户写「打开冰箱门、放入大象、关闭冰箱门」——主路径就是这三步,结果仍是大象进了冰箱。只写这三句会不流畅,扩写应补门怎么拉开、大象怎么塞进去、门关不上再用力扣上等细节;不能改成没放进去、或中途改去别的房间。
- 扮演停笔:用户本轮行为与对话→正文开头并扩写填满;禁止描述用户角色未输入的其它行为/对话;禁止替用户角色做决定;正文停在其他角色需要用户角色回应之刻。 - 用法为「大纲扩写」时:材料是待演成场面的大纲,不是终稿句子。每个节拍都要演到;节拍之间必须补连接动作,大纲没写这些动作也要写。另例:大纲「我去相亲、坐了下来,她说要婚后自由出轨权,我拒绝了」——坐下前写先要一杯咖啡,又急忙改成两杯,显出局促;她的要求和拒绝的结果不动。
3. 交互范式已有停笔权限时:把它收成上面两种之一写入产物,不要因为权限已有就不落本步。 - 用法为「续写」或「自动写入」时:材料是用户本轮已声明的行为与对话。扩写只在这些声明之间补过程与连接动作,可写他人怎么接这一下;不另写用户没声明的新决定。
``` 3. 续写:从用户材料结束后往下写。写其他角色与世界如何反应、把场面推到下一个停笔点。默认不可再写用户角色的实际行动、说话或带倾向抉择;最多被动占位(望去、让开)。整轮停在其他角色等用户角色行动抉择之刻。
- 用法为「大纲扩写」或「续写」时:禁止替用户角色做决定,不引入材料外的新剧情事实。
## probe - 用法为「自动写入」时:可按主角设定代演用户角色言行,把续写写长;关键抉择仍按交互范式的权限处理。
4. 三种输入用法互斥,按体验与交互范式选一种,写入时只保留一个完整合同。差别在输入是什么、扩写/续写谁长谁短、续写能不能代演用户角色:
```probe - 大纲扩写:用户是写手、要大纲成稿,输入多为剧情节拍时选它。扩写充分、续写短。
本步只钉两件:推进方式(场面推进 / 用户决策 / 无专门推进)、用户输入用法(大纲扩写 / 扮演停笔)。 - 续写(旧称扮演停笔):代入扮演、轮转等用户决策时选它(交互范式规定关键抉择或言语只由用户做、停笔时机是等用户回应)。输入往往较短;扩写只跟已声明内容,续写多于大纲扩写、短于自动写入。
- 自动写入:用户只给短 meta 指令或极简扮演、希望 AI 代演用户角色,且交互范式允许模型代写 user 言行(旁观、操控站位常见)时选它。扩写少、续写宜长。
用户未提输入用法时:按体验选一种写入产物并简短确认;两者都像才追问二选一,不必当成大缺口。 5. 多写“要怎样完成扩写/续写”,少写否定清单;只有会破坏该用法边界的禁点才保留。
推进方式不明则正文写「无专门推进」,不问好感公式、进度表或事件池。 6. 推进可轻可无:`推进方式` 写「场面自推 / 等用户决策 / 无专门推进」之一,不明则写「无专门推进」。不自己发明好感公式、进度表或事件池;表规格交给「变量设计与更新规则」与「生成规则」。
7. 推进子维度按需填,推进方式为「无专门推进」时可全省:
缺其中一件:在产物「追问」写 1~2 点。两件够验收:`追问.题目` 为 `[]`。 - `驱动力`:故事靠谁、靠什么往前(NPC 主动作为、世界压力与时限、用户决策),承接交互范式的 `世界对user的影响` 与 `引入新内容`。
- `成败与难度`:用户行动会不会失败、失败怎么写(代价落在哪、挫败场面写多重),承接 `行为后果判定` 与 `设计困境`。体验本身要顺遂碾压时写明「不设失败」以及仍保留的阻力;未写明时不默认无条件顺遂。
用户谈到遣词、笔墨、写法档:写入「开放问题」,交给「叙事指南」。 - `变量钩子`:推进挂在哪些变量上,每条一句「变量名:推进如何看它」;只写钩子,不写公式、阈值或数值表。
用户谈到变量增减、生成规则:只抽取能改推进方式的一句,其余写入「开放问题」。 8. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
9. 偏好库是参考:读取后改成本局可执行要求,不照抄。
10. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。段落形态归叙事指南,本步不写。
11. 不写遣词、写法档、段落形态、版式或事件池公式。
``` ```
## output ## output
@@ -89,46 +75,90 @@ summary:`故事推进 · …`
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "故事推进", "技能": "故事推进",
"brief": "一句话:推进有无 + 输入用法", "brief": "一句话:唯一输入用法 + 单轮篇幅与扩写/续写分配",
"mount": ["world-simulator", "narrator"], "mount": ["world-simulator", "narrator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据的体验": ["…"], "选用偏好": [
"推进与决策": { {
"有无专门推进": "有|无", "库条目": "preferences 中的 id",
"驱动力来源": "…", "本局要求": "本局化后的可执行约束"
"用户输入用法": "择一写入。大纲扩写:输入=待扩写为事实的大纲;细化后写正文;不超出大纲未涉及处,完整演绎。扮演停笔:用户本轮行为与对话作正文开头并扩写填满;不可写用户角色未给出的其它行为/对话;不可替用户角色做决定;停在其他角色等用户角色回应之刻。用户另定则改写", }
"叙事速度与详略": "…",
"成败与难度": "…",
"变量门控钩子": []
}
},
"自评": {
"维度": [
{ "名": "推进可执行", "分数": 8, "说明": "…" },
{ "名": "输入用法钉清", "分数": 8, "说明": "…" }
], ],
"薄弱点": "" "推进方式": "场面自推 / 等用户决策 / 无专门推进",
}, "驱动力": "可选:故事靠谁、靠什么往前;无专门推进时省略",
"追问": { "导语": "", "题目": [] }, "成败与难度": "可选:用户行动会不会失败、失败怎么写、代价落在哪;无专门推进时省略",
"开放问题": [] "变量钩子": ["可选:变量名:推进如何看它(只写钩子,不写公式)"],
"用户输入用法": "大纲扩写 | 续写 | 自动写入",
"扩写要求": [
"主路径(人、事、话、结果)不动;节拍之间补连接小动作,把场面写顺。例:打开冰箱、放入大象、关上冰箱——补怎么塞、门怎么扣,不能改成没放进去",
"所选用法下材料是什么、节拍之间补什么(按 principles 第 2 条写具体)"
],
"续写要求": [
"从材料结束后写他人与世界反应,停在等用户角色回应之刻",
"所选用法下能否代演用户角色、续写写到哪停(按 principles 第 3 条写具体)"
],
"单轮篇幅": {
"目标字数": "明确数字或窄区间",
"扩写占比": "百分比",
"扩写字数预算": "与目标字数及占比一致",
"续写占比": "百分比",
"续写字数预算": "与目标字数及占比一致",
"自然浮动条件": "为完成当前句、动作或必要停笔点允许怎样小幅浮动"
},
"OOC处理": "裸写/上帝视角指令如何服从、是否打断场面、如何与比例兼容",
"主角设定偏好": "大纲扩写或续写→客观设定为主;自动写入→详性格等主观面;无主角则省略"
}
} }
``` ```
硬规则:合法 JSON;输入用法必须是大纲扩写或扮演停笔之一(或用户另定的完整写法),禁止半套;禁止 order;summary 见 task。 硬规则:
不写遣词示范、写法档、禁忌清单。 1. 输出合法 JSON;`用户输入用法` 只能是三者之一,不得并列或混装半套。
2. `扩写要求` 只写「把本轮材料演成场面」怎么做;`续写要求` 只写「材料结束后往下写」怎么做。不要把另一种输入用法的规则抄进这两项。两项占比合计 100%,字数预算与目标字数对应。
3. `推进方式` 是单个字符串;`驱动力`、`成败与难度`、`变量钩子` 可选,`变量钩子` 不写公式或数值。
4. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
5. 不重定义裸写 / `#行动#` / 引号的识别规则;只写 OOC 处理策略。
6. 不在产物正文写自评、追问、开放问题、遣词示范或段落形态。
7. `mount` 固定包含 `world-simulator` 与 `narrator`。
## score
```score
唯一性与切分:
- 5:只选定一种用法,且与交互范式的权限和停笔时机相符;扩写(演本轮材料)与续写(材料后往下写)定义清楚,目标字数和预算切分清楚,无第二种用法残留。
- 3:主用法明确,但仍有一处边界、占比或字数预算含糊。
- 1:混用多种用法,或把扩写/续写当成另一种输入用法、两段职责无法区分。
可执行性:
- 5:按该合同可直接开玩,推进方式、扩写、续写、停笔与 OOC 处理都能执行;有推进时成败与难度写清,不默认无条件顺遂。
- 3:大方向可用,但停笔、成败或 OOC 处理仍需猜测。
- 1:只有用法名称,没有可执行要求,或自行发明公式与进度表。
偏好本局化:
- 5:已读偏好并改成本局要求,与唯一用法咬合。
- 3:有选用,但微调不足或与用法略冲突。
- 1:未读库却声称偏好,或照抄且与本局脱节。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 推进清楚或明确无?输入用法是完整的一种? - [ ] 已继承交互范式的描写权限与停笔时机并落进本步?
- [ ] 未把遣词/写法档写进本步? - [ ] 推进方式已写(不明则「无专门推进」),未发明公式、进度表或事件池?
- [ ] 只选定一种用户输入用法,且符合它的适用场景?
- [ ] 扩写与续写已按两段工作分别写清(扩写=演本轮材料,续写=材料后往下写),且与所选用法相符?
- [ ] 扩写已写明「主路径不动、节拍之间补连接小动作」?只加形容词、或改掉已定结果,都不算写清。
- [ ] 单轮目标字数、扩写/续写定义、占比与字数预算已写清且互相一致?
- [ ] OOC/裸写处理策略已写,且未重定义固定标记语法?
- [ ] 偏好已按需读取并本局化?
- [ ] 未写入遣词、写法档、段落形态或半套混用规则?
- [ ] 产物正文不含评分、诊断、追问?
``` ```
## examples ## examples
```examples ```examples
好:推进写「等用户决策」;输入用法钉「扮演停笔」。 好:用法钉「续写」;用户写「我推开门」——扩写补门轴、屋里人抬头,不另写用户没说的台词;续写写屋里人的反应,停在等用户开口之刻。
好:推进写「无专门推进」;输入用法钉「大纲扩写」,并写清不越界、一次成篇。 好:用法钉「大纲扩写」;用户写「打开冰箱门、放入大象、关闭冰箱门」——扩写补门怎么拉开、大象怎么塞、门关不上再扣上,三步结果不变;续写短。
坏:把两套输入用法混成半套;追问好感公式 / 事件池;或在本步写遣词示范。 坏:把「扩写」理解成另一种输入用法;只把三句动作各加形容词、场面仍不接;中途改成没放进冰箱或另起新目标;续写里替用户角色补一句台词。
``` ```
@@ -1,9 +1,5 @@
# 拓扑图谱 # 拓扑图谱
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 必须生成、但不适合走「生成规则→具体实例」时:用拓扑结构写出来。
## meta ## meta
```meta ```meta
@@ -11,70 +7,60 @@ name: 拓扑图谱
id: topology id: topology
artifact: 设计.拓扑图谱 artifact: 设计.拓扑图谱
declaration: > declaration: >
有些东西必须生成,但不需要或不适合走「生成规则→具体实例」时:用拓扑结构写出来(升级路径、地图、人物关系等) 为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则;可用于地图、关系链、升级树等可变备忘录。
when: | when: |
已有「美学纲领与交互范式」或等价整体感觉,且同时满足: 单个对象要用节点和连接才能写清时使用,例如地图、升级路径、关系网。
这些东西必须生成;不需要或不适合走「生成规则→具体实例」;能用拓扑结构描述。 一张图一份产物。不因开场后会不会再长节点而改用别的能力。
很泛:升级路径、地图、人物关系等等都可以。
when_not: | when_not: |
整体感觉未钉 → 先「美学纲领与交互范式」。 没有节点和连接,只是同类对象的多份记录,或一份要先审字段的合同 → 生成规则,再按需填具体实例。
需要精准格式,或本局原创听了会雾且还要按规则生成 → 「生成规则」;已有规则再要名片 → 「具体实例」。 只有一条字段很少的具名内容,且用户明确要留下 → 具体实例。
只是活动区域写细、没有拓扑可描述 → 「舞台骨架」。 只有地点、势力或社会格局的叙述,没有可定义的连接 → 舞台骨架。
跨轮真值 → 「变量设计与更新规则」。执行单元勾选 → 「游玩拓扑」。 只需保存少量数值或枚举 → 变量设计。
没有必须生成的拓扑结构 → 不排。
boundary: | boundary: |
本技能:用拓扑结构写出必须存在、又不走生成规则合同的内容(节点、连接、方向/层级)。 本能力定义一张图的结构真相与维护合同,不负责界面、SVG、排版或是否展示。
生成规则 / 具体实例:复杂合同与 records——本步不写生成格式、不填名片表。 「正文组成」决定是否渲染以及显示入口;维护合同编译工具负责把本产物的更新要求交给运行期维护者。
舞台骨架:社会结构与活动区(谁在台上、区怎么划)——有节点与连接才走本步;仅格局叙述、无边可画 → 舞台骨架。 本能力不设计 Worker 拓扑,不把图拆成生成规则,也不把节点扩写成人物卡或地点百科。
游玩拓扑:固定槽勾选——本步不描述执行单元谁读谁写。 feeds: gm,auditor
变量*:跨轮状态——本步不设计变量表。
feeds: gm
``` ```
## opening ## opening
```opening ```opening
先说要写哪块拓扑(想到什么写什么): 说说要保存的这张图是什么、游玩时用它记住什么,以及开场后它应保持固定、只记录状态,还是允许继续长出节点和连接。
1. 有什么必须生成、又不适合走「生成规则→具体实例」?
2. 它的拓扑是什么:升级路径、地图、人物关系,还是别的连接?
例:家门→小区门→花园↑;谁连谁;功法从哪一阶接到哪一阶。
``` ```
## task ## task
```task ```task
你正在执行「拓扑图谱」。产物必须是 **context-fragment.v1** JSON,写入「设计.拓扑图谱」。 为一个独有的结构对象设计拓扑图谱,写入 `设计.拓扑图谱#图名`。每份产物只定义一张图:先写名称与用途,再定义节点字段、连接字段和初始图,最后写开场后的动态模式与维护规则。
本步写出必须生成、但不需要或不适合走「生成规则→具体实例」的内容。用拓扑结构描述即可:升级路径、地图、人物关系等都很泛,能连起来就写。 动态模式只选一种:
- `static`:节点、连接和状态均固定。
- `record`:节点与连接固定,但可记录发现、备注或节点/连接状态。
- `expand`:允许按规则新增、修改或删除节点与连接。
执行顺序: 拓扑是呈现无关的运行备忘录。只说明存什么、怎样合法修改;不生成 SVG、坐标、前端、Tab 或正文展示格式。需要从正文事实推断的变化交给统一维护副 Worker;必须在正文前完成的机械更新才标为 `pre_narrative`。
1. 认定本步写哪几块拓扑(名称 + 为何必须生成、为何不走生成规则)。
2. 每块列出节点与连接(从哪到哪、什么关系、有无方向或层级)。
3. 路径写清顺序或岔路;关系写清称谓或从属;升级写清阶与阶怎么接。点保持短名,不写成百科或名片表。
4. 填齐正文 + 自评 + 追问,输出 JSON。
若程序已发默认问题:禁止重复开场;在首答上补洞。 材料足够便直接成稿;真正会改变结构合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
summary:`拓扑图谱 · {名称或类型}`。
``` ```
## principles ## principles
```principles ```principles
1. 先问:必须生成吗?走「生成规则→具体实例」是不是过重或不合适?能用拓扑结构描述吗?三问成立才写。 1. 一产物一张图。不同地图、关系网或升级树分别调用本能力,不能塞进同一 `拓扑` 数组。
2. 拓扑很泛:升级路径、地图、人物关系都是同一类产物,不要只当成探索地图。 2. 用途决定字段。节点与连接只保留识别、推演和维护所需信息;字段不重叠,不把节点写成完整名片。
3. 节点短、连接准。点不是名片,连接不是散文,也不是生成合同。 3. 节点 id 稳定且唯一;连接只能引用已存在节点,并明确方向、关系和必要属性。
4. 该用规则反复生成的对象族不要塞进本步。 4. 动态模式决定维护权限:`static` 无运行写入;`record` 只改允许的状态字段;`expand` 才能改变图结构。
5. 仅有「谁在台上、如何分层」的格局叙述、画不出边 → 交给舞台骨架,本步不写。 5. 维护规则写触发事实、允许操作、执行时机、写入者和不变量。多数由正文事实引起的变化使用 `post_narrative`。
6. 图数据与显示分离。相对位置若是世界真相可作为节点/连接字段;屏幕坐标、缩放、颜色与排版不进入本产物。
7. 初始图只写已确定内容;可扩展不等于预先填满所有未知节点。
8. 给人读的键和专名用中文。材料里的英文名音译成中文。外壳的 schema、brief、mount 保持既有键。
``` ```
## probe ## probe
```probe ```probe
每轮 1~2 点。已答清的禁止重问。 每轮 1~2 点。优先确认用途、节点/连接各需哪些字段、动态模式,以及哪些变化必须跨轮记住。展示方式留给正文组成,不在这里追问。
优先:为何不走生成规则;这块拓扑是路径、地图还是关系;缺了哪条连接会断。
不追问:执行单元读写、生成格式与池、变量公式、舞台百科、具体名片字段。
``` ```
## output ## output
@@ -83,85 +69,136 @@ summary:`拓扑图谱 · {名称或类型}`。
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "拓扑图谱", "技能": "拓扑图谱",
"brief": "一句话:写了哪几块拓扑", "brief": "一句话说明这张图保存什么,以及开场后如何维护",
"mount": ["world-simulator"], "mount": ["world-simulator", "auditor"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"拓扑": [ "图名": "这张图的中文短名,稳定且唯一",
"名称": "这张地图 / 关系链 / 升级树的名称",
"用途": "为什么需要保存它,后续据此推演或记忆什么",
"类型": "map|relation|path|tree|network|other",
"动态模式": "static|record|expand",
"节点字段": [
{ {
"名称": "升级路径 / 地图 / 人物关系 / …", "键": "名称",
"类型": "路径|地图|关系|其它", "类型": "string|number|boolean|enum|set",
"用途": "为何必须生成、游玩里怎么用", "填写要求": "此字段保存什么、何时必须填写",
"节点": [ "可更新": false
{
"id": "英文或短码",
"名称": "家门 / 某阶 / 某人短名",
"是什么": "一句;不是名片"
}
],
"连接": [
{
"from": "节点 id",
"to": "节点 id",
"关系": "通往 / 下一阶 / 父女 / …",
"方向": "单向|双向",
"备注": "如 ↑ 上楼;无则空字符串"
}
]
}
]
},
"自评": {
"维度": [
{
"名": "时机合适",
"分数": 8,
"说明": "是否必须生成、且不该走生成规则→具体实例"
},
{
"名": "结构清楚",
"分数": 8,
"说明": "拓扑是否写清、有无断连"
} }
], ],
"薄弱点": "一句话" "连接字段": [
},
"追问": {
"导语": "为钉准这块拓扑,还需确认:",
"题目": [
{ {
"问": "…", "键": "关系",
"建议选项": ["选项A", "选项B", "其它(请写明)"], "类型": "string|number|boolean|enum|set",
"示例": "可选" "填写要求": "此字段保存什么、何时必须填写",
"可更新": false
} }
] ],
}, "初始图": {
"开放问题": [] "节点": [
{
"id": "稳定节点 id",
"字段": {
"名称": "节点短名"
}
}
],
"连接": [
{
"id": "稳定连接 id",
"from": "起点 id",
"to": "终点 id",
"directed": true,
"字段": {
"关系": "连接含义"
}
}
]
},
"维护规则": {
"允许操作": ["update_node_state"],
"默认时机": "post_narrative",
"写入者": ["auditor", "user"],
"触发与更新": [
{
"当": "正文中出现什么已成立事实",
"做": "对哪个目标执行什么操作",
"要求": "操作需要满足的条件"
}
],
"不变量": [
"节点 id 不复用;连接端点必须存在"
],
"用户可编辑": [
"允许用户直接修正的字段;无则空数组"
]
},
"维护摘要": {
"给统一维护合同": [
"需要投影给维护副 Worker 的短规则"
],
"当前状态引用": "运行.拓扑#图名"
}
}
} }
``` ```
硬规则: 硬规则:
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 输出合法 JSON;一份产物只含一张图,并提供稳定中文 `图名`。
2. 正文含 `拓扑` 数组;每块含名称、类型、节点、连接。 2. `节点字段 / 连接字段` 必须说明类型、填写要求和是否可更新;字段应足够且不重叠。
3. `mount` 默认主世界层;**禁止**写 `order`。不写执行单元槽位、不交 records、不写生成 schema。 3. 初始连接引用的节点必须存在;节点与连接 id 各自唯一。
4. summary:`拓扑图谱 · {brief 缩略}`。 4. `static` 的允许操作与触发更新必须为空;`record` 不得允许新增或删除结构;只有 `expand` 可改变节点与连接。
5. 维护规则必须写默认时机、写入者和不变量;不得把正文生成者要求为在可见正文中输出隐藏维护块。
6. 不输出 SVG、屏幕坐标、CSS、前端或展示入口;这些属于正文组成。
7. 产物正文不含自评、追问、开放问题或多张图。
8. `mount` 固定为 `["world-simulator", "auditor"]`。
## score
```score
对象适配:
- 5:确实是一张独有的关系结构,普通实例或扁平变量不足以表达。
- 3:存在真实连接,但部分内容可移交舞台、实例或变量。
- 1:只是清单、名片或背景散文,没有拓扑必要性。
字段合同:
- 5:节点与连接字段由用途推出,数量恰当、互不重叠,更新权限明确。
- 3:主体可用,但有字段含义含混、缺少必要状态或略有重复。
- 1:只有通用名称/备注,或堆入整份人物卡与地点百科。
初始图有效性:
- 5:id 稳定唯一,连接均有有效端点,方向与关系足以支持后续推演。
- 3:结构基本成立,但有一处连接、方向或字段需要猜测。
- 1:存在断边、重复 id,或无法判断节点怎样相连。
维护可执行性:
- 5:动态模式、触发事实、允许操作、时机、写入者和不变量彼此一致。
- 3:更新方向正确,但触发条件或操作边界仍不够明确。
- 1:只说“后续更新”,维护者无法据此生成合法 patch。
长期一致性:
- 5:返回旧节点、追加新结构和并发修正时都有明确边界,不会重造既有事实。
- 3:普通更新稳定,但扩展或冲突处理仍有模糊处。
- 1:每轮都可能重写整张图,无法保证跨轮一致。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 这一产物只定义一张确有拓扑必要性的图?
- [ ] 每块拓扑有节点和连接?路径/地图/关系能认? - [ ] 节点与连接字段足够、不重叠,并声明可否更新?
- [ ] 确是必须生成且不适合走生成规则→具体实例? - [ ] 初始图无重复 id、断边和含糊方向?
- [ ] 动态模式与允许操作一致?
- [ ] 维护触发、时机、写入者和不变量足以编译进统一维护合同?
- [ ] 未提前设计渲染、界面或可见正文维护块?
``` ```
## examples ## examples
```examples ```examples
好:家门→小区门→花园↑——地图拓扑,连接带方向。 - 固定空间:节点与通路固定;若只需记录“已发现/封锁”,使用 record,不因探索重新生成建筑结构。
好:家族谁连谁——关系拓扑,连接是称谓。 - 关系网络:人物节点固定或可扩展,关系边可记录类型、强度与公开状态;关系变化必须由正文中的已成立事实触发。
好:功法一阶接到二阶、旁支接到某秘术——升级路径。 - 升级树:阶段和前置连接固定时用 static;若允许游玩中发现新分支才用 expand,并写清新增分支必须满足的前置关系。
坏:其实该按规则反复生成的对象族(如心之彩)硬写成拓扑。 - 不合适:把若干互不相连的人物简介装进节点;应改用生成规则,需要留下的单条再写具体实例。
坏:把舞台活动区再抄一遍,没有连接。 - 不合适:在本产物中指定 SVG 坐标、颜色和 Tab;展示应由正文组成决定。
坏:只有「谁在台上、如何分层」的格局散文、画不出边 → 舞台骨架,不是本步。
坏:描述主世界层谁读谁写 → 游玩拓扑,不是本步。
``` ```
@@ -1,118 +0,0 @@
# 用户需求
> 对话落盘与工序编排共用。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 从全局【偏好库】选用硬约束写入本局;不管文风遣词(那是叙事指南)。
## meta
```meta
name: 用户需求
id: user-requirements
artifact: 设计.用户需求
declaration: >
把跨局偏好库里对本局生效的硬约束(禁区/纠偏/默认取向)落成可执行条目;
不重做文风问卷,也不钉体验站位
when: |
提示中有【偏好库 · 可选用】且库非空,或用户主动谈跨局禁区、绝对不要写什么、默认取向要进本局。
开局常见:库里已有起步条目且本局尚无「设计.用户需求」。
when_not: |
本局已有等价「设计.用户需求」且用户未要求改偏好 → 不重复排。
只谈文风遣词/示范 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。
只谈体验站位/核心感觉 → 「美学纲领与交互范式」。
库空且用户未提硬约束 → 不排。
boundary: |
本技能:一份 context-fragment,写清本局必须遵守的硬约束列表。
文风:叙事指南。体验:美学。推进:故事推进。
feeds: narrator,gm
```
## opening
```opening
偏好库里已有跨局硬约束时,请确认本局要带哪些(想到什么写什么):
1. 哪些条目本局生效?有没有要改写、收紧或放宽的?
2. 有没有库外、但本局一定要遵守的禁区/纠偏?(一句话即可)
```
## task
```task
你正在执行「用户需求」。产物必须是 **context-fragment.v1** JSON,写入「设计.用户需求」。
作用对象是本局游玩固定上下文(主世界层与转述都读)。只写硬约束,不写遣词示范。
执行顺序:
1. 若提示中有【偏好库 · 可选用】:默认把 active 条目纳入,再按用户点名增删/改写;勿整库无脑粘贴原文若用户只要子集。
2. 用户本局补充的禁区/纠偏:写成可执行短句,与库条并列。
3. 自评 + 追问。文风交给叙事指南;体验站位交给美学。
summary:`用户需求 · …`
```
## principles
```principles
1. 硬约束 ≠ 文风:禁区/取向/纠偏归本步;遣词与示范归叙事指南。
2. 条目要短、可执行;写「不要 X,改做 Y」优于空泛「注意分寸」。
3. 库空且用户无补充 → 可不排本步;有库则优先落盘,勿装作没有。
4. 与本局世界观许可冲突时,以本步产物为准(游玩侧已约定)。
```
## probe
```probe
本步只钉:本局生效的硬约束列表是否够用、有无漏掉用户点名的禁区。
库条目用户未否决的默认纳入;用户只要子集则只写子集。
缺关键禁区且用户话里有信号:在「追问」写 1 点。够用:`追问.题目` 为 `[]`。
用户谈到文风遣词:写入「开放问题」,交给叙事指南。
用户谈到站位/核心感觉:写入「开放问题」,交给美学。
```
## output
```output
{
"schema": "context-fragment.v1",
"技能": "用户需求",
"brief": "一句话:本局硬约束重心",
"mount": ["world-simulator", "narrator"],
"稳变": "stable",
"正文": {
"来源说明": "偏好库选用 + 本局补充",
"约束": [
"短、可执行的一条",
"…"
]
},
"自评": {
"维度": [
{ "名": "可执行", "分数": 8, "说明": "…" },
{ "名": "覆盖用户点名", "分数": 8, "说明": "…" }
],
"薄弱点": ""
},
"追问": { "导语": "", "题目": [] },
"开放问题": []
}
```
硬规则:合法 JSON;`约束` 至少 1 条(若用户明确「本局不要任何跨局偏好」则可写一条「本局不套用偏好库」);禁止 order。
## checklist
```checklist
- [ ] 约束短且可执行?
- [ ] 未把文风遣词/体验站位写进本步?
- [ ] 用户点名的禁区已覆盖或已追问?
```
## examples
```examples
好:纳入偏好库 NTR/取向两条,并加一条「不要反复报精确数字」。
坏:写成叙事遣词示范;或整库粘贴却无视用户「只要取向那条」。
```
@@ -1,4 +1,4 @@
# 变量控制上下文 # 信息可见范围
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 上游:`variable-design`;方法:`docs/progressive-data-design.md`。 > 上游:`variable-design`;方法:`docs/progressive-data-design.md`。
@@ -6,23 +6,18 @@
## meta ## meta
```meta ```meta
name: 变量控制上下文 name: 信息可见范围
id: variable-context id: variable-context
artifact: 设计.变量控制上下文 artifact: 设计.变量控制上下文
declaration: > declaration: >
已有真值或映射时:钉谁看见什么(主世界层/转述/旁观)、未解锁不注入;无信息差则可不排 程序根据变量、拓扑和秘密声明整理谁能看到什么、何时可以看到;不让模型另造一份状态摘要
when: | when: |
变量设计已有真值或 Data 映射,且存在信息差:玩家不该看见但主世界层/旁观要知道,或到某档才允许提起,或旁观需要「当前档摘要」。 落档或开玩时自动执行;读取已有公开性、写入权限、揭示条件和固定执行单元。
when_not: | when_not: |
尚无变量且无投影 → 不排。 无变量、拓扑、秘密或其它可变状态时输出空规则即可,不向用户提问。
仅内部记账、永不进任何提示词 → 跳过,在变量设计备注即可。
只改字段与更新规则 → 「变量设计与更新规则」。
真值对所有槽同样可见、无剧透边界 → 可不排。
boundary: | boundary: |
本技能:挂载表、剧透边界、旁观汇总视野、与维护语句/投影 tag 对齐。 上游内容节点决定公开性、秘密与揭示条件;本程序只编译读取范围,不发明语义。
变量设计:字段、映射索引、维护语句形状、side_effects。 回复呈现决定展示哪些安全投影;正文组成决定如何渲染。
具体实例:表行正文——本步不重生成,只声明「旁观读当前档/哪几个摘要键」。
回复呈现 / 正文组成:用户可见层(看什么 / 怎么展示);本步管提示词视野。
feeds: gm,auditor feeds: gm,auditor
``` ```
@@ -1,9 +1,5 @@
# 变量设计与更新规则 # 变量设计与更新规则
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 方法:`docs/progressive-data-design.md`;外壳:`docs/context-fragment-design.md`。
> 与「正文组成」隐藏段、生成规则/具体实例的 Data 表、旁观汇总对齐。
## meta ## meta
```meta ```meta
@@ -11,78 +7,59 @@ name: 变量设计与更新规则
id: variable-design id: variable-design
artifact: 设计.变量设计与更新规则 artifact: 设计.变量设计与更新规则
declaration: > declaration: >
体验必须跨轮记住少数状态时:钉真值、谁可写、如何变、维护语句,以及按值换文案的映射索引 定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射;有真值时由程序固定启用旁观副 LLM 按准则填表。
when: | when: |
体验需要跨轮记住少数状态,否则会糊:必须可查询、门控、防重复,或到某值换一套态度/文案/章纲。 某项状态若不持久保存,就会导致数值、阶段、资源、门控或已发生事实跨轮漂移时使用。
用户点名要好感/资源/阶段数字、分档换性格、进度门控时排。
when_not: | when_not: |
无状态纯对话,或状态只临场描写、从不程序化 → 不排。 状态只需从最近正文理解、无需查询或门控 → 不使用。
只要大段设定不要门控 → 「舞台骨架」/「生成规则」。 节点与连接构成的地图、关系或路径 → 拓扑图谱。
只规定谁的提示词看见什么 → 「变量控制上下文」。 大段设定或同类对象合同 → 舞台骨架 / 生成规则。
只规定每轮给用户看哪些(正文/字数/变量/日期)→ 「回复呈现」。
boundary: | boundary: |
本技能:真值清单、更新规则、维护语句约定、Data 映射索引、side_effects 草案。 本能力定义什么是真值、什么事实允许它改变,以及改变后的约束。
分档长文/性格切片正文:由生成规则定格式、具体实例填表;本步做索引与门控,不重写整表。 表维护固定走旁观副 LLM(读上一份表、本产物维护准则、本轮主世界正文);不在此决定内联 tool / state_ops。
正文组成:隐藏段定界符——本步钉「维护语句写什么」;定界样式与之对齐。 变量控制上下文负责详细可见范围;正文组成负责展示。本能力不写隐藏段、正则、maintain 包或界面格式。
随机范围整理:检定/掷骰取值范围汇总(程序插备用数)——不是变量表维护手段;本步不收随机表。 feeds: gm,auditor
变量控制上下文:挂载对象与旁观汇总视野。
细化终稿:收成 tables.side_effects。
feeds: gm
``` ```
## opening ## opening
```opening ```opening
先说「玩的时候必须记住、否则体验会糊」的东西: 说说哪些状态必须跨轮准确记住,以及什么已经发生的事实才能让它们改变。只列不保存就会出错的真值;临时描写不用变量化。
1. 数字/阶段?(好感、章节、资源…)一次性门?
2. 有没有「到了某值换一套态度/大纲」的表?(表正文可已在具体实例里)
3. 每轮如何声明变量变了?(旁观维护 maintain.v1 / 正文隐藏段键值 / 两者)
4. 旁观或主世界层是否需要一份「当前映射摘要」(如好感档→性格要点)?
``` ```
## task ## task
```task ```task
你正在执行「变量设计与更新规则」。产物必须是 **context-fragment.v1** JSON,写入「设计.变量设计与更新规则」。 为本局定义跨轮真值及其更新规则,写入 `设计.变量设计与更新规则`。先做必要性检验,再为每个真值写稳定 id、类型、初始化要求、公开性、允许写入者、允许操作、范围和基于事实的更新规则。
三分: 区分两类判断:
- **真值**:跨轮必须记住的少数字段(好感、章节…) - `deterministic`:程序能从明确指令、公式或工具结果直接计算。
- **Data 映射**:阈值→切片大表(性格档、章纲、事件池)——格式归生成规则,行归具体实例;本步建索引并指向实例/规则 id - `narrative_inference`:必须结合本轮正文中已经成立的事实判断;须写证据要求,不能凭倾向猜测。
- **Progressive**:side_effects 按真值边沿投影当前切片;旁观/主世界层读投影或汇总,不整表常驻
**维护语句**:规定如何声明变更(供程序拆进 `变量.当前`)。主路径是旁观维护 `maintain.v1`(读本轮正文推断 table_ops);可选正文隐藏段键值。主世界层不交 settlement 裁决包。 若真值只用于选择分档文本或实例切片,保存真值并引用映射来源,不把派生结果建立成第二个可写真值。若跨越阈值会触发一次性或边沿效果,写成触发规则。
这不是掷骰;随机数走「随机范围整理」+ 程序插入的备用数。隐藏段定界与「正文组成」对齐。
执行顺序: 可写简短「维护复杂度」备注(交叉依赖、须结合正文推断的点),供旁观副 LLM 参考;**程序有真值即固定开旁观填表**,不再按建议在内联/旁观间切换。勿手写 `play_slots`。
1. 读美学/机制/生成规则/具体实例/正文组成;不重开体验。
2. 立真值(承重才建);写写入源与更新规则。
3. 建 Data 映射索引:每张表写清键依哪个真值、材料来自哪条 rule_id/batch、给旁观/投影用的摘要字段。
4. 写维护语句约定:形状、必填键、与隐藏段/旁观维护的关系、示例一句。
5. 写 side_effects(once/every_edge → replace_tag 等)。
6. 自评 + 追问;输出 JSON。
summary:`变量设计 · …`(点题主真值 + 有无映射表)。 材料足够便直接成稿;真正会改变变量合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
``` ```
## principles ## principles
```principles ```principles
1. 承重才立真值;派生性格不落第二可写格。 1. 承重才立真值:必须可查询、计算、门控或防止重复;仅为描写方便的状态不落库。
2. 映射内容:具体实例生成 → 本步索引汇总 → side_effects/变量控制上下文供给旁观与主世界层。 2. 字段不多不少且互不重叠。余额与存款只留一个;由好感推得的态度档不另设可写字段。
3. 维护靠「声明 + 程序合并」,不靠模型直接改库: 3. 更新规则以已发生事实为证据,写清条件、操作和边界。模型可以判断事实,程序负责校验与提交。
- 主路径:旁观维护 maintain.v1(读本轮正文)和/或正文隐藏段(正则/定界)→ Runtime 合并进 变量.当前 4. 类型决定操作:number/integer 可 `set|delta`;enum/string/boolean 可 `set`;set 可 `add|remove`。不用散文值绕过类型。
- 边沿:side_effects 换投影 tag 5. 公开性只区分用户可见与隐藏;更细的主世界层、转述、旁观投影交给变量控制上下文。
- 用户手改;chance 只出随机数,不替代维护语句 6. 阈值映射只保存引用和选取条件;长文内容仍在生成规则/具体实例中。
4. 维护语句必须可生成、可校验:固定键名、短值、禁止散文糊弄。 7. 「维护复杂度」只写给旁观看的难点备注(交叉依赖、叙事推断证据),不决定开不开旁观;有真值即旁观填表。
5. 旁观要看的是「当前档摘要」,不是整份实例库;汇总句或投影 tag 即可。 8. 不在正文中设计隐藏标签、正则或维护 JSON;主世界不承担隐藏填表通道。
6. 宁少勿多;与监控栏字段可交集但职责不同(监控=用户看见;真值=程序真相)。
``` ```
## probe ## probe
```probe ```probe
每轮 1~2 点:主真值;连续数字还是阶段枚举;维护走旁观 maintain / 隐藏段 / 两者;映射表是否已有具体实例可索引;旁观要不要当前档摘要。 每轮 1~2 点。优先确认不保存会出什么错、变量类型与初值、哪些已成立事实允许更新,以及更新是程序可确定还是需要正文推断。
``` ```
## output ## output
@@ -91,136 +68,138 @@ summary:`变量设计 · …`(点题主真值 + 有无映射表)。
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "变量设计与更新规则", "技能": "变量设计与更新规则",
"brief": "一句话:主真值 + 映射/维护方式", "brief": "一句话说明必须记住哪些真值,以及更新判断的复杂度",
"mount": ["world-simulator"], "mount": ["world-simulator", "auditor"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据的体验": ["从机制/美学提取的状态化点"],
"真值": [ "真值": [
{ {
"名": "好感", "id": "稳定安全 id",
"类型": "number|enum|boolean|string|set", "名称": "用户可读名称",
"初值": 0, "用途": "不保存它会造成什么跨轮错误",
"对用户可见": true, "类型": "number|integer|enum|string|boolean|set",
"对主世界层可见": true, "初始化": {
"写入源": ["llm", "用户"], "默认值": 0,
"更新": { "开场确定规则": "开场可覆盖默认值时写条件;否则写固定"
"时机": "…",
"方式": "delta|绝对值|枚举切换",
"钳制": "0..100|未定",
"执行单元": "world-simulator"
}, },
"备注": "" "公开性": "public|hidden",
"允许写入者": ["从 program|gm_tool|auditor|user 中选择实际允许项"],
"允许操作": ["按类型从 set|delta|add|remove 中选择"],
"约束": {
"最小值": 0,
"最大值": 100,
"枚举值": [],
"其它不变量": []
},
"更新规则": [
{
"当": "什么已发生事实允许更新",
"判断方式": "deterministic|narrative_inference",
"证据要求": "程序输入或正文中必须能指出什么",
"操作": "set|delta|add|remove",
"值规则": "固定值、公式或允许的推断范围",
"时机": "pre_narrative|post_narrative"
}
]
} }
], ],
"Data映射索引": [ "派生映射": [
{ {
"映射id": "affinity-personality", "id": "稳定映射 id",
"用途": "好感分档性格|章大纲|场景包|事件池|…", "用途": "真值变化后选择什么只读切片",
"键依真值": "好感", "依据变量": ["变量 id"],
"键规则": "例:区间 [0,30)/[30,60)/[60,100]", "选取规则": "区间、枚举匹配或组合条件",
"结构摘要": "阈值 → 态度/行为切片字段…", "来源": {
"生成规则rule_id": "来自设计.生成规则;无则待补", "规则名": "设计.生成规则#中文规则名;无则省略",
"具体实例batch_id": "来自设计.具体实例;无则待补", "名称": "设计.具体实例#中文名;无则省略"
"旁观与投影要用的摘要键": ["档名", "态度要点", "禁触"], },
"说明": "长文在实例表;本索引只为查表与汇总" "读取字段": ["需要投影的摘要字段"]
} }
], ],
"维护语句约定": { "触发规则": [
"通道": ["旁观maintain.v1", "隐藏段", "二者都要"],
"与正文组成对齐": "隐藏段段id / 定界约定;无隐藏段则写「仅旁观维护」",
"语句形状": {
"格式": "maintain.v1 table_ops|隐藏段键值列表",
"必填键": ["key", "delta|to"],
"禁止": ["无键散文", "改未立真值", "主世界层交 settlement JSON"]
},
"生成要求": "旁观维护按本轮正文推断变更(多数轮可空);主世界层专注故事正文",
"示例": [
{ "key": "好感", "delta": 8, "reason": "送礼成功" }
],
"程序侧": "Runtime 合并 maintain.v1 → 变量.当前;再跑 side_effects;非 chance toolcall"
},
"side_effects": [
{ {
"id": "affinity-romance", "id": "稳定触发 id",
"field": "好感", "条件": {
"op": "gte", "变量": "变量 id",
"value": 60, "比较": "eq|neq|gte|lte|gt|lt|changed",
"mode": "once", "值": 0
},
"模式": "once|every_edge",
"action": { "action": {
"type": "replace_tag", "类型": "project_mapping|write_memo|queue_generation",
"tag": "上下文.角色态度", "目标": "映射 id、备忘录 id 或规则 id",
"content": "恋爱档要点或「按映射id查当前档摘要」" "要求": "触发后程序应做什么"
} }
} }
], ],
"旁观汇总意图": { "维护复杂度": {
"需要": "是|否", "备注": "交叉依赖、须结合正文推断的点;给旁观副 LLM 参考,不驱动开槽",
"汇总什么": "例:当前好感档性格要点 + 已触发 once 门", "依据": [
"建议tag": "上下文.旁观.状态摘要", "变量数量、交叉依赖、narrative_inference、触发规则与其它可变产物"
"材料来自": ["Data映射索引", "投影tag", "真值字段"] ],
}, "可合并更新": [
"不立变量的理由": ["…"] "必须原子提交的一组变量 id;无则空数组"
}, ]
"自评": { }
"维度": [ }
{
"名": "承重度",
"分数": 8,
"说明": "真值是否只覆盖必须跨轮记住的状态"
},
{
"名": "可维护",
"分数": 8,
"说明": "维护语句是否可解析;映射索引是否接得上实例/规则"
},
{
"名": "克制度",
"分数": 8,
"说明": "无派生可写格膨胀;无用 chance 冒充改表"
}
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为钉准变量与维护,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A", "选项B", "其它(请写明)"],
"示例": "可选"
}
]
},
"开放问题": []
} }
``` ```
硬规则: 硬规则:
1. context-fragment.v1 外壳;自评承重度/可维护/克制度。 1. 输出合法 JSON;每个真值必须有稳定 `id`、用途、类型、初始化、权限、操作、约束和更新规则。
2. Data 映射索引可指向尚未生成的实例(标待补),但键与用途必须清。 2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。
3. 维护语句约定必填;须声明通道与示例。 3. `narrative_inference` 必须写证据要求;正文没有该证据时不更新。
4. side_effects 可 `[]`。禁止输出完整 workers[]。 4. 派生映射与触发规则可为空;存在时必须引用已声明变量,且派生结果不得成为第二个可写真值。
5. summary:`变量设计 · {brief 缩略}`。 5. `维护复杂度` 可选;有则写备注与依据给旁观,勿再写 `建议: inline_*` 指望关旁观。有真值时程序固定 `play_slots.auditor=true`。勿手写 `play_slots`。
6. 不输出隐藏段、正则、`maintain.v1/v2`、tool schema、Worker 提示或界面字段。
7. 产物正文不含自评、追问或开放问题。
8. `mount` 固定为 `["world-simulator", "auditor"]`。
## score
```score
真值必要性:
- 5:每个字段不保存都会造成明确跨轮错误,没有临时描写或可派生冗余。
- 3:主体必要,但夹有一个可从其它字段或最近正文推出的状态。
- 1:把人物感受、场景描述或大段设定全部变量化。
字段妥当性:
- 5:类型、初值、操作和约束彼此一致,字段数量恰当且不重叠。
- 3:基本可用,但有边界、枚举或初始化要求不够明确。
- 1:类型与操作冲突,或存在余额/存款一类重复真值。
更新可执行性:
- 5:每条规则都有已发生事实、判断方式、证据、操作、值规则和时机。
- 3:更新方向明确,但证据或值范围仍需猜测。
- 1:只说“随剧情变化”,无法生成或校验更新。
权限与公开性:
- 5:写入者与公开性符合用途,隐藏真值不会因显示或用户误改而泄露、漂移。
- 3:大体合理,但有一项写入者或公开边界含混。
- 1:任何执行单元都能任意改,或秘密状态被默认公开。
映射与触发一致性:
- 5:映射、阈值触发和原子更新都引用有效变量,且没有建立重复可写真值。
- 3:主体能接通,但有来源引用或边沿语义不清。
- 1:派生结果反向覆盖真值,或触发引用不存在字段。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 真值有写入源与更新时机? - [ ] 每个真值都通过“不保存会出错”的必要性检验?
- [ ] 映射索引指向规则/实例或标待补?旁观摘要键清楚? - [ ] 类型、初值、允许操作、约束和写入者互相一致?
- [ ] 维护语句可解析且与正文组成/旁观维护对齐? - [ ] 每条更新规则都有事实证据和可执行的值规则?
- [ ] 未把分档长文做成可写真值?未用 chance 当改表? - [ ] 派生映射未被重复建成可写真值?
- [ ] 维护复杂度若写了,是旁观备注而非开槽开关?
- [ ] 未写隐藏标签、正则、维护包或界面格式?
``` ```
## examples ## examples
```examples ```examples
好: - 单个数值真值或关系矩阵:都写清更新规则;有真值即由旁观副 LLM 填表,主世界只写正文。
- 好感真值 + 映射索引指向 concrete batch「分档性格」+ side_effect 投影态度 + 维护语句走 variable_changes。 - 多个真值需结合正文共同判断:在维护复杂度.备注里点出推断难点与证据,供旁观读。
- 旁观汇总意图:只挂当前档「态度要点」,不挂整表。 - 当前态度由好感区间映射得到:只保存好感,态度作为派生映射,不建立第二个可写“当前态度”。
- 不合适:每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作。
坏:
- 每轮让模型重写「当前性格」长字符串当真值。
- 只写「好感会变」无维护语句形状。
- 用 run_worker chance 来改好感。
``` ```
@@ -1,4 +1,4 @@
# 游玩拓扑 # 游玩执行方式
> 能力文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 能力文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> **禁止自由发明执行单元。** 只从固定槽勾选;程序按 `play_slots` 展开 workers。 > **禁止自由发明执行单元。** 只从固定槽勾选;程序按 `play_slots` 展开 workers。
@@ -8,27 +8,18 @@
## meta ## meta
```meta ```meta
name: 游玩拓扑 name: 游玩执行方式
id: worker-spec id: worker-spec
artifact: 设计.worker规格 artifact: 设计.worker规格
declaration: > declaration: >
不能吃默认槽时才勾选:主世界层必开;转述默认关(要独立文风层才开);有变量/表维护才开旁观;强秘密才开视角;写手则大纲+章节;禁止发明执行单元与机遇槽 程序根据前面已经确认的写作、维护和秘密隔离需求,整理本局需要启用的固定执行单元
when: | when: |
体验契约已大致清楚,且不能吃默认槽:要开转述、开旁观、开角色视角(强秘密不能进主世界层),或走写手大纲+章节。 落档或开玩时自动执行;从叙事、信息隔离、写作路径推导固定槽位;有变量真值时程序固定开旁观填表。
或要修订已勾选槽(开/关 auditor、perspective、narrator 等)。
when_not: | when_not: |
体验/站位仍混沌 → 先「美学纲领与交互范式」。 前置语义尚未确定时沿用安全默认值,不向用户重复询问。
世界模拟可吃默认(仅主世界层;转述/旁观/视角关)→ 可不排,「细化终稿」按默认收成。
只需输出完整运行规格 → 「细化终稿」(本步只交槽位勾选)。
检定/掷骰范围汇总 → 「随机范围整理」;不要为本步加「机遇裁定」槽。
不要用本步发明世界观/性格/变量专用执行单元。
boundary: | boundary: |
本能力:输出 play_slots(及写手路径的 writing_slots),可选覆盖挂载说明;不写完整 设计.worker集。 本程序只选择白名单中的固定执行单元,不创作内容、不发明专用 Worker。
主世界层(gm)几乎总要。转述默认关:主世界层直接交用户可见原文。旁观维护:有变量/表/规则要程序化盯时才开。 随机数由程序和变量表维护,不建立机遇裁定 LLM。
不再设机遇裁定(chance)槽:真随机走「随机范围整理」+ 程序插入上下文备用数。
细化终稿:按本步勾选展开 workers、合并常驻与 tables。
变量设计 / 变量控制上下文:真值与投影,不是推理槽。
叙事指南:默认挂主世界层;若开了转述可挂转述。故事推进挂主世界层(有转述可兼挂)。工序编排二者合为「叙事指南与故事推进」。世界/机制:挂到主世界层——本步只点名槽。
``` ```
## opening ## opening
@@ -66,7 +57,8 @@ boundary: |
2. 默认世界模拟:`gm: true, narrator: false, auditor: false, perspective: false`; 2. 默认世界模拟:`gm: true, narrator: false, auditor: false, perspective: false`;
调度序 gm → perspective? → narrator? → auditor?; 调度序 gm → perspective? → narrator? → auditor?;
主世界层必开(用户明确只要旁观/写手路径除外); 主世界层必开(用户明确只要旁观/写手路径除外);
要独立文风改写才 narrator true;有变量设计/表维护需求 → auditor true; 要独立文风改写才 narrator true;
**旁观填表由程序固定**:有「设计.变量设计与更新规则」真值时落档会开 `auditor=true`,本步不必猜;手写 play_slots 时旁观与之对齐即可。
仅信息隔离才开 perspective。 仅信息隔离才开 perspective。
3. 写手路径:`outline` + `chapter-writer` 默认都开;用户明确只要正文则可关 outline。 3. 写手路径:`outline` + `chapter-writer` 默认都开;用户明确只要正文则可关 outline。
4. 可写简短 `mount_notes`(哪类上游产物挂哪槽),不粘贴长文。 4. 可写简短 `mount_notes`(哪类上游产物挂哪槽),不粘贴长文。
@@ -79,8 +71,8 @@ boundary: |
```principles ```principles
1. 只勾选,不发明:ref 必须在白名单内;无机遇裁定槽。 1. 只勾选,不发明:ref 必须在白名单内;无机遇裁定槽。
2. 默认:世界模拟 = 仅主世界层;转述/旁观/perspective 默认关。主世界层必要。 2. 默认:世界模拟 = 仅主世界层;转述/perspective 默认关。主世界层必要。
3. 要独立文风层再开转述;有变量/表/规则要盯再开旁观。 3. 要独立文风层再开转述。表维护固定旁观副 LLM:有变量真值即 auditor true,不再按 inline 建议关旁观。
4. 变量 / Data / Progressive 不是执行单元;旁观维护只出 maintain.v1,不写真相。 4. 变量 / Data / Progressive 不是执行单元;旁观维护只出 maintain.v1,不写真相。
5. 删掉检验仍适用:关某个槽要说得清损失什么。 5. 删掉检验仍适用:关某个槽要说得清损失什么。
6. 本步不输出完整 Worker 集、不写 tables 全文、不写随机范围表(交给细化终稿 / 变量设计 / 随机范围整理)。 6. 本步不输出完整 Worker 集、不写 tables 全文、不写随机范围表(交给细化终稿 / 变量设计 / 随机范围整理)。
@@ -94,10 +86,9 @@ boundary: |
- 是否必须独立转述改写正文?(要才 narrator=true;默认不要,主世界层原文即终稿) - 是否必须独立转述改写正文?(要才 narrator=true;默认不要,主世界层原文即终稿)
- 是否有「主世界层不该知道的角色秘密」?(有才 perspective=true) - 是否有「主世界层不该知道的角色秘密」?(有才 perspective=true)
- 有变量/表要盯却关了旁观?或无状态却硬开旁观?
- 扩写:要不要先验大纲再写章? - 扩写:要不要先验大纲再写章?
不要问「还想加什么 Worker」;不要问要不要机遇裁定。 不要问「还想加什么 Worker」;不要问要不要机遇裁定;旁观开否由程序按有无变量真值固定。
``` ```
## output ## output
@@ -132,7 +123,7 @@ boundary: |
- `path=writing` 时必须有 `writing_slots`;`play_slots` 可省略。 - `path=writing` 时必须有 `writing_slots`;`play_slots` 可省略。
- `play_slots.gm` 世界模拟路径下应为 true。 - `play_slots.gm` 世界模拟路径下应为 true。
- `narrator` 缺省按 false(主世界层兼呈现);仅用户明确要独立文风层时 true。 - `narrator` 缺省按 false(主世界层兼呈现);仅用户明确要独立文风层时 true。
- `auditor` 缺省按 false;有变量/表维护意图才 true。 - `auditor` 缺省按 false;有变量真值时落档程序固定为 true(旁观副 LLM 填表)。对话落盘不必本步猜。
- 不要输出 `chance` 字段;旧产物若有 `chance: true`,本步改为 false/删除,并指向「随机范围整理」。 - 不要输出 `chance` 字段;旧产物若有 `chance: true`,本步改为 false/删除,并指向「随机范围整理」。
- 不要输出自造 `actors[]` / 自由 `workers[]`。 - 不要输出自造 `actors[]` / 自由 `workers[]`。
- 旧产物若含 `actors[]`:本步应改写为槽位勾选,不再追加自定义 ref。 - 旧产物若含 `actors[]`:本步应改写为槽位勾选,不再追加自定义 ref。
@@ -141,8 +132,8 @@ boundary: |
```checklist ```checklist
- [ ] 是否只有白名单槽,无自造 ref、无机遇裁定? - [ ] 是否只有白名单槽,无自造 ref、无机遇裁定?
- [ ] 主世界层是否开启(世界模拟路径)?旁观是否与变量需求一致? - [ ] 主世界层是否开启(世界模拟路径)?有变量真值时旁观是否按程序约定开启?
- [ ] auditor / perspective / 只要正文等非常规选择是否有理由? - [ ] perspective / 只要正文等非常规选择是否有理由?
- [ ] 是否误把变量管理做成槽?是否误交完整 设计.worker集? - [ ] 是否误把变量管理做成槽?是否误交完整 设计.worker集?
``` ```
@@ -1,8 +1,5 @@
# 舞台骨架 # 舞台骨架
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`;范例:`aesthetics-interaction` / `mechanism`。
## meta ## meta
```meta ```meta
@@ -10,137 +7,42 @@ name: 舞台骨架
id: world-blueprint id: world-blueprint
artifact: 设计.舞台骨架 artifact: 设计.舞台骨架
declaration: > declaration: >
本局舞台备案:划出玩家实际游玩会换区活动的主要区域并写细;尺度与详细度随体验变。代入后几乎不移动、只在一处待着则不排 保存会在游玩中反复影响世界反馈的外部舞台:熟悉基底、必要变造、核心运转和主要活动场所。
when: | when: |
已有「美学纲领与交互范式」或等价整体感觉,且玩家实际游玩会在划分的活动区域上发生(会换区、会移动)。 核心体验依赖外部环境、社会力量或世界状况持续作出一致反馈,而现有材料还不足以支撑这些反馈时使用。
变造世界与现实世界都要排:只是详细度有所不同。 是否换区不是判断标准:跨越多地可以需要舞台骨架;一室之内的朝堂、审讯或家庭关系同样可以需要。
玩家实际游玩过程中的主要活动区域必须写细,但如果本身世界变造不多、或特殊设定较少(如西幻就足以概括的世界),就不需要多写。
when_not: | when_not: |
整体感觉未钉 → 先「美学纲领与交互范式」。 熟悉现实基底已经够用,地点和外部格局不会改变核心体验或世界反馈时不使用。 既成硬法则归实现机制;多份人物或要先审字段的名片归生成规则,单条字段很少的具名内容才归具体实例;重点在节点与连接本身时归拓扑图谱。
既少换区、社会格局与世界状况也不需要备案才能撑体验(如网聊、门卫室)→ 不排。
虽少换区,但台上势力/运转状况仍决定体验(如一室朝堂博弈)→ 仍排;活动区可极简成一处。
可点名名片 → 「具体实例」另排。对象族怎么生成 → 「生成规则」。
boundary: | boundary: |
本技能:舞台备案——范围、基底+变造、主要活动区域(写细)、社会结构、世界状况。 本能力只交会上台并反复有用的外部事实,其余沿用熟悉常识或明确留白。
多张名片/同批实例共享的台上格局与世界状况写在这里,供下游引用。 不重写核心体验,不解释硬法则起源,不制作人物名片、完整地图、历史或百科。
美学纲领与交互范式:体验与轮转——本步不重定。
实现机制:既成变造法则——本步把法则落到活动区域上长什么样,不重钉法则来源。
具体实例 / 生成规则 / 叙事指南:名片、生成合同(schema/池/描写法)、遣词——本步不定。故事推进(工序编排并入「叙事指南与故事推进」)也不定。
feeds: gm feeds: gm
``` ```
## opening ## opening
```opening ```opening
先框住「玩家实际会待着玩的地方」(想到什么写什么): 说说这局里会反复影响你的外部舞台:它以什么熟悉世界为基底,哪里不同,哪些场所、关系或世界状况会持续参与反馈。只写真正会上台的部分即可。
1. 舞台有多大?熟悉基底是什么?变在哪里?
例:一座城 / 一所学校几个系 / 「魔族与人族对峙的前线」(可抽象,不必真地图)。
2. 社会结构与世界状况里,什么在撑故事?
例:人类与丧尸如何相处;每年寒灾;南极通往怪物世界的出口;村中活人祭祀……
只点会影响核心运转、会反复碰到的。
3. 玩家实际游玩的主要活动区域是哪几块?
这些区域要写细——不是点名就走。舞台外可以留黑。
``` ```
## task ## task
```task ```task
你正在执行「舞台骨架」。产物必须是 **context-fragment.v1** JSON,写入「设计.舞台骨架」。 把用户材料整理为一份可供主世界层稳定反馈的舞台骨架,写入 `设计.舞台骨架`。先确定最短的熟悉基底与必要变造,再提取会反复影响玩家的社会关系、环境条件和主要场所;体验碰不到的范围明确留白。
本步是舞台**备案**:变造世界与现实世界都有划分的舞台。钉的是玩家实际游玩过程中的**主要活动区域**——这些区域要写细。范围、基底与变造、社会结构、世界状况沿用现有字段。 材料足够便直接成稿。若两种舞台尺度或运转条件会导致明显不同的世界反馈,把这一处缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
尺度由本局体验决定。同一「现代都市」基底下:
- 「体验作为韩国顶级财阀」→ 舞台往往是地球级势力、国家与资本网络,活动区写到财团/宴会/听证层;
- 「体验普通大学生活」→ 舞台可能只是一座城或一所学校,活动区写到系馆与社团层。详细度不同,不是跳过本步。
核心操作:在已确认体验(及可选的既成法则)之上做备案;玩家主要活动区域写细;舞台外留黑。
执行顺序:
1. 读依赖与用户表述,忠实继承体验与既成法则;不重做美学/机制。
2. 判定舞台尺度:以体验会碰到的边界为准。
3. 选定熟悉基底,写清变点。
4. 钉「社会结构」:台上有哪些种类/势力/阶层在互动(种类级与格局级,非名片)。
5. 钉「世界状况」:灾变、通道、祭祀、资源命脉、禁忌等——须能回答如何影响核心运转;软性日常条件也归此。
6. 玩家主要活动区域写细;舞台外「刻意留黑」。
7. 类型滤镜仅作体裁/氛围参照;禁止题材套件勾选。
8. 填齐正文 + 自评 + 追问,输出 JSON。
工作姿态——「备案活动区,不写百科」:问玩家会反复待在哪、碰到什么运转部件;舞台外不补全图。
若程序已发默认问题:禁止重复开场;在首答与依赖上补洞。
summary:`舞台骨架 · …`(点题尺度 / 社会结构或世界状况)。
``` ```
## principles ## principles
```principles ```principles
1. 舞台服务于体验 1. 舞台以世界怎样回应玩家为准,不以是否换区、地图大小或题材类型为准。
尺度、格局、社会结构、世界状况都必须能回答:它让哪段已确认体验得以发生或被感觉到? 2. 尺度由核心体验会碰到的边界决定。同是“现代都市”基底:体验作为韩国顶级财阀,尺度升到地球级势力、国家与资本网络,主要舞台写到财团、宴会、听证层;体验普通大学生活,尺度是一座城或一所学校,主要舞台写到系馆与社团层。按体验正推,写“需要跨国资本压迫感 → 升到国家层”,不写“因为是校园文所以不要国际政治”。
不能从题材标签反推「这类故事通常有完整大陆/完整国别」。 3. 熟悉常识默认继承,只写相对基底的必要变造,以及会反复参与体验的外部事实。
4. 核心运转可以是社会力量、阶层关系、周期事件、资源条件、禁忌或日常秩序;只说明它如何作用于玩家和主要舞台。用户已点名的灾变、祭祀、异界出口等世界状况必须写进核心运转。
2. 骨架 ≠ 地图 ≠ 百科;社会与状况同等重要 5. 主要舞台可以是空间、机构、社会层或一间房;写到能稳定反馈为止:玩家在这里做什么、会碰到谁或什么、得到什么反馈,不只点名。
地理只是一类钩子。社会结构(谁在台上、如何分层互动)与世界状况(寒灾、异界出口、活人祭祀、命脉资源等) 6. 既成硬法则只引用其结果,规则本身交给实现机制;多份或要先审字段的具名人物交给生成规则,单条字段很少的具名内容才交给具体实例。多张名片共享的台上格局与世界状况写在本能力,生成规则只引用,不在规则里重抄。
往往比「哪条河接哪座山」更能决定故事如何运转——必须写,但不能写成物种志/民俗全书。 7. 用户未触及的地区、历史、物种、民俗和势力不补全,在留白边界中说明即可。类型(西幻、修仙、末日等)只作体裁与氛围参照,不是必选题单:不因题材自动塞入该类型常见的地理与势力;不为显得严谨补起源史、伪科学或神话谱系,除非它们本身会上台。
可以是抽象格局(对峙、割据、阶层天井)。不要求接壤比例尺、全史年表。
下游要生成多张名片时:共享的台上格局与状况仍写在本步;生成规则只引用,不在规则里重抄世界书。
下游需要点名条目时交给「具体实例」;本步给格局级与状况级引用钩子。
3. 变造基底,再按活动区写细
优先路径:大家都有印象的基底世界 → 点明变在哪里 → 玩家主要活动区域写细,并写清会撑故事的社会/状况条目。
详细度跟体验走:财阀博弈可写到国家/财团层;校园日常写到系馆与社团层。这是备案详细度,不是跳过本步。
4. 类型滤镜是叠加在基底上的体裁/氛围参照,不是必选题单
可用于澄清「故事以何种体裁被讲述」,从而影响冲突模式与背景气质。
禁止:列出大菜单请用户勾选;禁止因选了滤镜就自动塞满该类型常见地理与势力。
5. 与实现机制的分工
机制已钉既成变造法则时:本步把法则落到主要活动区域上长什么样,不重钉「为何存在」。
机制未排入时:仍从体验直接做舞台备案。
6. 活动区写细,舞台外留黑
玩家主要活动区域写细即可停止。舞台外、体验碰不到的部分默认不写。
「未展开范围」写明刻意省略,避免下游把留黑当成缺口去补百科。
7. 正推,禁止否定式路由
写「需要跨国资本压迫感 → 舞台升到国家/财团层」,
不写「因为是校园文所以不要国际政治」(除非用户明确不要)。
8. 继承优先于发明
基底已蕴含的常识默认成立;只在变点与体验依赖处显式改写。
不要为显得严谨而补起源史、伪科学或全套神话谱系,除非它们本身会上台。
```
## probe
```probe
只追问会改变尺度、变造、社会结构、世界状况或台上格局的缺口。每轮 1~2 点。已答清的禁止重问。
【默认问题已覆盖】尺度、基底+变点、社会结构/世界状况、玩家主要活动区域。首答后按缺口补。
优先方式:
1. 尺度对照
例:「同是现代都市——更接近『全国财阀与国家势力都在台上』,还是『基本不出这座城/这所学校』?」
2. 变造落句
例:「是否可以写成:现代都市基底,但国家边界弱化到几乎无国别,冲突主要在公司与城市场景里发生?」
3. 社会与状况核对
例:「台上互动方是否就是『人类 + 无攻击欲的丧尸』?还有没有别的智能方会影响你的掌控?」
例:「『每年寒灾』是全局运转的硬时钟,还是只在边陲传说?祭祀是真要死人,还是可被你改写的习俗?」
4. 主要活动区域
例:「真正会反复待着玩的,是总部—宴会—监管听证,还是还要经常切到海外子公司现场?这些区要写细。」
5. 滤镜澄清 / 机制落地(可选)
体裁气质会改变冲突模式时才问滤镜;机制已点世界侧切面时,问它在台上长什么样,不重做支撑检验。
才追问:两种尺度会显著改变后续;变点/社会/状况不清会改核心运转;主要活动区域范围决定要否某类势力;类型标签分不清氛围与硬设定。
不追问:具体名片(→ 具体实例);完整物种志/民俗志/世界史/全地图;生成/变量/文风;用户已拒绝展开的背景;基底可默认继承的常识。
``` ```
## output ## output
@@ -149,129 +51,77 @@ summary:`舞台骨架 · …`(点题尺度 / 社会结构或世界状况)
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "舞台骨架", "技能": "舞台骨架",
"brief": "一句话:舞台多大 + 社会/状况如何撑故事", "brief": "一句话说明什么外部舞台在支撑核心体验",
"mount": ["world-simulator"], "mount": ["world-simulator"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
"依据的体验": [ "舞台基底": {
"从依赖产物忠实提取;可附带与舞台相关的机制支点名" "熟悉基底": "现实或常见类型中可直接继承的最短描述",
], "尺度": "体验会碰到的边界,如国家与资本网络、一座城、一所学校、一间殿;熟悉基底已说明时省略",
"舞台尺度": { "必要变造": ["只列会改变世界反馈的差异;无则省略"]
"范围": "地球级 / 国家 / 城市 / 机构内部 / 抽象割据带 / …",
"为何如此": "与核心体验的关系",
"玩家常活动边界": "反复碰到的空间或社会边界(可抽象)"
}, },
"基底与变造": { "核心运转": [
"基底": "大家都有印象的原型(现代都市、西方高奇幻常见格局…)",
"变点": ["相对基底改了什么;每条宜短、可引用"],
"类型滤镜": "可选体裁/氛围参照名;无则空字符串",
"默认可继承": "基底下可默认成立、本步不写的常识范围(一句话)"
},
"社会结构": [
{ {
"名称": "种类/势力/阶层/阵营等格局级称呼——不是具体名片", "要素": "社会力量、关系、环境条件或世界状况",
"性质": "智慧生灵种类|国家/财团|院系|帮派|阵营|阶层|其它", "如何作用": "它怎样持续影响玩家、选择或后果",
"在舞台上的位置": "玩家如何碰到;相对权力/危险/依赖/互动方式", "作用范围": "涉及哪些主要舞台;无需区分时省略"
"服务体验": "它让什么感觉或规则成立",
"细化程度": "点到为止|需要下游实例化|本步已够用"
} }
], ],
"世界状况": [ "主要舞台": [
{ {
"名称": "影响核心运转或台上氛围的状况条目", "名称": "会反复上台的空间、机构或社会层",
"性质": "周期性灾变|异界/通道|祭祀习俗|资源命脉|禁忌律法|日常节奏|话语禁忌|其它", "玩家如何接触": "玩家在这里做什么、会碰到谁或什么、受到什么反馈;写到能稳定反馈的具体程度,不只点名",
"是什么": "最短可引用描述(例:每年寒灾;南极通往怪物世界的出口;村中活人祭祀)", "关键运转": ["仅引用与此处直接相关的核心运转"]
"如何影响运转": "它怎样推动、限制或框住故事与体验;软条件也可写「塑造氛围/日常感」",
"节奏或触发": "年周期 / 可往返 / 日常默认…;不明则「未定」",
"作用范围": "全局|仅某舞台区",
"服务体验": "对应哪段体验或机制支点"
} }
], ],
"关键舞台区": [ "留白边界": ["本局不会上台、只继承常识或刻意不展开的范围"]
{ }
"名称": "会反复上台的区/层/格局名",
"是什么": "空间、社会层或抽象舞台的最短描述",
"为何需要": "服务哪段体验或哪个机制支点",
"格局要点": ["与社会结构/世界状况如何交汇——只写台上用得上的"]
}
],
"未展开范围": [
"刻意留黑或仅继承常识;禁止下游当缺口补百科"
],
"覆盖检验": {
"关键骨架是否够用": "是|否|部分",
"说明": "玩家会反复碰到的社会与状况是否已有钩子",
"宁少勿多说明": "为何到此停止"
}
},
"自评": {
"维度": [
{
"名": "覆盖度",
"分数": 8,
"说明": "能否覆盖用户已表达的舞台/社会/状况/变造硬要求"
},
{
"名": "充分度",
"分数": 8,
"说明": "骨架是否够下游引用,并撑起用户想要的体验发生与运转"
},
{
"名": "克制度",
"分数": 8,
"说明": "是否只写会上台部分;无地图百科、物种志、民俗全书膨胀"
}
],
"薄弱点": "一句话"
},
"追问": {
"导语": "为钉准舞台骨架,还需确认:",
"题目": [
{
"问": "…",
"建议选项": ["选项A(可带短场景)", "选项B", "其它(请写明)"],
"示例": "可选示范句"
}
]
},
"开放问题": []
} }
``` ```
硬规则(公共三段 + 本技正文): 硬规则:
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 输出合法 JSON;未知或无内容的可选字段直接省略,不写“未定”占位。
2. **公共三段**:正文=舞台主体;自评=覆盖度/充分度/克制度(**分数 0–10 十分制**);追问=导语+建议选项。 2. `核心运转` 统一容纳必要的社会结构与世界状况,不为分类而补内容。
3. **本技正文**须含:依据体验、舞台尺度、基底与变造、**社会结构**、**世界状况**、关键舞台区、未展开范围、覆盖检验。 3. `主要舞台` 只写会反复上台且需要稳定反馈的部分;单一房间也可成为完整舞台。
4. 社会结构=谁在台上互动(种类/势力/阶层,格局级);世界状况=世界如何运转(灾变/通道/习俗等硬部件 + 日常软条件)。无则空数组,但须在覆盖检验说明「为何无需」。 4. 不在产物正文写自评、追问、开放问题、覆盖检验或设定诊断。
5. `mount` 默认主世界层;**禁止**写 `order`。`稳变` 多为 `stable`。 5. `mount` 固定为 `["world-simulator"]`。
6. 不要输出具体实例名片、物种大全、民俗全书、生成规则、变量表、执行单元列表。
7. summary:`舞台骨架 · {brief 缩略}`。 ## score
```score
支撑度:
- 5:核心体验依赖的舞台、关系和运转条件均足以支持稳定世界反馈。
- 3:方向正确,但缺少一项会反复影响体验的环境或社会条件,或其作用尚不明确。
- 1:只有题材或地点标签,无法据此判断世界如何回应玩家。
克制度:
- 5:只写会上台且反复有用的内容,熟悉常识默认继承,其余明确留白。
- 3:夹带少量暂时不会影响游玩的地图、历史或设定。
- 1:写成完整世界百科,或因题材自动塞满该类型常见地理与势力,核心舞台反而不突出。
```
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 舞台内容足以让主世界层稳定回应玩家?
- [ ] 自评为覆盖度 / 充分度 / 克制度? - [ ] 判断依据是外部反馈,而不是换区数量或地图大小?
- [ ] 尺度由体验决定?基底+变造可引用? - [ ] 核心运转只含会反复作用的关系与状况?
- [ ] 社会结构与世界状况已写(或空数组且说明无需)?硬状况能说清如何影响运转? - [ ] 主要舞台详细度够用即止,未触及范围已留白?
- [ ] 玩家主要活动区域已写细,且有未展开范围? - [ ] 未重写体验、硬法则、人物名片、完整历史或百科?
- [ ] 未写成地图百科、物种志、民俗全书、类型勾选菜单、具体名片? - [ ] 尺度由体验正推?多张名片共享的格局已写在本步?
- [ ] 追问只打尺度/社会/状况/变造缺口,带建议选项? - [ ] 产物正文不含评分、诊断、追问和开放问题?
``` ```
## examples ## examples
```examples ```examples
好: 好:
- 体验「普通大学生活」→ 仍要备案;舞台=校园几系(活动区写细);世界状况可轻;未展开=国家政治。 - 体验普通大学生活:熟悉基底为现代都市,尺度是一所学校;主要舞台写到系馆、宿舍、社团,各写玩家在那里做什么、受到什么反馈;国家政治列入留白边界。
- 体验「垄断粮食的末日支配」→ 社会结构=人类 + 无攻击欲丧尸 + 楼内依赖关系;世界状况=外出采集命脉;舞台区=楼内安全区 / 楼外采集区。 - 核心运转写世界状况:“每年寒灾”“南极通往怪物世界的出口”“村中活人祭祀”各写如何作用,如寒灾按年逼近、决定储粮与迁徙的代价;祭祀每年要交出活人,决定村里谁有话语权。
- 世界状况示例:每年寒灾;南极通往怪物世界的出口;村中活人祭祀。 - 一室朝堂:主要舞台只有一座殿,核心运转是皇权、派系与言官的相互牵制,如何作用写清每次奏对会被谁记账、牵动哪一方。
- 抽象:「魔族与人族对峙割据」——社会结构两种阵营;不画大陆全图。
坏: 坏:
- 只写山脉河流,不写祭祀/灾变/异界口等用户已点名的运转部件。 - 只写山脉河流,不写用户已点名的祭祀、灾变、异界出口:世界怎样回应玩家仍无从判断。
- 「先写完整七大国、五十种魔族生态、三万年神话…」→ 百科。 - “地点1:XX咖啡馆,店主叫…”:这是具体实例的活;本能力只写这类场所在舞台上怎样作用于玩家。
- 「地点1:XX咖啡馆,店主叫…」→ 具体实例,不是骨架。 - 把多张名片共享的台上格局丢给生成规则写“共通前提”:格局写在本能力,生成规则只引用。
- 把多张名片共享的台上格局丢给生成规则去写「共通前提」→ 本步该写格局钩子,规则只引用。
- 整份只有散文无三段外壳。
``` ```
@@ -0,0 +1,185 @@
# 0 层开场白与初态
## meta
```meta
name: 0层开场白与初态
id: zero-layer-opening-setup
artifact: 设计.0层开场白与初态
declaration: >
0 层创作终节点,且可增殖:每条开场是一个节点,落成 `设计.0层开场白与初态#场景短码`。
每条自带持久卡第一屏、槽位初值和同真相运行初态。默认 1 条;还要入口就再增殖。
游玩时左右切换,选中的一条作为 0 层。
when: |
0层回复呈现、0层正文组成和其它内容能力均已完成,程序也已组装运行配置,需要创建持久卡第一屏时使用。
when_not: |
本局使用普通消息楼层 → 开场白与开场变量。
槽位或持久外壳未定 → 先 0层回复呈现、0层正文组成。
`设计.worker集` 尚未由程序组装 → 先运行 prepare_opening 收口工具链。
boundary: |
本能力只写候选第一屏、卡槽初值与同真相变量/拓扑初态;不重写外壳和更新合同。
开场不依赖随机数,不填写任何随机数行的具体值。
feeds: gm,narrator
```
## opening
```opening
用持久卡的第一屏建立第一拍:玩家进场时在哪、哪些槽位一开始有内容、结束后显然能做什么。默认一条。还要别的入口时,每条再增殖一个开场节点;进游玩后左右切换,选中的一条作为 0 层。
```
## task
```task
读取程序已组装的 `设计.worker集`、`设计.0层回复呈现`、`设计.0层正文组成` 和其它最终层产物,写入 `设计.0层开场白与初态`。
默认生成 1 条。用户要多个入口时,每条再增殖一份产物,写入 `设计.0层开场白与初态#场景短码`;改某一条则覆盖同一完整 tag。不要把多条入口收成聊天里的选择题。每条候选必须携带一套不可混用的初态:
- 稳定候选 id 与一句区分。
- 可选用户角色名片。
- `zero-layer.card.v1` 初始卡:revision=0,包含所有可见槽位的初值或合同允许的空态。
- 与第一屏同真相的变量初值。
- 若有拓扑:当前位置、焦点和已揭示范围。
卡内模型生成槽位由本步填写;程序投影槽位只声明初始绑定,由程序从变量和拓扑初态灌入。正文槽的可见内容至少出现一次字面 `@玩家`。开场不依赖随机数,因此不输出任何 `名称.随机数` 的具体数组。
全部候选保存。游玩时左右切换,选中的一条作为 0 层:程序整套替换用户角色、卡状态、变量和拓扑初态,之后每轮只应用 `zero-layer.update.v1`,不新增可见助手楼层。
材料足够便直接成稿;会改变第一屏成立的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
## principles
```principles
1. 第一屏、卡槽、变量和拓扑初态必须是同一个事实版本。
2. 默认一条。多条是不同入口,每条一个可增殖节点,各自一整套初态,不共享半套状态。
3. 所有可见槽位都必须有初值、允许的空态或程序投影绑定。
4. 只填模型生成槽;状态、地图和表盘从权威初态投影。
5. 用户角色没有名片时仍使用 `@玩家`;有名片时也不把姓名硬写进占位位置。
6. 不写 HTML/CSS/JS,不复制固定外壳,不在开场里模拟后续 slot_updates。
7. 本步结束后创作收口;后续变化进入持久卡更新协议。
```
## probe
```probe
每轮 1~2 点。优先确认第一拍、初始活动视图、模型槽初值、某项权威状态的开场值,以及多入口时整套初态差异。
```
## output
```output
{
"schema": "context-fragment.v1",
"技能": "0层开场白与初态",
"brief": "一句话说明默认第一屏与候选数量",
"mount": ["world-simulator", "narrator"],
"稳变": "stable",
"正文": {
"默认候选id": "zero-opening-1",
"候选": [
{
"id": "zero-opening-1",
"区分": "默认;多条时说明入口差异",
"用户角色": {
"名字": "可选;无则省略整段",
"简介": "可选"
},
"初始卡": {
"schema": "zero-layer.card.v1",
"card_id": "本局稳定卡 id",
"revision": 0,
"active_view": "初始主工作面或 Tab id",
"slots": {
"body": {
"mode": "replace|append",
"content": "包含字面 @玩家 的第一拍"
},
"其它模型生成槽位": {
"mode": "与0层回复呈现一致",
"content": "初值或允许的空态"
},
"程序投影槽位": {
"mode": "data",
"projection_ref": "变量、拓扑或备忘录的稳定引用"
}
}
},
"变量初态": [
{
"名": "与变量设计真值名一致",
"值": "开场值",
"依据": "第一屏事实"
}
],
"拓扑初态": [
{
"topology_id": "有拓扑时填写",
"当前位置": "节点 id",
"焦点": "可选节点或连接 id",
"已揭示": ["节点或连接 id"]
}
],
"用户可行动空间": "第一屏结束后显然能尝试什么"
}
]
}
}
```
硬规则:
1. 输出合法 JSON;候选默认 1 条,仅用户明确要求时 2~3 条。
2. `默认候选id` 必须指向候选之一;每条都是完整独立初态。
3. 初始卡 schema 固定为 `zero-layer.card.v1`,revision 固定为 0。
4. 初始卡覆盖全部可见槽位;模型生成槽给初值或空态,程序投影槽给稳定 `projection_ref`。
5. 每条正文可见内容包含字面 `@玩家`;不写死用户角色姓名替代占位。
6. 不输出随机数具体值,不填后续 slot_updates,不复制前端代码。
7. 变量、拓扑与第一屏同真相;切换候选时整套替换。
8. 产物正文不含自评、追问或开放问题。
9. `mount` 固定为 `["world-simulator", "narrator"]`。
## score
```score
第一屏可玩:
- 5:第一拍、当前信息和行动空间清楚,短到愿意立即开玩。
- 3:可以开玩,但当前目标或下一步略含糊。
- 1:像长篇序章,或看不出玩家能做什么。
卡合同符合:
- 5:全部可见槽位都有正确初值、空态或程序投影绑定,模式与0层合同一致。
- 3:主体符合,一个辅助槽位初值含糊。
- 1:漏槽、模式冲突或直接复制整页 HTML。
状态自洽:
- 5:第一屏、变量、拓扑和程序投影引用完全同真相。
- 3:主体一致,但一项次要初态依据不足。
- 1:叙述与初态冲突或程序槽由模型编造。
持久起点:
- 5:card_id 稳定、revision=0、初始视图明确,后续可直接应用 slot_updates。
- 3:可初始化,但初始视图或某槽持久语义略含糊。
- 1:仍按普通消息设计,无法形成持久卡。
候选切换:
- 5:默认明确;多条时每条入口与整套初态差异成立。
- 3:可切换,但多条差异偏弱。
- 1:候选共享冲突状态,或未要求却堆重复入口。
```
## checklist
```checklist
- [ ] 默认一条,默认 id 有效?
- [ ] 每条卡覆盖全部可见槽位,revision=0?
- [ ] 第一屏含 @玩家,变量与拓扑初态同真相?
- [ ] 程序投影槽只写 projection_ref,未编造权威数据?
- [ ] 未写随机数、前端代码或后续 slot_updates?
```
## examples
```examples
- 终端操作师:初始活动视图为公司指令,正文写 @玩家 登入后的第一项任务;表盘和地图只给程序投影引用。
- 固定学校:正文建立第一拍,地图槽绑定学校拓扑,线索槽使用允许的空态。
- 不合适:把后续每轮更新预写进开场;输出随机数组;每个候选只换正文却共用冲突变量。
```
@@ -0,0 +1,213 @@
# 0 层正文组成
## meta
```meta
name: 0层正文组成
id: zero-layer-reply-format
artifact: 设计.0层正文组成
declaration: >
把 0 层语义槽位落成一张跨轮持久前端:定义固定外壳、槽位位置、Tab 与 slot_updates 渲染合同。
when: |
已有 0层回复呈现,需要决定持久卡如何显示及怎样原地应用槽位更新时使用。
when_not: |
本局使用普通消息楼层 → 正文组成。
还不知道卡内有哪些语义槽位 → 先 0层回复呈现。
boundary: |
0层回复呈现决定槽位内容和更新语义;本能力决定固定外壳、位置、渲染与更新应用。
权威状态由程序投影;本能力只消费数据,不让正文模型重复生成。
feeds: gm,narrator
```
## opening
```opening
把已启用槽位做成一张持久卡。先确定主工作面、Tab 或辅助区关系,再为 replace、append、data 三种更新写稳定渲染入口。
```
## task
```task
为 0 层模式生成持久前端,写入 `设计.0层正文组成`。开场只创建一次外壳;后续助手回合不新增可见气泡,而是提交 `zero-layer.update.v1` 更新现有槽位。
执行顺序:
1. 读取 0层回复呈现,只处理实际启用的可见槽位。
2. 从【壳参考库】选一只与本局已启用槽位最接近的壳。按该壳备注改写样本里的名称、栏目、配色和内部小节。本局没有的区从样本骨架删除并重排,不留空位。槽位更新方式仍以 0层回复呈现为准。
3. 为每个槽位定义 DOM 落点、显示名、渲染器和空态。Tab、折叠、缩放只做本地查看。
4. 定义 `zero-layer.card.v1` 初始状态和 `zero-layer.update.v1` 更新合同:
- `replace`:替换目标槽当前内容。
- `append`:按顺序追加记录,由程序执行上限或压缩规则。
- `data`:对程序权威数据应用结构化投影,不接收模型伪造的完整状态。
5. 隐藏维护通道不创建 DOM。模型只提交允许它生成的槽位;状态和拓扑更新由程序投影。
6. 写完整 HTML/CSS/JS,创作时一次生成并固定。每个可见槽位一个空的 `data-block` 节点,程序按契约把内容灌进去。markdown/text 槽禁止用 `textContent` 写入;本地必须写时用 `present.markdown`(若宿主提供)或等价安全渲染。根容器(如 `#zero-layer-app`)`width: 100%`,铺满宿主可用宽度,禁止整壳 `max-width` + `margin: 0 auto` 收成居中卡片;若要控阅读行长,只给正文柱设 `max-width`。不要 `100vh`,不要整页或侧栏滚动条。美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。JS 使用宿主提供的 `zeroLayer.onInit(renderCard)` 与 `zeroLayer.onPatch(applyPatch)`;不得依赖正则替换。地图 SVG 只从结构化数据绘制。
7. 给一份非正史初始卡和一次更新预览,覆盖所有可见槽位与空态。
HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变主工作面的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
## principles
```principles
1. 固定外壳只创建一次;每轮只更新目标槽位,不重发整页代码。
2. 正文或核心工作面保持最高阅读优先级,辅助 Tab 不平均争夺注意力。
3. 外壳铺满宿主可用宽度(`width: 100%`);行长只限正文柱,不限整壳;缺则按宿主全宽排。
4. 更新模式来自 0层回复呈现,前端不能擅自把 replace 改成 append。
5. 程序投影槽只消费权威数据;正文模型不输出状态副本、地图 SVG 或完整卡快照。
6. 每次更新带目标槽位和模式;程序校验允许列表与 revision 后原子提交。
7. 动态文字安全渲染;Markdown 使用宿主安全能力,不能拼接未清洗 HTML。
8. 允许本地 Tab、折叠、缩放和 SVG 图谱;禁止联网、表单、弹窗、顶层导航和宿主行动回传。
9. 正规路径使用结构化 slot_updates;正则只属于外部兼容适配器,不写进本产物。
10. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
```
## probe
```probe
每轮 1~2 点。优先确认主工作面、哪些槽位放入 Tab、正文是替换还是卡内日志,以及结构化地图需要哪种可读降级。
```
## output
```output
{
"schema": "context-fragment.v1",
"技能": "0层正文组成",
"brief": "一句话说明持久卡的主工作面、辅助导航与更新方式",
"mount": ["world-simulator", "narrator"],
"稳变": "stable",
"正文": {
"参考壳": [
{
"id": "壳参考库方法 id",
"采用": "借用的布局或局部交互方法",
"本局化": "怎样适配本局槽位"
}
],
"阅读结构": {
"主工作面": "body 或本局核心槽位",
"导航": "无|tabs|本局描述",
"响应式": "窄屏与宽屏如何保持主工作面优先"
},
"卡合同": {
"schema": "zero-layer.card.v1",
"字段": {
"card_id": "稳定卡 id",
"revision": "从 0 开始的程序 revision",
"active_view": "当前本地视图;不写入世界真相",
"slots": "以槽位 id 为键的持久状态"
}
},
"更新合同": {
"schema": "zero-layer.update.v1",
"字段": {
"base_revision": "更新所依据的 revision",
"slot_updates": [
{
"slot_id": "目标槽位 id",
"mode": "replace|append|data",
"content": "replace/append 使用;类型与槽位一致",
"data": "data 使用;由程序权威投影提供"
}
]
},
"提交": "程序校验槽位、模式、来源和 revision 后原子应用并持久化"
},
"槽位落点": [
{
"slot_id": "与0层回复呈现一致",
"位置": "DOM 区域或 Tab id",
"显示名": "需要标题时填写",
"渲染": "safe_markdown|text|kv|list|table|topology_svg",
"允许模式": "replace|append|data",
"空态": "首次无内容时显示什么",
"降级": "结构化渲染失败时的可读形式"
}
],
"不渲染": [
{
"id": "隐藏维护通道 id;无则省略",
"交给": "运行时维护解析器"
}
],
"frontend": {
"html": "<div id=\"zero-layer-app\">完整稳定外壳</div>",
"css": "完整样式;根容器 width:100%;行长只限正文柱;含响应式规则",
"js": "使用 zeroLayer.onInit(renderCard) 与 zeroLayer.onPatch(applyPatch) 的完整代码"
},
"预览": {
"initial_card": {
"schema": "zero-layer.card.v1",
"card_id": "preview",
"revision": 0,
"active_view": "main",
"slots": {}
},
"one_update": {
"schema": "zero-layer.update.v1",
"base_revision": 0,
"slot_updates": []
}
}
}
}
```
硬规则:
1. 输出合法 JSON;槽位落点覆盖 0层回复呈现的全部可见槽位,id 与更新模式一致。
2. `frontend.html/css/js` 完整且总量不超过 60 KB;JS 必须实现初始化与增量更新入口。根容器铺满宿主宽度;不要整壳 `max-width` 居中卡片。
3. 后续回合只提交 `zero-layer.update.v1`,不重发 HTML 或完整卡。
4. `data` 槽只接受程序权威投影;隐藏维护通道不进入 DOM。
5. 更新必须带 `base_revision`,由程序校验后原子提交;失败时保持旧卡。
6. 禁止正则作为正规更新协议;禁止网络、表单、弹窗、顶层导航和行动回传。
7. topology 读取结构化节点与连接绘制 SVG,并提供列表降级。
8. 预览是非正史验收数据,不写成开场。
9. 产物正文不含自评、追问或开放问题。
10. `mount` 固定为 `["world-simulator", "narrator"]`。
## score
```score
槽位落实:
- 5:每个可见槽位都有落点、允许模式、渲染器、空态和降级。
- 3:主体完整,但一个辅助槽位的空态或降级含糊。
- 1:漏槽或模式与语义合同冲突。
持久更新:
- 5:初始化、revision、原子 slot_updates 与失败保持旧值形成闭环。
- 3:可更新,但 revision 或失败处理有一处不清。
- 1:仍要求每轮重发整张页面或依赖文本替换。
权威绑定:
- 5:模型槽与程序投影槽边界清楚,状态和拓扑没有双写。
- 3:主要正确,但一个数据槽来源含糊。
- 1:模型每轮重编权威状态。
界面结构:
- 5:主工作面明确;外壳铺满宿主宽度;Tab 与辅助区服务本局体验,窄屏仍可用。
- 3:总体可读,但一个辅助区抢占注意力,或整壳被限宽成居中卡片。
- 1:机械地堆满 Tab,没有主次,或外壳明显窄于宿主。
实现与安全:
- 5:代码完整、安全渲染、无联网与回传,结构化区域有降级。
- 3:主体可运行,但一处安全或降级细节不完整。
- 1:只有外观描述、空代码或不安全 HTML 拼接。
```
## checklist
```checklist
- [ ] 所有可见槽位都有落点,模式与语义合同一致?
- [ ] 外壳只初始化一次,后续只应用 slot_updates?
- [ ] revision、原子提交与失败保持旧值完整?
- [ ] 权威状态没有被模型重复生成,隐藏维护区未进 DOM?
- [ ] 前端完整、安全、根容器铺满宿主宽度、无正则依赖与外部联网?
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
```
## examples
```examples
- 机械终端:正文为主工作面;公司指令、监控表盘、地图分 Tab;指令 replace、日志 append、表盘和地图 data。
- 学校舞台:正文 replace;地图 data;已知线索 append 并有上限;切 Tab 只改变本地视图。
- 不合适:让模型每轮输出完整 HTML;用正则替换 `<公司指令>`;把按钮点击直接回传为角色行动。
```
@@ -0,0 +1,189 @@
# 0 层回复呈现
## meta
```meta
name: 0层回复呈现
id: zero-layer-status
artifact: 设计.0层回复呈现
declaration: >
定义持久 0 层卡实际启用的语义槽位、更新触发、更新方式、权威来源与内容要求;不决定 HTML 和排版。
when: |
本局选择 0 层模式,开场卡会在后续回合原地更新,而不是持续增加新的可见助手楼层时使用。
when_not: |
本局使用普通消息楼层 → 回复呈现。
只讨论 HTML、Tab、布局或具体渲染 → 0层正文组成。
boundary: |
本能力决定持久卡里有什么语义槽位,以及每轮怎样更新。
0层正文组成负责固定外壳、槽位位置和渲染;变量与拓扑仍由其权威产物维护。
feeds: gm,narrator
```
## opening
```opening
这张持久卡需要保留哪些区域?逐项说明它在什么情况下更新、是替换当前内容、追加记录,还是由程序更新结构化数据。
```
## task
```task
为 0 层模式定义持久卡语义合同,写入 `设计.0层回复呈现`。只列本局实际启用的槽位;未启用的槽位直接省略。
每个可见槽位写清:
- 用途、内容类型和用户能看到什么。
- 更新触发:每轮,或可由程序判断的条件。
- 更新方式只选一种:`replace` 替换当前内容、`append` 追加有界记录、`data` 更新结构化数据。
- 来源是模型生成还是程序权威投影,并引用稳定上游。
- 没有新内容时保持原值、显示空态还是清空。
正文同样是槽位。它可以替换为本轮正文,也可以追加到卡内日志,但不能同时使用两种语义。`append` 必须给记录上限或压缩规则,避免 0 层卡无限增长。
变量、拓扑和备忘录使用 `program_projection`,正文模型不重复生成。简单变量需要内联维护时,另列用户不可见的维护通道;程序优先使用 tool call,工具不可用时使用结构化 `state_ops`。副 Worker 维护不建立主回复维护通道。
材料足够便直接成稿;真正改变槽位合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
## principles
```principles
1. 0 层是同一张卡持续更新,不是把普通回复列表藏起来。
2. 一个槽位只选一种更新方式;需要不同语义时拆成两个有明确用途的槽位。
3. `replace` 保留最新值;`append` 保留有界记录;`data` 由程序按结构化字段更新。
4. 程序已有的状态、拓扑和计算结果只投影,不让正文模型复述。
5. 槽位更新条件必须可判断;没有更新时默认保持原值,不用空内容覆盖。
6. 隐藏维护通道不是可见槽位,不进入 DOM。
7. 本能力只定义语义,不写 HTML、CSS、JS、Tab、SVG 或正则。
8. 每个可见槽位属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容。隐藏维护通道不占这些区。
```
## probe
```probe
每轮 1~2 点。优先确认哪些内容必须持久可见、正文是替换还是追加、追加内容保留多久,以及某槽位应由模型还是程序更新。
```
## output
```output
{
"schema": "context-fragment.v1",
"技能": "0层回复呈现",
"brief": "一句话说明持久卡有哪些槽位及主要更新方式",
"mount": ["world-simulator", "narrator"],
"稳变": "stable",
"正文": {
"模式": "zero_layer",
"语义槽位": [
{
"id": "body",
"名称": "正文",
"用途": "承载当前场面或卡内叙事记录",
"用户可见": true,
"内容类型": "markdown",
"区域种类": "正文",
"更新": {
"方式": "replace|append",
"触发": "every_turn",
"无新内容": "keep",
"记录上限": "仅 append 时填写条数或压缩规则"
},
"来源": {
"方式": "model_generated",
"ref": "设计.故事推进"
},
"内容要求": [
"遵守故事推进与叙事指南",
"只生成该槽位本次需要新增或替换的内容"
]
},
{
"id": "本局实际启用的其它槽位 id",
"名称": "公司指令、监控表盘、地图、关系或其它本局名称",
"用途": "该槽位持续帮助用户看到什么",
"用户可见": true,
"内容类型": "text|markdown|kv|list|table|topology",
"区域种类": "小故事|数据卡|结构|文字选项",
"更新": {
"方式": "replace|append|data",
"触发": "every_turn|conditional",
"条件": "conditional 时填写可判断条件",
"无新内容": "keep|empty_state|clear",
"记录上限": "仅 append 时填写"
},
"来源": {
"方式": "model_generated|program_projection",
"ref": "稳定上游或运行状态引用",
"字段": ["程序投影时只选择实际展示字段"]
},
"内容要求": ["正向说明这个槽位每次应提供什么"]
}
],
"隐藏维护通道": [
{
"id": "简单变量内联维护时填写;否则省略",
"内容类型": "state_ops",
"投递": "runtime_selects_tool_or_structured",
"用户可见": false
}
]
}
}
```
硬规则:
1. 输出合法 JSON;只列实际启用槽位,至少包含 `body`。
2. 每个槽位必须有稳定 id、区域种类、更新方式、触发、无新内容处理、来源和内容要求。可见槽位的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
3. `append` 必须有记录上限或压缩规则;`data` 必须来自程序权威投影。
4. 没有更新时保持原值;除非合同明确要求,不用空值清槽。
5. 隐藏维护通道用户不可见,不属于语义槽位。
6. 不写 HTML、CSS、JS、Tab、SVG、正则或槽位选择器。
7. 产物正文不含自评、追问或开放问题。
8. `mount` 固定为 `["world-simulator", "narrator"]`。
## score
```score
槽位必要性:
- 5:每个槽位都服务明确体验或运行需要,没有为了像应用而堆空 Tab。
- 3:主体必要,但有一个槽位可以合并或价值偏弱。
- 1:大量槽位没有稳定用途。
更新语义:
- 5:每个槽位的 replace、append 或 data 选择准确,触发和无更新处理完整。
- 3:主体清楚,但有一个槽位的更新方式或触发略含糊。
- 1:只说“刷新”,无法判断怎样修改持久内容。
权威来源:
- 5:模型内容与程序投影边界清楚,变量和拓扑没有被重复生成。
- 3:主要来源正确,但有一个字段存在双写风险。
- 1:权威状态由正文模型每轮重编。
长期负担:
- 5:追加槽有上限或压缩规则,未更新槽保持原值,长期运行不会无限膨胀。
- 3:整体可控,但一个追加区缺少明确上限。
- 1:所有历史都无限追加或每轮重写整张卡。
职责边界:
- 5:只定义语义槽位,把渲染、数据维护和篇幅交给正确节点。
- 3:主体正确,夹有少量布局描述。
- 1:在本步直接写前端代码或变量公式。
```
## checklist
```checklist
- [ ] 只列实际启用槽位,且包含 body?
- [ ] 每个槽位只选一种更新方式,触发与无更新处理完整?
- [ ] append 有上限,data 有权威来源?
- [ ] 隐藏维护通道未混入可见槽位?
- [ ] 未写前端、正则或重复权威数据?
```
## examples
```examples
- 终端体验:正文 replace;公司指令 replace;操作日志 append 且只保留最近若干条;监控表盘 data。
- 固定学校:正文 replace;地图 data,读取权威拓扑;线索板 append 并按章节压缩。
- 不合适:每轮输出整张地图文本并覆盖权威图;所有槽位都 append 且没有上限。
```
@@ -13,7 +13,7 @@ core: >-
process: process:
- 开始通常先做「美学纲领与交互范式」。 - 开始通常先做「美学纲领与交互范式」。
- 对照【能力 · 可选工序】的「何时用 / 何时不用」选型;调用条件以技能 meta 为准。 - 对照【能力 · 可选工序】的「何时用 / 何时不用」选型;调用条件以技能 meta 为准。
- 收成前若不能吃写手默认槽(大纲+章节),再排「游玩拓扑」勾选 →「细化终稿」并 closed。 - 收成时由程序根据已确认需求整理「游玩执行方式」,再用「运行配置组装」生成可执行规格并 closed。
- 舞台、机制、变量、监控栏等:仅当对应技能「何时用」成立时再选,勿套世界模拟全套。 - 舞台、机制、变量、监控栏等:仅当对应技能「何时用」成立时再选,勿套世界模拟全套。
principles: principles:
@@ -8,23 +8,23 @@ when: 回合互动、可扩展的世界推进、角色扮演、沉浸推演类
core: >- core: >-
先钉清用户如何参与、正文如何呈现、核心体验与禁忌(美学卡上用户会在意的部分); 先钉清用户如何参与、正文如何呈现、核心体验与禁忌(美学卡上用户会在意的部分);
再按那些承诺选出要兑现的上下文技能,并标明挂哪些固定槽(默认:只开主世界层,直接交可见原文;转述可选); 再按那些承诺选出要兑现的上下文技能,并标明挂哪些固定槽(默认:只开主世界层,直接交可见原文;转述可选);
挂载归属可早定,插入顺序必须晚定——收成前用「上下文投影排序」按各块概括排出数字序与双锚点投影; 挂载归属可早定,插入顺序由程序在落档时按变化频率与重要性自动整理;
再细化终稿收成运行规格。执行单元只用固定槽(主世界层必开 / 叙事转述默认关 / 可选角色视角;旁观按变量需要),不为表/百科再拆槽,不开机遇裁定槽。 再由「运行配置组装」生成可执行规格。执行单元只用固定槽(主世界层必开 / 叙事转述默认关 / 可选角色视角;旁观按变量需要),不为表/百科再拆槽,不开机遇裁定槽。
process: process:
- 开始通常先做「美学纲领与交互范式」。 - 开始通常先做「美学纲领与交互范式」。
- 读美学卡上用户会在意的部分,对照【能力 · 可选工序】的何时用 / 何时不用,把兑现那些承诺所需的节点写进 steps(需要几步排几步)。各技能调用条件以 meta 为准,本表不重复。 - 读美学卡上用户会在意的部分,对照【能力 · 可选工序】的何时用 / 何时不用,把兑现那些承诺所需的节点写进 steps(需要几步排几步)。各技能调用条件以 meta 为准,本表不重复。
- 世界模拟默认槽:仅主世界层(转述关、旁观关、视角关)。能吃默认则不必排「游玩拓扑」。要独立文风层再开转述;有变量/表再开旁观。 - 世界模拟默认槽:仅主世界层(转述关、旁观关、视角关)。「游玩执行方式」由程序从独立文风层、变量维护和秘密隔离需求中整理。
- 中段按承诺写上下文;每块只标挂谁(mount)与稳/变,order 留给「上下文投影排序」。挂载归属:世界/机制/生成/叙事指南 → 主世界层(无转述时叙事也挂主世界层);开了转述则叙事指南可挂转述;美学进常驻。 - 中段按承诺写上下文;每块只标挂谁(mount)与稳/变,具体顺序留给自动「上下文排序」。挂载归属:世界/机制/生成/叙事指南 → 主世界层(无转述时叙事也挂主世界层);开了转述则叙事指南可挂转述;美学进常驻。
- 「回复呈现」几乎都排:先钉每轮看什么。有检定/对抗/掷骰则排「随机范围整理」收范围表。二者都在「正文组成」之前;正文组成把清单(及隐藏备用随机区)展示出来,开场白之前。 - 「回复呈现」几乎都排:先钉每轮看什么。有检定/对抗/掷骰则排「随机范围整理」收范围表。二者都在「正文组成」之前;正文组成把清单(及隐藏备用随机区)展示出来,开场白之前。
- 收成链顺序固定:上游上下文大致齐 →「回复呈现」→(若有随机)「随机范围整理」→「正文组成」→「上下文投影排序」→「细化终稿」→ 需要开场则「开场白与开场变量」(〔收口〕)。选定开场后结束创作并保存。 - 收成链顺序固定:上游上下文大致齐 →「回复呈现」→(若有随机)「随机范围整理」→「正文组成」→ 程序自动整理执行方式、上下文并组装运行配置 → 需要开场则「开场白与开场变量」(〔收口〕)。选定开场后结束创作并保存。
principles: principles:
- 正推:美学卡上用户会在意的部分 → 缺口 → 技能。同一题材换核就换图,不按题材套世界模拟全套。 - 正推:美学卡上用户会在意的部分 → 缺口 → 技能。同一题材换核就换图,不按题材套世界模拟全套。
- 技能选型以技能 meta 为准;本配方不代替各技能写调用条件。 - 技能选型以技能 meta 为准;本配方不代替各技能写调用条件。
- 执行单元只用固定槽(主世界层 / 可选叙事转述 / 可选角色视角;旁观按需)。不开机遇裁定;真随机=随机范围整理 + 程序插备用数。 - 执行单元只用固定槽(主世界层 / 可选叙事转述 / 可选角色视角;旁观按需)。不开机遇裁定;真随机=随机范围整理 + 程序插备用数。
- 默认少槽:只开主世界层;转述默认关(主世界层原文即终稿);有变量/表开旁观;仅强信息隔离才开角色视角。 - 默认少槽:只开主世界层;转述默认关(主世界层原文即终稿);有变量/表开旁观;仅强信息隔离才开角色视角。
- 挂谁可早、排第几须晚:插入序与投影位只由「上下文投影排序」产出;中段技能写 mount 与稳/变。 - 挂谁可早、排第几须晚:插入序与投影位只由自动「上下文排序」产出;中段技能写 mount 与稳/变。
- 投影序扁平:「对话.历史」按 projection 动态裁剪;少变靠前、易变靠后是软规则,不是硬分区。 - 投影序扁平:「对话.历史」按 projection 动态裁剪;少变靠前、易变靠后是软规则,不是硬分区。
- 真值 / Data / Progressive 三分;派生性格与章细纲不落可写真值。 - 真值 / Data / Progressive 三分;派生性格与章细纲不落可写真值。
- 有编排参数的步骤:进执行前钉齐 params(askUser 选项+其它);缺参时先问再进 design-step。 - 有编排参数的步骤:进执行前钉齐 params(askUser 选项+其它);缺参时先问再进 design-step。
@@ -1,20 +1,16 @@
# 对话落盘 · 配方目录(与工序编排配方不共享) # 对话落盘 · 本局创作方案参考案例目录(不供用户手动选择)
# id = recipes/转述/{id}/ 文件夹;对用户展示用 name(中文) # id = recipes/转述/{id}/ 文件夹;本局方案可引用、混合或改写多个案例
# 选定后写入黑板 tag 创作.选用配方;对话落盘不种节点 DAG
recipes: recipes:
- id: 快穿短局 - id: 快穿短局
name: 快穿短局 name: 快穿短局
declaration: 围着一个核心设定快速代入;难扩展、不适合长期经营;至多简单数值 declaration: 用一个立刻生效的体验核形成短反馈环;需要时可混入长连续或硬机制
- id: 数据化跑团体验 - id: 数据化跑团体验
name: 数据化跑团体验 name: 数据化跑团体验
declaration: AIRP / 交互式小说;设定为主,可选跨轮真值与分档映射 declaration: 用硬判断机制与表格/数值真值,让选择、资源和后果可核对地持续生效
- id: 文本生成器 - id: 文本生成器
name: 文本生成器 name: 文本生成器
declaration: 创作收范例与模仿面;开玩后主世界出正文、转述出文风/壳 declaration: 把每次输入转成相对独立的成稿,并稳定复现范例与写法约束
- id: 交互式长文生成器 - id: 交互式长文生成器
name: 交互式长文生成器 name: 交互式长文生成器
declaration: 多轮钉需求与文风;连贯长文/章段(无数值跑团) declaration: 以长连续承接事实、人物弧线和未决承诺;可有变量,也可混入硬机制
- id: 正文组成
name: 正文组成
declaration: 钉呈现格式(壳与分区);开场白按该格式生成
@@ -1,25 +1,28 @@
# 交互式长文生成器 · 对话落盘配方 # 本局创作方案参考案例;可以与硬机制等案例混合,不以有无变量作区分。
# 原「文本生成器」长文路径;多轮互动钉规格,再生成连贯长文。
when: >- brief: 让多轮生成共享长连续,在章节与段落推进中守住事实、人物弧线和未决承诺
用户要写较长、可分段推进的正文(章段、连载向、润色扩写);
需要多轮确认需求/文风/结构;不要世界推演、不要跨轮数值状态机
core: >- signals:
把用户原话收成可生成长文的规格:需求、文风、结构与禁忌用 insert。 - 用户要章段、连载或持续多轮推进的长正文
默认无变量、无旁观、无主世界层。正文格式(设计.正文组成)与开场白按需另钉。 - 后文必须承接早先事实、伏笔、人物变化与章节目标
- 用户会在生成过程中改方向、局部重写或控制节奏
process: method:
- 听清写什么、给谁看、气质与禁忌 → insert 用户.需求 / 设计.文风 等(order 偏小)。本轮材料已够时可同轮多落;缺关键信息就问一句。 - 把长期承诺与本段目标分开:稳定保存不能漂移的事实,同时允许当前段落灵活改写
- 需要版式 → insert 设计.正文组成;要开篇样例 → 按格式 insert 设计.开场白。上游已齐时可与上列同轮一并落。 - 用少而承重的变量或动态表维护连续性;变量可以存在,但不必形成硬判定系统
- 不要搭跑团数值、舞台骨架、declare_variable。 - 每轮既推进当前章段,也更新仍对后文有价值的关系、伏笔和未决事项
- 长章叙事落产物,不要只留在聊天确认段。 - 若成功失败、阈值或资源消耗需要可核对裁决,同时混入数据化跑团案例
principles: tier_tendencies:
- 正推长文交付规格,勿套数据化跑团。 - module_id: story-progression
- 保留用户原意;规格只走 toolcall。 tier: 必须
- 格式与开场白分清:正文组成 ≠ 开场白。 why: 要钉清每轮如何承接、推进、停笔和接受用户改向
- module_id: narrative-guide
brief: 交互式长文:多轮钉需求与文风 → 可分段生成 tier: 有必要
why: 长文本需要跨段稳定的遣词、节奏与笔墨焦点
steps: [] - module_id: variable-design
tier: 有必要
why: 变量或动态表可维护人物变化、伏笔和未决事项,但不必天然数值化
- module_id: mechanism
tier: 有一定效果
why: 只有本局存在需要一致裁决的硬规则时才升档
@@ -1,36 +1,27 @@
# 快穿短局 · 对话落盘配方 # 本局创作方案参考案例;可以与其它案例混合或被整份改写,不是固定路径。
# 注入系统提示「本局配方」;不种节点 DAG。
# 各能力何时落盘 → 【能力 · 何时落盘】;本配方只给骨架,并覆盖「舞台/生成/拓扑」即使何时用成立也不落。
when: >- brief: 用一个立刻生效的体验核形成短反馈环,让少量材料尽快变成可玩的局面
用户要快速代入一小段体验:快穿小世界、一次性副本、单钩子情景、短任务。
核依赖跨轮记住时,至多两三个数字(时限、任务进度、好感)。
core: >- signals:
把用户原话收成可开玩的一核短局规格,写入本局黑板;开玩后主世界层读产物写正文,默认原文即终稿。 - 用户要一次性副本、单钩子情景、短任务或快穿小世界
必交:美学纲领(这一核是什么、怎么代入、轮转到哪停)。 - 乐趣集中在一个处境、关系或变造法则,删掉它整局就失去意义
用户代入有名字/简介 → insert 设计.主角设定(名字+背景);不要写进开场白。 - 用户希望快速进入局面,而不是先建设可长期经营的广阔世界
核是拿掉会换故事的变造法则 → 再交实现机制。
核依赖跨轮记住 → 再 declare 至多两三个真值。核可玩 → 交开场白(只要叙事,勿夹用户角色 meta)。
某一能力能不能落对照【能力 · 何时落盘】。材料只够围着这一核开玩 → 收口开场白。
用户改口要铺可经营世界 → 本局仍把已说的收成这一核,并告知完整世界用「数据化跑团体验」或工序编排「回合推演」。
process: method:
- 每轮对照【能力 · 何时落盘】与已有产物:条件已成立的可同轮多落;核还糊 → 问挡住的 1 点。 - 把最承重的处境、关系或法则压成一核,其余设定只保留能让这一核马上运转的部分
- 【1】insert 设计.美学纲领与交互范式:用用户原话写清这一核、站位、何时等用户。缺哪件问哪件。 - 让每轮反馈都回到这一核;需要状态时只保留真正改变选择或后果的少量状态
- 【1b】代入且有名字/简介 → insert 设计.主角设定(或 设计.主角设定#短码,与开场同槽)。禁止写进开场白 meta。 - 用户后来要求扩展经营时,可以混入长连续或硬机制案例,不把“短局”当永久限制
- 【2】核=拿掉会换故事的变造法则(如任务失败即送走)→ insert 设计.实现机制,写短。只是身份或开场处境 → 写进主角设定,跳过机制。
- 用户点名文风/禁忌/写法档 → insert 叙事指南。点名推进/大纲/停笔 → insert 故事推进。二者条件独立,不要因为没谈推进就不落文风。用户要分区或壳 → 回复呈现再正文组成。未点名文风与推进 → 跳过二者,开玩吃默认散文。材料已齐且条件成立时可与上列同轮一并落。
- 核必须跨轮记住的数(时限/进度/好感,至多三个)→ declare_variable;到某值换文案再 declare_map。没有门控 → 不立真值。
- 核已可玩 → insert 设计.开场白(须含 @玩家)。多开局用 设计.开场白#短码,互不覆盖;改某一局 insert 同一完整 tag。禁止夹用户角色.名字/简介。
- 舞台骨架 / 生成规则 / 拓扑即使「何时用」成立也不 insert:地点与规则折进美学核或开场白。
principles: tier_tendencies:
- 落盘只留删掉会让「这局玩什么」变掉的内容;其余当随口补充,问一句要不要并进核,用户没要就不 insert。 - module_id: aesthetics-interaction
- 交得出可玩开场即停。地图、分册、生成一族 NPC 留给「数据化跑团体验」或「回合推演」。 tier: 必须
- 有门控才 declare 真值。 why: 必须知道短局反复兑现的核心感受和停笔边界
- 默认只开主世界层;有真值时旁观由落档程序开。规格只走 toolcall。 - module_id: opening-setup
tier: 必须
brief: 一核短局:钉体验核 → 主角名片 → 按需一条机制/两三个数 → 开场开玩 why: 短局需要让体验核立即进入可玩的具体局面
- module_id: world-blueprint
steps: [] tier: 有一定效果
why: 只有体验核依赖换区、探索或空间选择时才需要展开
- module_id: variable-design
tier: 有一定效果
why: 只有状态会跨轮改变选择或后果时才升档
@@ -1,35 +1,27 @@
# 数据化跑团体验 · 对话落盘配方 # 本局创作方案参考案例;可以与长连续等案例混合,不以篇幅长短作区分。
# 注入转述总助手「本局配方」;不种节点 DAG。
# 名字带「数据化」,内核仍是 AIRP / 交互式小说。
# 各能力何时落盘 → 系统提示【能力 · 何时落盘】(modules meta);本配方只给顺序骨架。
when: >- brief: 用硬判断机制和结构化状态让选择、资源与后果可核对地持续生效
用户要回合制角色扮演、沉浸推演或交互式小说(AIRP / IF);
在普通设定之外,还可能需要跨轮记住少量状态,或按阈值换态度/大纲/场景文案
core: >- signals:
理论上总是先设计美学纲领与体验核心,用来锚定「用户会在意什么」; - 用户在意成功失败、资源消耗、阈值、检定或明确触发条件
再按需用核心规则(实现机制)钉下那套特殊故事设计;然后才往外兑现舞台、 - 状态必须跨轮保持真值,不能只靠叙事语气近似表达
叙事、真值、映射、版式与开场。作用对象是本局黑板;开玩后主世界层读投影与 - 表格或数值的变化本身会改变可做选择、世界反应或结局
真值写正文,默认只开主世界层、原文即终稿。
具体能不能落:对照【能力 · 何时落盘】的何时用 / 何时不用;本配方不重复罗列。
process: method:
- 每轮对照【能力 · 何时落盘】与已有产物:凡「何时用」已成立的可同轮多次 insert / declare_*;条件含糊或未到 → 本轮不落该能力,短确认或问挡住的 1 点。不抢跑未齐条件的下游。 - 只把会参与判断的事实结构化,明确真值、写入者、变化条件和冲突时的裁决来源
- 【骨架序 · 1】美学纲领与体验核心(能力表里通常总是先做)→ insert 设计.美学纲领与交互范式。模糊则细化;不确定则询问。未锚定前不建议下游。 - 把规则结果转成世界中的可感后果;数值服务体验,不用数字替代叙事
- 【骨架序 · 2】实现机制(仅当能力「何时用」成立:有拿掉就换骨架的变造法则)→ insert 设计.实现机制。何时不用成立则跳过,不追问要不要加规则。 - 长短与连续性另行决定;长连续跑团可同时引用交互式长文案例
- 【骨架序 · 3+】其余能力(主角设定、舞台、生成规则、叙事、变量、回复呈现、正文组成、开场白…)各自对照何时用再落;变量走 declare_variable / declare_map。本轮材料已够且条件齐的可一并落。
用户代入且有名字/身份/金手指时落主角设定(insert 设计.主角设定)。
- 聊完一轮:按 reply_module 分段确认本轮所落模块 + 建议「条件已近」的下一刀(勿推荐尚未满足何时用的能力)。
principles: tier_tendencies:
- 锚定优先:美学不定稳,不做下游;完善设定时不要忘掉核心体验。 - module_id: mechanism
- 何时落盘以能力 meta 为准;配方顺序是骨架,不是许可抢跑。 tier: 必须
- 正推:美学卡上用户会在意的部分 → 缺口 → 再落对应产物;换核就换补什么。 why: 硬判断需要可重复执行且结果一致的规则
- 承重才立真值;派生文案 = f(真值),只走映射。真值是兑现体验的加层,不是主戏。 - module_id: variable-design
- 默认无独立叙事转述、无旁观;用户点名再议。 tier: 必须
- 保留用户原意;规格只走 toolcall。 why: 参与判断的状态必须有真值、更新条件和维护方式
- module_id: story-progression
brief: 美学锚定 → 按需机制 → 对照能力何时落盘再兑现;可选真值+映射 tier: 有必要
why: 需要说明用户输入怎样触发判断,以及何时停下等待选择
steps: [] - module_id: topology
tier: 有一定效果
why: 只有升级、地图或关系本身构成可计算结构时才增加价值
@@ -1,32 +1,27 @@
# 文本生成器 · 对话落盘配方 # 本局创作方案参考案例;可以与其它案例混合或被整份改写,不是固定路径。
# 创作期写出游玩要用的固定上下文;成文在游玩:主世界出正文,转述出文风/壳。
when: >- brief: 把每次输入转成相对独立的成稿,并让范例与写法约束稳定复现
用户要短篇/短文批量,且要强模仿给定范例;
篇与篇不必剧情承接;成文走游玩管线(主世界 + 叙事转述)
core: >- signals:
创作进料必须 insert 齐游玩上下文:模仿范例、模仿要点、故事推进(大纲扩写)、可选叙事指南与轻量需求。 - 用户提供范例,要求仿写、扩写或批量产出
落档只挂载这些创作期产物,不另造用法。开玩后主世界写故事正文(Markdown),转述按范例改写/镶壳。 - 每份成稿可以独立收束,不依赖长期世界状态
刚需开转述;用户输入用法以创作期故事推进为准。 - 评价重点是成稿质量与风格复现,而非回合判定
process: method:
- 要齐:① insert 设计.模仿范例;② insert 设计.模仿要点(用户点名要学的面)。缺则问这两项。 - 区分范例中要复现的写法与只属于原文的内容,形成少量可执行约束
- 必 insert 设计.故事推进(创作期产物,开玩直接挂载)。正文至少写清: - 把用户每次输入解释为本篇素材或大纲,保留明确的人、事、话与结果,再补足具体场面
「用户输入用法:大纲扩写。本轮输入=短文大纲/场面要点;一次成完整篇并收束。禁止扮演停笔、禁止等用户行动。主世界交故事正文;有转述时转述负责文风与呈现,可补残稿但不改剧情。」 - 每份输出自行收束;只有用户明确要求跨篇承接时才混入长连续方法
- 文风细则对照「叙事指南」何时用再 insert 设计.叙事指南。
- 可选 insert 用户.需求(题材/禁忌/篇幅)。
- 齐料清单缺故事推进 / 范例 / 要点任一 → 先补 insert,不要说可以开玩。
- 材料齐了:告知「可以落档/开玩」;禁止在创作期写成稿短文。
- 开玩:用户本轮=本篇大纲;读创作期已挂上下文推进。对话一长 clear_dialogue,留产物。
principles: tier_tendencies:
- 游玩只消费创作期产物;用法/文风/模仿面都在 insert 里,不靠落档程序补种。 - module_id: narrative-guide
- 创作≠生产:创作收上下文;生产在游玩。 tier: 必须
- 分工:主世界 = 故事正文;转述 = 文风/壳(对照范例+要点;可补残稿)。 why: 范例的遣词、节奏和笔墨焦点需要稳定复现
- 输入用法由创作期故事推进钉死为大纲扩写。 - module_id: story-progression
- 一篇一世界;少问约束;无 declare_variable。 tier: 必须
why: 必须钉清输入是素材、大纲、续写还是改写要求
brief: 文本生成:创作期钉范例+要点+大纲扩写推进 → 开玩挂载成文 - module_id: variable-design
tier: 没有意义
steps: [] why: 独立成稿通常不需要跨篇真值;若要连续承接则按本局重新升档
- module_id: reply-format
tier: 有一定效果
why: 只有成稿需要特殊分区或局部渲染时才增加价值
@@ -1,25 +0,0 @@
# 正文组成 · 转述进料配方
# 只钉「怎么给用户看」的格式;开场白按此格式另生成。
when: >-
已大致知道要什么体验,需要定版式:选壳、分区、各区职责;
或用户在谈呈现/版式/要不要选项坞/监控栏
core: >-
写入设计.正文组成(格式契约)。
这不是开场白、不是故事正文;开场白必须按本格式的可见块生成。
process:
- 对照设定产物,选 shell_id(文本沉浸默认 prose)。
- 写可见块挂到壳区域(至少 body)。
- 需要时写微调(tone_chrome、block_labels、是否显示建议行动)。
- 用户要第一幕时:再按本格式生成设计.开场白(内容),勿把格式 JSON 当开场白展示。
principles:
- 格式权威在设计.正文组成;开场白服从格式。
- 不重议文风遣词(归叙事/需求);本步只交展示契约。
- 产物自洽:只读设计.正文组成就能知道开场/每轮终稿怎么灌数。
brief: 正文组成:钉呈现壳与可见块(格式)→ 开场白按格式生成
steps: []
@@ -0,0 +1,104 @@
# 对照
## intro
```intro
正文和一块结构同时在屏上。谁占主面积按本局定。抬头可贴在这一行的上沿。
```
## layout
```layout
id: alongside
主区: 正文与结构,并排
可加窄带: 抬头、文字选项
摆放:
抬头: 最上的一行短内容
正文: 一栏
结构: 另一栏。创作时写一次绘图渲染器并固定;游玩把运行拓扑的节点与连接灌进去出图。不把节点写成清单。折叠标题用本局这块内容的短名
文字选项: 最下,始终直接可见,不放进标签或折叠
窄屏改为上下排列。未启用的区不留空位。
```
## note
```note
不替换:外壳铺满宿主宽度;正文和结构同屏。窄屏改为上下。选项在最下,直接可见。结构用创作时固定的绘图器,游玩灌入拓扑。
可替换:两栏谁更宽、抬头文字、结构块的短名、配色和字体。本局没有结构时改用阅读或正文为主。
```
## sample
```sample
html:
<div id="app">
<p id="heading" data-block="heading"></p>
<div class="pair">
<article id="body" data-block="body"></article>
<section id="structure">
<div id="graph" data-block="structure"></div>
</section>
</div>
<ol id="options" data-block="options"></ol>
</div>
css:
:root { color: #1c1915; background: #f7f3ea; }
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.25rem; font: 16px/1.6 "Palatino Linotype", "Songti SC", serif; }
.pair { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 1rem; }
#structure { border: 1px solid #d5cbb8; border-radius: 6px; padding: 0.8rem; background: #fffdf8; }
#graph { min-height: 8rem; }
#options { margin: 1rem 0 0; padding-left: 1.2rem; }
@media (max-width: 720px) { .pair { grid-template-columns: 1fr; } }
js:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putGraph("graph", blocks.structure);
});
function putGraph(id, value) {
var el = document.getElementById(id);
if (!el) return;
el.replaceChildren();
var nodes = value && Array.isArray(value.nodes) ? value.nodes : [];
var links = value && Array.isArray(value.links) ? value.links : [];
if (!nodes.length) { el.remove(); return; }
var step = 120;
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 " + Math.max(nodes.length * step, step) + " 92");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "92");
var at = {};
nodes.forEach(function (node, i) { at[node.id] = i; });
links.forEach(function (link) {
var a = at[link.from];
var b = at[link.to];
if (a == null || b == null) return;
var line = document.createElementNS(svg.namespaceURI, "line");
line.setAttribute("x1", 48 + a * step);
line.setAttribute("y1", 34);
line.setAttribute("x2", 48 + b * step);
line.setAttribute("y2", 34);
line.setAttribute("stroke", "#8a5a2b");
svg.appendChild(line);
});
nodes.forEach(function (node, i) {
var x = 48 + i * step;
var circle = document.createElementNS(svg.namespaceURI, "circle");
circle.setAttribute("cx", x);
circle.setAttribute("cy", 34);
circle.setAttribute("r", 14);
circle.setAttribute("fill", node.current ? "#1c1915" : "#fffdf8");
circle.setAttribute("stroke", "#1c1915");
var label = document.createElementNS(svg.namespaceURI, "text");
label.setAttribute("x", x);
label.setAttribute("y", 70);
label.setAttribute("text-anchor", "middle");
label.setAttribute("font-size", "12");
label.textContent = String(node.name || node.id || "");
svg.appendChild(circle);
svg.appendChild(label);
});
el.appendChild(svg);
}
```
@@ -0,0 +1,16 @@
# 壳参考库。正文组成与 0 层正文组成读取后,按本局启用的区改一版并固定。
# 旧 present-shells 只留给现有渲染器,不作为生成样本。
shells:
- id: reading
name: 阅读
- id: dual-text
name: 正文为主
- id: data-cards
name: 数据常驻
- id: alongside
name: 对照
- id: paged
name: 分时
- id: piece
name: 成稿
@@ -0,0 +1,104 @@
# 数据常驻
## intro
```intro
数据一直铺在面上,不折叠,也不替换正文。正文仍是数百字的主文,占较宽的一栏。另一路内容占较窄的一栏。选项始终直接可见。
```
## layout
```layout
id: data-cards
主区: 数据常驻
附区: 正文(数百字)、窄栏
可加窄带: 抬头、文字选项
摆放:
抬头: 最上的一行
数据: 一直展开,紧挨抬头。当前值都短则并排;任一条较长则整组改为上下排列
正文: 数据之下较宽的一栏,不因数据常驻而缩成一句
窄栏: 较窄的一栏。栏顶标题按本局这块内容填写。窄屏改为正文在上、窄栏在下
文字选项: 最下,始终直接可见
本局没有窄栏时删掉窄栏,正文占回整宽。两三条短内容用阅读壳的抬头一行,不用本壳。未启用的区不留空位。
```
## note
```note
不替换:外壳铺满宿主宽度;数据区在抬头下一直展开。正文在其下较宽的一栏,篇幅仍是数百字。窄栏在较窄的一栏。选项在两栏之下,直接可见。窄屏时宽栏在上。
可替换:数据项的名称、条数和当前值,窄栏的标题和条目,配色和字体。本局没有窄栏时删掉窄栏,正文占回整宽。
```
## sample
```sample
html:
<div id="app">
<p id="heading" data-block="heading"></p>
<div id="cards"></div>
<div class="pair">
<article id="body" data-block="body"></article>
<aside class="side">
<p id="side-title" data-block="side_title"></p>
<div id="side"></div>
</aside>
</div>
<ol id="options" data-block="options"></ol>
</div>
css:
:root { color: #1c2420; background: #d5ddd4; }
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1rem; font: 15px/1.75 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
#heading { margin: 0 0 0.6rem; font-size: 13px; }
#cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.45rem; margin-bottom: 0.75rem; }
#cards.stack { grid-template-columns: 1fr; }
.card { padding: 0.4rem 0.55rem; border-radius: 4px; background: rgba(255,255,255,0.72); }
.card b { display: block; font-size: 12px; font-weight: 600; color: #5c6b62; }
.card span { font-size: 1.05rem; font-weight: 650; }
.pair { display: grid; grid-template-columns: 1.65fr 0.72fr; gap: 0.7rem; align-items: start; }
#body { margin: 0; padding: 0.85rem 1rem; border-radius: 6px; background: #f7faf6; }
.side { padding: 0.55rem 0.65rem; border-radius: 6px; background: rgba(22,28,26,0.88); color: #f2f4f2; font-size: 13px; line-height: 1.45; }
#side-title { margin: 0 0 0.15rem; padding-bottom: 0.35rem; border-bottom: 1px solid rgba(255,255,255,0.16); font-weight: 700; }
#options { margin: 0.75rem 0 0; padding: 0; list-style: none; }
#options li { margin-top: 0.35rem; padding: 0.35rem 0.7rem; border-radius: 4px; background: rgba(255,255,255,0.72); }
@media (max-width: 720px) { .pair, #cards { grid-template-columns: 1fr; } }
js:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putCards("cards", blocks.cards);
putLines("side", blocks.side);
});
function putCards(id, value) {
var el = document.getElementById(id);
if (!el) return;
el.replaceChildren();
var rows = Array.isArray(value) ? value : [];
if (!rows.length) { el.remove(); return; }
var long = rows.some(function (row) { return String(row.value ?? "").length > 8; });
el.classList.toggle("stack", long);
rows.forEach(function (row) {
var card = document.createElement("div");
card.className = "card";
var name = document.createElement("b");
var num = document.createElement("span");
name.textContent = String(row.name || "");
num.textContent = String(row.value ?? "");
card.appendChild(name);
card.appendChild(num);
el.appendChild(card);
});
}
function putLines(id, value) {
var el = document.getElementById(id);
if (!el) return;
el.replaceChildren();
var rows = Array.isArray(value) ? value : [];
if (!rows.length) { el.remove(); return; }
rows.forEach(function (line) {
var p = document.createElement("p");
p.textContent = String(line && line.text != null ? line.text : line);
el.appendChild(p);
});
}
```
@@ -0,0 +1,58 @@
# 双文
## intro
```intro
正文占满阅读宽度。短文和结构用标签或折叠,点开才看。选项始终直接可见。
```
## layout
```layout
id: dual-text
主区: 正文
可加: 短文、结构、数据、抬头、文字选项
摆放:
抬头: 最上的一行短内容
正文: 占满宿主阅读面;行长可限在正文柱,按数百字来写
短文、结构: 标签或折叠,点开才看。标签用短显示名
数据: 若有,默认可收起
文字选项: 始终直接可见,不放进标签或折叠
未启用的区不留空位。
```
## note
```note
不替换:外壳铺满宿主宽度;正文是主阅读面。其余块收在标签或折叠里,默认不展开。选项在这些控件之外,直接可见。
可替换:标签和折叠的短名、收起块里的文字、配色和字体、正文柱行长上限。本局没有的块删掉。数据要一直展开时改用数据常驻。
```
## sample
```sample
html:
<div id="app">
<p id="heading" data-block="heading"></p>
<article id="body" data-block="body"></article>
<details>
<summary id="vignette-title" data-block="vignette_title"></summary>
<div id="vignette" data-block="vignette"></div>
</details>
<ol id="options" data-block="options"></ol>
</div>
css:
:root { color: #1c1915; background: #f7f3ea; }
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.25rem; font: 17px/1.75 "Palatino Linotype", "Songti SC", serif; }
#heading, #body, details, #options { max-width: 40rem; margin-inline: auto; }
#heading { margin: 0 0 0.8rem; font-size: 0.95rem; }
#vignette { font-size: 0.92rem; margin-top: 0.6rem; }
#options { margin: 0.8rem auto 0; padding-left: 1.2rem; }
summary { cursor: pointer; }
js:
present.onData(function () {
/* 文本区由程序按 data-block 灌数;折叠开合是浏览器原生行为 */
});
```
@@ -0,0 +1,79 @@
# 分时
## intro
```intro
正文、小故事、数据卡、结构里有三块以上都要单独看。一次只显示一块,其余用标签切换。切换只改变当前视图。
```
## layout
```layout
id: paged
主区: 当前这一页
分页: 正文、小故事、数据卡、结构中本局启用且需要单独看的区
可加窄带: 抬头留在标签之外,始终可见
摆放:
抬头: 标签之上
标签: 一行,当前页加下划线。标签用本局该页的短名,不写区种类名
页内: 按该区自己的读法排。数据卡用卡片。结构调用已固定的拓扑绘图渲染器,不输出节点清单。
未启用的区不建标签。
```
## note
```note
不替换:外壳铺满宿主宽度;同时只显示一页。页用一行标签切换。抬头在标签之外。
可替换:每个标签的短名、页内文字和数据项、配色和字体、页内正文柱行长上限。本局未启用的页不建标签。
```
## sample
```sample
html:
<div id="app">
<p id="heading" data-block="heading"></p>
<div id="tabs"></div>
<div id="page" data-block="page"></div>
</div>
css:
:root { color: #1c1915; background: #f7f3ea; }
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.25rem; font: 16px/1.65 "Segoe UI", "Source Han Sans SC", sans-serif; }
#heading, #tabs, #page { max-width: 44rem; margin-inline: auto; }
#tabs { display: flex; gap: 0.4rem; margin-bottom: 0.9rem; }
#tabs button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 0.35rem 0.6rem; font: inherit; color: inherit; }
#tabs button[aria-current="true"] { border-bottom-color: #8a5a2b; }
js:
var pages = [];
var current = 0;
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
pages = Array.isArray(blocks.pages) ? blocks.pages : [];
current = 0;
drawTabs();
drawPage();
});
function drawTabs() {
var el = document.getElementById("tabs");
el.replaceChildren();
pages.forEach(function (page, index) {
var button = document.createElement("button");
button.type = "button";
button.textContent = page.name;
if (index === current) button.setAttribute("aria-current", "true");
button.addEventListener("click", function () {
current = index;
drawTabs();
drawPage();
});
el.appendChild(button);
});
}
function drawPage() {
var el = document.getElementById("page");
var text = pages[current] ? pages[current].text : "";
el.innerHTML = text ? present.markdown(String(text)) : "";
}
```
@@ -0,0 +1,48 @@
# 成稿
## intro
```intro
一次只放一份能单独拿走的成稿。成稿内部的小节在创作时按这一局的范例重排。选项贴在成稿下面,始终直接可见。
```
## layout
```layout
id: piece
主区: 一份成稿
可加窄带: 文字选项
摆放:
成稿: 外壳铺满宿主;成稿正文柱可限行长并水平居中
文字选项: 成稿之后,始终直接可见
未启用的区不留空位。
```
## note
```note
不替换:外壳铺满宿主宽度;一份成稿容器。选项在容器之后,直接可见。
可替换:容器内部的标记、分段、配色和字体、成稿柱行长上限。按本局这一份成品重写内部。样本内部与本局不同时整段替换,保留容器和选项的前后关系。
```
## sample
```sample
html:
<div id="app">
<article id="piece" data-block="piece"></article>
<ol id="options" data-block="options"></ol>
</div>
css:
:root { color: #1c1916; background: #ebe6dc; }
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.6rem 1.25rem 2rem; font: 16px/1.65 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
#piece, #options { max-width: 34rem; margin-inline: auto; }
#piece { margin: 0 auto; }
#options { margin: 1rem auto 0; padding-left: 1.15rem; }
js:
present.onData(function () {
/* 成稿与选项由程序按 data-block 灌数 */
});
```
@@ -0,0 +1,51 @@
# 阅读
## intro
```intro
只展示正文。抬头、文字选项需要时贴在正文前后。本局没有的区从骨架删除。
```
## layout
```layout
id: reading
主区: 正文
可加窄带: 抬头、文字选项
摆放:
抬头: 正文之前的一行短内容
正文: 阅读柱;外壳铺满宿主,行长可限在正文柱
文字选项: 正文之后,始终直接可见,不放进标签或折叠。不显示「文字选项」这个区名
未启用的区不留空位。
```
## note
```note
不替换:外壳铺满宿主宽度;单栏阅读柱。抬头在正文前,独占一行。选项在正文后,直接可见。
可替换:抬头文字、正文文字、配色和字体、正文柱行长上限。本局没有抬头时删掉抬头。
```
## sample
```sample
html:
<div id="app">
<p id="heading" data-block="heading"></p>
<article id="body" data-block="body"></article>
<ol id="options" data-block="options"></ol>
</div>
css:
:root { color: #1c1915; background: #f7f3ea; }
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.5rem 1.25rem 2.5rem; font: 17px/1.75 "Palatino Linotype", "Songti SC", serif; }
#heading, #body, #options { max-width: 38rem; margin-inline: auto; }
#heading { margin: 0 0 0.85rem; font-size: 0.95rem; letter-spacing: 0.04em; }
#body { font-size: 1.05rem; }
#options { margin: 1.25rem auto 0; padding-left: 1.2rem; }
js:
present.onData(function () {
/* 正文/抬头/选项由程序按 data-block 灌数;本地交互再写这里 */
});
```
@@ -22,6 +22,6 @@ presentation_hints:
prompt_excerpt: | prompt_excerpt: |
你是章节写手。只写本轮要求的一段/一章,对照大纲节点兑现承诺。 你是章节写手。只写本轮要求的一段/一章,对照大纲节点兑现承诺。
输出进 正文.当前段;若需归档到 正文.已完成 按声明 outputs 执行。 输出进 正文.当前段;若需归档到 正文.已完成 按声明 outputs 执行。
用户本轮输入视为待扩写为事实的大纲:细化完全后写成正文;剧情不超出大纲未涉及处,完整演绎大纲内容。 用户本轮输入是待演成场面的大纲,不是终稿句子。已写明的人、事、话、结果保持不变,每个节拍都要演到。节拍之间补具体动作、物件和当场的小纠正,把场合里已有的处境演出来;大纲没写这些动作也要写。例:「我去相亲、坐了下来」——坐下前写先要一杯咖啡,又急忙改成两杯,显出局促。只加形容词、比喻或气氛、不增加动作,不算扩写。另起大纲没有的冲突、新目标,或改掉已定结果,才算出界。
正文采用完整长自然段叙述,不要单句成段;篇幅默认约 1000~2000 字(用户或回复呈现另有规定则从其)。 正文采用完整长自然段叙述,不要单句成段;篇幅默认约 1000~2000 字(用户或回复呈现另有规定则从其)。
不要重写整本;不要发明与大纲冲突的主线转折,除非用户本轮明确要求。 不要重写整本;不要发明与大纲冲突的主线转折,除非用户本轮明确要求。
@@ -42,8 +42,9 @@ prompt_excerpt: |
用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。 用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。
采用完整长自然段叙述,不要单句成段;不要几句短信就结束。 采用完整长自然段叙述,不要单句成段;不要几句短信就结束。
用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准: 用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
- 大纲扩写:把主世界正文当事实底稿,细化文风后写完整终稿;不超出底稿未涉及的剧情;一次成篇,勿停等用户行动。 - 大纲扩写:把主世界正文当事实底稿写终稿。底稿里节拍之间的具体动作原样保留;文风只改遣词与节奏,不要把这些动作收成形容词。不另补底稿没有的动作,不改底稿已有的人、事、话、结果。续写短;用户角色实操仅保留底稿已有,续写不加代控。
- 扮演停笔:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。 - 续写:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。
- 未钉故事推进/叙事指南时:按大纲扩写,禁止默认扮演停笔。 - 自动写入:续写宜长;可按主角设定保留/补全用户角色言行(与底稿、人设一致)。
- 未钉故事推进/叙事指南时:按大纲扩写,禁止默认续写或自动写入。
禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。 禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。
禁止编造主世界正文中没有的事实。 禁止编造主世界正文中没有的事实。
@@ -31,9 +31,10 @@ prompt_excerpt: |
若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。 若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。
真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。 真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。
用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准: 用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
- 钉了「大纲扩写」:本轮输入=待扩写大纲/场面要点;一次交完整篇正文,勿停在等用户行动处。 - 钉了「大纲扩写」:本轮输入是待演成场面的大纲,不是终稿句子。已写明的人、事、话、结果保持不变,每个节拍都要演到。节拍之间补具体动作、物件和当场的小纠正,把场合里已有的处境演出来;大纲没写这些动作也要写。例:「我去相亲、坐了下来」——坐下前可以写先跟服务员要一杯咖啡,话出口又急忙改成两杯,用来显出局促;后面她提出的要求和拒绝的结果不动。只加形容词、比喻或气氛、不增加动作,不算拓写。另起大纲没有的冲突、新目标,或改掉已定结果,才算出界。设计文若写「不发明未涉及的剧情」,指的是这一种出界,不是禁止节拍之间的具体动作。续写段宜短且不可代写用户角色行动/说话;演绎后停在抉择点(配方另钉一次收束则从其配方)。
- 钉了「扮演停笔」:才只推进用户已声明行为/对话,并停在需用户回应之刻。 - 钉了「续写」:拓写跟用户已声明行为/对话;续写可推进场面但用户角色仅被动占位;禁止替用户角色做决定;停在需用户回应之刻。
- 未钉故事推进/叙事指南时:按大纲扩写处理,禁止默认扮演停笔。 - 钉了「自动写入」:短需求驱动;续写宜长;可按主角设定代写用户角色言行。
- 未钉故事推进/叙事指南时:按大纲扩写处理,禁止默认续写或自动写入。
需要随机数时:只用本轮上下文里程序已插入的备用随机数(见「设计.随机范围整理」);禁止口算编造。 需要随机数时:只用本轮上下文里程序已插入的备用随机数(见「设计.随机范围整理」);禁止口算编造。
不要把未投影的分档长文整表塞进输出;只写本轮可见后果。 不要把未投影的分档长文整表塞进输出;只写本轮可见后果。
禁止在正文里写出 tag 名、压缩摘要或过程日志。 禁止在正文里写出 tag 名、压缩摘要或过程日志。
+26 -3
View File
@@ -9,7 +9,20 @@ import {
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import path from "node:path"; import path from "node:path";
import type { BookProject, BookSummary } from "../types/book.js"; import type {
BookCreationMode,
BookProject,
BookSummary,
} from "../types/book.js";
function normalizeBookCreationMode(
raw: string | undefined | null,
): BookCreationMode {
if (raw === "listen_serial" || raw === "recipe" || raw === "dictate") {
return raw;
}
return "dictate";
}
import type { PersistedBookSession } from "../types/book-session.js"; import type { PersistedBookSession } from "../types/book-session.js";
import type { RunSnapshot } from "../types/run-snapshot.js"; import type { RunSnapshot } from "../types/run-snapshot.js";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
@@ -57,6 +70,7 @@ export function listBooks(): BookSummary[] {
title: b.title, title: b.title,
activeSkillId: b.activeSkillId ?? b.orchestratorId, activeSkillId: b.activeSkillId ?? b.orchestratorId,
activeSkillName: b.activeSkillName ?? b.orchestratorName, activeSkillName: b.activeSkillName ?? b.orchestratorName,
creationMode: normalizeBookCreationMode(b.creationMode),
preview: b.preview, preview: b.preview,
updatedAt: b.updatedAt, updatedAt: b.updatedAt,
orchestratorId: b.orchestratorId, orchestratorId: b.orchestratorId,
@@ -67,19 +81,27 @@ export function listBooks(): BookSummary[] {
export function getBook(id: string): BookProject | null { export function getBook(id: string): BookProject | null {
try { try {
const raw = readFileSync(bookPath(id), "utf8"); const raw = readFileSync(bookPath(id), "utf8");
return JSON.parse(raw) as BookProject; const book = JSON.parse(raw) as BookProject;
return {
...book,
creationMode: normalizeBookCreationMode(book.creationMode),
};
} catch { } catch {
return null; return null;
} }
} }
export function createBook(input: { title?: string }): BookProject { export function createBook(input: {
title?: string;
creationMode?: BookCreationMode;
}): BookProject {
ensureUserDataDirs(); ensureUserDataDirs();
mkdirSync(booksDir(), { recursive: true }); mkdirSync(booksDir(), { recursive: true });
const now = new Date().toISOString(); const now = new Date().toISOString();
const book: BookProject = { const book: BookProject = {
id: randomUUID(), id: randomUUID(),
title: input.title?.trim() || "未命名作品", title: input.title?.trim() || "未命名作品",
creationMode: normalizeBookCreationMode(input.creationMode),
preview: "新建作品,描述你想创作什么…", preview: "新建作品,描述你想创作什么…",
sessionIds: [], sessionIds: [],
createdAt: now, createdAt: now,
@@ -100,6 +122,7 @@ export function updateBook(
| "activeSessionId" | "activeSessionId"
| "activeSkillId" | "activeSkillId"
| "activeSkillName" | "activeSkillName"
| "creationMode"
| "orchestratorId" | "orchestratorId"
| "orchestratorName" | "orchestratorName"
> >
+24
View File
@@ -0,0 +1,24 @@
/**
* 从仓库 seeds/libraries 恢复偏好库/文风库,或导出当前库到种子目录。
*
* npx tsx src/cli/restore-library-seeds.ts
* npx tsx src/cli/restore-library-seeds.ts --export
*/
import {
exportLibrarySeedsToRepo,
restoreLibrarySeedsFromRepo,
} from "../libraries/seed-backup.js";
const exportMode = process.argv.includes("--export");
if (exportMode) {
const result = exportLibrarySeedsToRepo();
console.log(
`已导出种子:preferences=${result.preferences} style-packs=${result.stylePacks}`,
);
} else {
const result = restoreLibrarySeedsFromRepo();
console.log(
`已合并恢复:preferences +${result.preferencesAdded} style-packs +${result.stylePacksAdded}`,
);
}
+236 -37
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { import {
@@ -7,6 +7,7 @@ import {
type ProfileCapabilities, type ProfileCapabilities,
} from "../llm/capabilities.js"; } from "../llm/capabilities.js";
import { sanitizeReasoningEffort } from "../llm/generation-compat.js"; import { sanitizeReasoningEffort } from "../llm/generation-compat.js";
import { normalizeBaseUrl } from "../llm/list-models.js";
import type { LlmConfig } from "./env.js"; import type { LlmConfig } from "./env.js";
import { loadLlmConfigOptional } from "./env.js"; import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js"; import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
@@ -16,12 +17,21 @@ export type ApiProfile = {
name: string; name: string;
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
/** 这一条配置对应的模型 */
model: string; model: string;
/** /**
* 该配置默认思考强度(按型号写入 reasoning_effort / thinking)。 * 旧数据里曾把同端点的多个模型塞进一条配置。
* 省略 / auto = 不发送;none = 关闭或映射为最轻档;带 tools 时非 GLM 强制 none,GLM-5.3 保留合法档。 * 读入时会拆回独立配置,不再写回。
* 探测永远用 low,不受此项影响。 */
*/ models?: string[];
/** 同 baseUrl + 同 Key 哈希的收纳组 */
groupId?: string;
/**
* 该配置的思考强度(按型号写入 reasoning_effort / thinking)。预设不带这项。
* 省略 / auto = 不发送;none = 关闭或映射为最轻档。
* 带 tools 时:多数型号强制 none;GLM-5.3 与 Gemini 3 保留合法档(Gemini 3.8 为 low/medium/high)。
* 探测永远用 low,不受此项影响。
*/
reasoningEffort?: string; reasoningEffort?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -29,9 +39,24 @@ export type ApiProfile = {
capabilities?: ProfileCapabilities; capabilities?: ProfileCapabilities;
}; };
/** 前厅里折叠显示。分类用 Key 哈希,不比较、不展示原文。 */
export type ApiProfileGroup = {
id: string;
name: string;
baseUrl: string;
keyHash: string;
};
export type ApiProfileGroupView = {
id: string;
name: string;
baseUrl: string;
};
type ApiProfilesFile = { type ApiProfilesFile = {
version: 1; version: 1;
profiles: ApiProfile[]; profiles: ApiProfile[];
groups?: ApiProfileGroup[];
}; };
const FILE_NAME = "profiles.json"; const FILE_NAME = "profiles.json";
@@ -69,6 +94,65 @@ export function normalizeProfileReasoningEffort(
return sanitizeReasoningEffort(trimmed); return sanitizeReasoningEffort(trimmed);
} }
/** 读旧的 models 字段时用:当前模型放最前,去重。 */
export function normalizeModelList(
model: string,
models: unknown,
): { model: string; models: string[] } {
const current = model.trim();
const extra = Array.isArray(models)
? models
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
: [];
const seen = new Set<string>();
const list: string[] = [];
const push = (id: string) => {
if (!id || seen.has(id)) return;
seen.add(id);
list.push(id);
};
push(current);
for (const id of extra) push(id);
const active = current || list[0] || "deepseek-v4-pro";
if (!seen.has(active)) list.unshift(active);
return { model: list[0] ?? active, models: list };
}
export function apiKeyHash(apiKey: string): string {
return createHash("sha256").update(apiKey.trim()).digest("hex");
}
function groupSlot(baseUrl: string, apiKey: string): string {
return `${normalizeBaseUrl(baseUrl)}\n${apiKeyHash(apiKey)}`;
}
function hostLabel(baseUrl: string): string {
const trimmed = baseUrl.trim();
try {
return new URL(trimmed).host || trimmed || "未命名组";
} catch {
return trimmed.replace(/\/+$/, "") || "未命名组";
}
}
function uniqueGroupName(host: string, keyHash: string, used: Set<string>): string {
if (!used.has(host)) return host;
const alt = `${host} · ${keyHash.slice(0, 4)}`;
if (!used.has(alt)) return alt;
let n = 2;
while (used.has(`${host} · ${n}`)) n += 1;
return `${host} · ${n}`;
}
function withoutLegacyModels(profile: ApiProfile): ApiProfile {
if (profile.models === undefined) return profile;
const next = { ...profile };
delete next.models;
return next;
}
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile { function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
if (data.profiles.length > 0) return data; if (data.profiles.length > 0) return data;
const env = loadLlmConfigOptional(); const env = loadLlmConfigOptional();
@@ -79,23 +163,129 @@ function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
name: "环境变量 (.env)", name: "环境变量 (.env)",
baseUrl: env.baseUrl, baseUrl: env.baseUrl,
apiKey: env.apiKey, apiKey: env.apiKey,
model: env.model, model: env.model.trim() || "deepseek-v4-pro",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
return { version: 1, profiles: [profile] }; return { version: 1, profiles: [profile] };
} }
export function listApiProfiles(): ApiProfile[] { function splitLegacyModelLists(profiles: ApiProfile[]): {
const data = seedFromEnvIfEmpty(readFile()); profiles: ApiProfile[];
if (data.profiles.length !== readFile().profiles.length) { changed: boolean;
writeFile(data); } {
const out: ApiProfile[] = [];
let changed = false;
const now = new Date().toISOString();
for (const profile of profiles) {
const current = profile.model.trim();
const extras = normalizeModelList(current, profile.models).models.filter(
(model) => model !== current,
);
if (profile.models !== undefined) changed = true;
out.push(withoutLegacyModels({ ...profile, model: current || profile.model }));
for (const model of extras) {
changed = true;
const sibling: ApiProfile = {
id: randomUUID(),
name: model,
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model,
createdAt: now,
updatedAt: now,
};
if (profile.reasoningEffort) sibling.reasoningEffort = profile.reasoningEffort;
out.push(sibling);
}
} }
return data.profiles; return { profiles: out, changed };
}
/** 同 baseUrl + 同 Key 哈希共用一个组。组名可改,分类键不变。 */
function assignGroups(data: ApiProfilesFile): { data: ApiProfilesFile; changed: boolean } {
const groups = [...(data.groups ?? [])];
let changed = !Array.isArray(data.groups);
const bySlot = new Map<string, ApiProfileGroup>();
for (const group of groups) {
bySlot.set(`${normalizeBaseUrl(group.baseUrl)}\n${group.keyHash}`, group);
}
const usedNames = new Set(groups.map((group) => group.name));
const profiles = data.profiles.map((profile) => {
const baseUrl = profile.baseUrl.trim().replace(/\/+$/, "") || profile.baseUrl;
const slot = groupSlot(baseUrl, profile.apiKey);
let group = bySlot.get(slot);
if (!group) {
const keyHash = apiKeyHash(profile.apiKey);
const name = uniqueGroupName(hostLabel(baseUrl), keyHash, usedNames);
usedNames.add(name);
group = {
id: randomUUID(),
name,
baseUrl: normalizeBaseUrl(baseUrl) || baseUrl,
keyHash,
};
groups.push(group);
bySlot.set(slot, group);
changed = true;
}
if (profile.groupId !== group.id || profile.baseUrl !== baseUrl || profile.models) {
changed = true;
return withoutLegacyModels({ ...profile, baseUrl, groupId: group.id });
}
return profile;
});
const used = new Set(profiles.map((profile) => profile.groupId).filter(Boolean));
const kept = groups.filter((group) => used.has(group.id));
if (kept.length !== groups.length) changed = true;
return { data: { version: 1, profiles, groups: kept }, changed };
}
function loadProfiles(): ApiProfilesFile {
const raw = readFile();
const seeded = seedFromEnvIfEmpty(raw);
const split = splitLegacyModelLists(seeded.profiles);
const assigned = assignGroups({
version: 1,
profiles: split.profiles,
groups: seeded.groups,
});
if (seeded !== raw || split.changed || assigned.changed) {
writeFile(assigned.data);
}
return assigned.data;
}
export function listApiProfiles(): ApiProfile[] {
return loadProfiles().profiles;
} }
export function getApiProfile(id: string): ApiProfile | null { export function getApiProfile(id: string): ApiProfile | null {
return listApiProfiles().find((p) => p.id === id) ?? null; return listApiProfiles().find((profile) => profile.id === id) ?? null;
}
export function listApiGroups(): ApiProfileGroupView[] {
return (loadProfiles().groups ?? []).map((group) => ({
id: group.id,
name: group.name,
baseUrl: group.baseUrl,
}));
}
export function renameApiProfileGroup(id: string, name: string): ApiProfileGroupView {
const trimmed = name.trim();
if (!trimmed) throw new Error("组名不能为空");
const data = loadProfiles();
const index = (data.groups ?? []).findIndex((group) => group.id === id);
if (index < 0) throw new Error("配置组不存在");
const groups = [...(data.groups ?? [])];
const current = groups[index]!;
if (current.name === trimmed) {
return { id: current.id, name: current.name, baseUrl: current.baseUrl };
}
groups[index] = { ...current, name: trimmed };
writeFile({ ...data, groups });
return { id, name: trimmed, baseUrl: current.baseUrl };
} }
export function createApiProfile(input: { export function createApiProfile(input: {
@@ -105,7 +295,7 @@ export function createApiProfile(input: {
model: string; model: string;
reasoningEffort?: string; reasoningEffort?: string;
}): ApiProfile { }): ApiProfile {
const data = readFile(); const data = loadProfiles();
const now = new Date().toISOString(); const now = new Date().toISOString();
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort); const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
const profile: ApiProfile = { const profile: ApiProfile = {
@@ -119,8 +309,9 @@ export function createApiProfile(input: {
updatedAt: now, updatedAt: now,
}; };
data.profiles.push(profile); data.profiles.push(profile);
writeFile(data); const assigned = assignGroups(data);
return profile; writeFile(assigned.data);
return assigned.data.profiles.find((item) => item.id === profile.id) ?? profile;
} }
export function updateApiProfile( export function updateApiProfile(
@@ -129,43 +320,50 @@ export function updateApiProfile(
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort"> Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
>, >,
): ApiProfile { ): ApiProfile {
const data = readFile(); const data = loadProfiles();
const index = data.profiles.findIndex((p) => p.id === id); const index = data.profiles.findIndex((profile) => profile.id === id);
if (index < 0) throw new Error("API 配置不存在"); if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index]; const current = data.profiles[index]!;
const nextBase = input.baseUrl?.trim() || current.baseUrl; const nextBase =
input.baseUrl !== undefined
? input.baseUrl.trim() || current.baseUrl
: current.baseUrl;
const nextModel = input.model?.trim() || current.model; const nextModel = input.model?.trim() || current.model;
const nextKey =
input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim()
: current.apiKey;
const endpointChanged = const endpointChanged =
nextBase !== current.baseUrl || nextModel !== current.model; normalizeBaseUrl(nextBase) !== normalizeBaseUrl(current.baseUrl) ||
nextModel !== current.model;
const reasoningEffort = const reasoningEffort =
input.reasoningEffort !== undefined input.reasoningEffort !== undefined
? normalizeProfileReasoningEffort(input.reasoningEffort) ? normalizeProfileReasoningEffort(input.reasoningEffort)
: current.reasoningEffort; : current.reasoningEffort;
const updated: ApiProfile = { const draft: ApiProfile = {
...current, ...current,
name: input.name?.trim() || current.name, name: input.name !== undefined ? input.name.trim() || current.name : current.name,
baseUrl: nextBase, baseUrl: nextBase,
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== "" apiKey: nextKey,
? input.apiKey.trim()
: current.apiKey,
model: nextModel, model: nextModel,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
// 端点或模型变了,旧探测作废
...(endpointChanged ? { capabilities: undefined } : {}),
}; };
if (reasoningEffort) updated.reasoningEffort = reasoningEffort; delete draft.models;
else delete updated.reasoningEffort; if (reasoningEffort) draft.reasoningEffort = reasoningEffort;
data.profiles[index] = updated; else delete draft.reasoningEffort;
writeFile(data); if (endpointChanged) delete draft.capabilities;
return updated; data.profiles[index] = draft;
const assigned = assignGroups(data);
writeFile(assigned.data);
return assigned.data.profiles.find((profile) => profile.id === id) ?? draft;
} }
export function saveProfileCapabilities( export function saveProfileCapabilities(
id: string, id: string,
capabilities: ProfileCapabilities, capabilities: ProfileCapabilities,
): ApiProfile { ): ApiProfile {
const data = readFile(); const data = loadProfiles();
const index = data.profiles.findIndex((p) => p.id === id); const index = data.profiles.findIndex((profile) => profile.id === id);
if (index < 0) throw new Error("API 配置不存在"); if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index]!; const current = data.profiles[index]!;
const updated: ApiProfile = { const updated: ApiProfile = {
@@ -193,9 +391,10 @@ export async function probeAndSaveProfileCapabilities(
} }
export function deleteApiProfile(id: string): void { export function deleteApiProfile(id: string): void {
const data = readFile(); const data = loadProfiles();
data.profiles = data.profiles.filter((p) => p.id !== id); data.profiles = data.profiles.filter((profile) => profile.id !== id);
writeFile(data); const assigned = assignGroups(data);
writeFile(assigned.data);
} }
export function profileToLlmConfig(profile: ApiProfile): LlmConfig { export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
+1
View File
@@ -113,6 +113,7 @@ export function resolveActiveProfile() {
baseUrl: env.baseUrl, baseUrl: env.baseUrl,
apiKey: env.apiKey, apiKey: env.apiKey,
model: env.model, model: env.model,
models: [env.model],
createdAt: "", createdAt: "",
updatedAt: "", updatedAt: "",
}; };
+161 -29
View File
@@ -6,42 +6,140 @@ import {
type DictateProduct, type DictateProduct,
} from "./types.js"; } from "./types.js";
export type DictateContextPhase = "select" | "fill" | "reply";
/** 五段字数,只给用量统计,不另存正文。 */
export type DictateSegmentChars = {
rules: number;
history: number;
products: number;
injected: number;
tail: number;
};
export type DictateBuiltContext = {
messages: ChatMessage[];
segmentChars: DictateSegmentChars;
};
/** /**
* 拼给模型的上下文:系统提示 + 全部产物(按相对序)+ 全量对话。 * 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
* 清空对话后,只剩产物块,产物即浓缩上下文。 * 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
* 更早对话只追加,最后一轮用户输入放在末尾。
*/ */
export function buildDictateContext(params: {
systemPrompt: string;
products: DictateProduct[];
dialogue: DictateChatTurn[];
phase?: DictateContextPhase;
injected?: string;
tail?: string;
}): DictateBuiltContext {
const phase = params.phase ?? "fill";
if (phase === "reply") {
return buildReplyContext(params);
}
const { earlier, lastUser } = splitEarlierDialogue(params.dialogue);
const history = formatEarlierDialogue(earlier);
const products =
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
const injected = phase === "fill" ? params.injected?.trim() ?? "" : "";
const tail = joinBlocks([params.tail?.trim() ?? "", lastUser ? `最后一轮用户输入\n${lastUser}` : ""]);
const fillDuty =
phase === "fill"
? "## 本步收尾\n\n写完本轮节点表后,同一次请求必须调用 reply_user。只 insert 不算完成。"
: "";
const sections = [
fillDuty,
history,
products ? `## 当前产物(已写入黑板,按相对序;对话清空后仍保留)\n\n${products}` : "",
injected
? `## 本轮注入\n\n写完下列节点后,同一次请求必须调用 reply_user。\n\n本轮节点表\n\n${injected}`
: "",
tail ? `## 本轮\n\n${tail}` : "",
].filter(Boolean);
const user = sections.join("\n\n");
return {
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: user },
],
segmentChars: {
rules: params.systemPrompt.length,
history: history.length,
products: products.length,
injected: injected.length,
tail: tail.length,
},
};
}
/** 回复兜底:只给本轮产物 + 末句,不带全黑板与节点规格。 */
function buildReplyContext(params: {
systemPrompt: string;
products: DictateProduct[];
dialogue: DictateChatTurn[];
tail?: string;
}): DictateBuiltContext {
const { lastUser } = splitEarlierDialogue(params.dialogue);
const products = formatRoundProducts(params.products);
const tail = joinBlocks([
params.tail?.trim() ?? "",
lastUser ? `最后一轮用户输入\n${lastUser}` : "",
]);
const sections = [
products ? `## 本轮刚落盘\n\n${products}` : "## 本轮刚落盘\n\n(无正文产物;按删除与用户原话回复)",
tail ? `## 本轮\n\n${tail}` : "",
].filter(Boolean);
const user = sections.join("\n\n");
return {
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: user },
],
segmentChars: {
rules: params.systemPrompt.length,
history: 0,
products: products.length,
injected: 0,
tail: tail.length,
},
};
}
function formatRoundProducts(products: DictateProduct[]): string {
if (products.length === 0) return "";
return sortDictateProducts(products)
.map((product) => {
const score = formatSelfScore(product);
return [`### ${product.tag}`, score, product.content].filter(Boolean).join("\n");
})
.join("\n\n");
}
function formatSelfScore(product: DictateProduct): string {
const dims = product.selfScore?.dims;
if (!dims?.length) return "";
const lines = dims.map((dim) => {
const gap = dim.gap?.trim() ? `;缺口:${dim.gap.trim()}` : "";
const options =
dim.options?.length ? `;选项:${dim.options.map((item) => item.trim()).filter(Boolean).join(" / ")}` : "";
return `- ${dim.name}:${dim.score}${gap}${options}`;
});
return `self_score:\n${lines.join("\n")}`;
}
export function buildDictateMessages(params: { export function buildDictateMessages(params: {
systemPrompt: string; systemPrompt: string;
products: DictateProduct[]; products: DictateProduct[];
dialogue: DictateChatTurn[]; dialogue: DictateChatTurn[];
phase?: DictateContextPhase;
injected?: string;
tail?: string;
}): ChatMessage[] { }): ChatMessage[] {
const parts: string[] = []; return buildDictateContext(params).messages;
const products = sortDictateProducts(params.products);
if (products.length > 0) {
parts.push("## 当前产物(已写入黑板,按相对序;对话清空后仍保留)");
for (const p of products) {
const ord = effectiveDictateOrder(p);
parts.push(`### ${p.tag} 〔order=${ord}〕\n${p.content}`);
}
} else {
parts.push("## 当前产物\n(尚无)");
}
parts.push("## 对话(全量)");
if (params.dialogue.length === 0) {
parts.push("(尚无对话;等用户开口)");
} else {
for (const turn of params.dialogue) {
const who = turn.role === "user" ? "用户" : "助手";
parts.push(`【${who}】\n${turn.text}`);
}
}
return [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: parts.join("\n\n") },
];
} }
/** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */ /** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */
@@ -62,3 +160,37 @@ export function extractDictateDialogue(
} }
return out; return out;
} }
function splitEarlierDialogue(dialogue: DictateChatTurn[]): {
earlier: DictateChatTurn[];
lastUser: string;
} {
const last = dialogue.at(-1);
if (last?.role === "user") {
return { earlier: dialogue.slice(0, -1), lastUser: last.text.trim() };
}
return { earlier: dialogue, lastUser: "" };
}
function formatEarlierDialogue(dialogue: DictateChatTurn[]): string {
if (dialogue.length === 0) return "## 更早的对话\n\n(尚无更早对话)";
const lines = dialogue.map((turn) => {
const who = turn.role === "user" ? "用户" : "助手";
return `【${who}】\n${turn.text}`;
});
return `## 更早的对话\n\n${lines.join("\n\n")}`;
}
function formatProducts(products: DictateProduct[]): string {
if (products.length === 0) return "(尚无)";
return products
.map((product) => {
const ord = effectiveDictateOrder(product);
return `### ${product.tag} 〔order=${ord}〕\n${product.content}`;
})
.join("\n\n");
}
function joinBlocks(blocks: string[]): string {
return blocks.map((block) => block.trim()).filter(Boolean).join("\n\n");
}
+429
View File
@@ -0,0 +1,429 @@
import {
catalogModulesForIntake,
isDictateProgramModule,
type ModuleCatalog,
type ModuleCatalogEntry,
} from "../skills/creation-flow.js";
import type { DictateProduct } from "./types.js";
export const CREATION_PLAN_TAG = "设计.本局创作方案";
export const CREATION_PLAN_SCHEMA = "creation-plan.v1";
export const CREATION_PLAN_TIERS = [
"必须",
"有必要",
"有一定效果",
"没有意义",
] as const;
export type CreationPlanTier = (typeof CREATION_PLAN_TIERS)[number];
export type CreationPresentationMode = "message" | "zero_layer";
const PRESENTATION_MODULE_IDS: Record<
CreationPresentationMode,
readonly string[]
> = {
message: ["status-bar", "reply-format", "opening-setup"],
zero_layer: [
"zero-layer-status",
"zero-layer-reply-format",
"zero-layer-opening-setup",
],
};
const ALL_PRESENTATION_MODULE_IDS = new Set(
Object.values(PRESENTATION_MODULE_IDS).flat(),
);
/** 技能页按节点在对话落盘里的角色分组,不按旧配方包。 */
export const MODULE_BROWSE_GROUPS = [
{
id: "plan",
label: "本局方案",
hint: "抽出体验并给内容节点分档,不进入游玩",
},
{
id: "content",
label: "内容节点",
hint: "本局方案按档取用;这句话说清了再写",
},
{
id: "present",
label: "呈现链",
hint: "普通楼层与 0 层卡各用一套,两套不混用",
},
{
id: "program",
label: "落档程序",
hint: "开玩前由程序编译,创作对话不写入",
},
] as const;
export type ModuleBrowseGroupId = (typeof MODULE_BROWSE_GROUPS)[number]["id"];
export function moduleBrowseGroup(module: {
id: string;
auto?: boolean;
}): ModuleBrowseGroupId {
if (module.id === "creation-plan") return "plan";
if (isDictateProgramModule(module)) return "program";
if (ALL_PRESENTATION_MODULE_IDS.has(module.id)) return "present";
return "content";
}
export function modulePresentationMode(
moduleId: string,
): CreationPresentationMode | null {
for (const mode of ["message", "zero_layer"] as const) {
if ((PRESENTATION_MODULE_IDS[mode] as readonly string[]).includes(moduleId)) {
return mode;
}
}
return null;
}
export type CreationPlanItem = {
module_id: string;
tier: CreationPlanTier;
why: string;
missing: string;
instances: unknown[];
};
export type CreationPlan = {
schema: typeof CREATION_PLAN_SCHEMA;
技能: string;
brief: string;
/** 缺省兼容旧方案,按普通消息楼层处理。 */
presentation_mode?: CreationPresentationMode;
recipe: {
primary: string;
secondary_traits: string[];
method: string[];
references: string[];
};
items: CreationPlanItem[];
};
/**
* 本局方案只覆盖对话落盘中的内容能力。旧 DAG 收成节点、机械映射节点和方案自身不参与。
* 新内容能力默认纳入;程序步(auto)默认排除。
*/
export const CREATION_PLAN_EXCLUDED_MODULE_IDS = new Set([
"creation-plan",
"narrative",
"variable-context",
"context-order",
"worker-spec",
"refine",
]);
export function creationPlanModules(
catalog: ModuleCatalog | null | undefined,
presentationMode: CreationPresentationMode = "message",
): ModuleCatalogEntry[] {
if (!catalog) return [];
const selectedPresentation = new Set(
PRESENTATION_MODULE_IDS[presentationMode],
);
return catalogModulesForIntake(catalog, "dictate").filter(
(module) =>
!module.auto &&
!CREATION_PLAN_EXCLUDED_MODULE_IDS.has(module.id) &&
(!ALL_PRESENTATION_MODULE_IDS.has(module.id) ||
selectedPresentation.has(module.id)),
);
}
function presentationModeOf(
plan: Pick<CreationPlan, "presentation_mode">,
): CreationPresentationMode {
return plan.presentation_mode === "zero_layer" ? "zero_layer" : "message";
}
export function parseCreationPlan(raw: string | undefined | null): CreationPlan | null {
if (!raw?.trim()) return null;
try {
const doc = JSON.parse(raw) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const row = doc as Record<string, unknown>;
if (row.schema !== CREATION_PLAN_SCHEMA || !Array.isArray(row.items)) {
return null;
}
const recipe = row.recipe;
if (
typeof row.技能 !== "string" ||
typeof row.brief !== "string" ||
(row.presentation_mode !== undefined &&
row.presentation_mode !== "message" &&
row.presentation_mode !== "zero_layer") ||
!recipe ||
typeof recipe !== "object" ||
Array.isArray(recipe)
) {
return null;
}
const recipeRow = recipe as Record<string, unknown>;
if (
typeof recipeRow.primary !== "string" ||
!isStringArray(recipeRow.secondary_traits) ||
!isStringArray(recipeRow.method) ||
!isStringArray(recipeRow.references)
) {
return null;
}
const validItems = row.items.every((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const value = item as Record<string, unknown>;
return (
typeof value.module_id === "string" &&
CREATION_PLAN_TIERS.includes(value.tier as CreationPlanTier) &&
typeof value.why === "string" &&
typeof value.missing === "string" &&
Array.isArray(value.instances)
);
});
if (!validItems) return null;
return row as CreationPlan;
} catch {
return null;
}
}
export type CreationPlanValidation = {
ok: boolean;
missingIds: string[];
duplicateIds: string[];
unknownIds: string[];
error?: string;
plan?: CreationPlan;
};
export function validateCreationPlan(
raw: string,
catalog: ModuleCatalog | null | undefined,
): CreationPlanValidation {
const plan = parseCreationPlan(raw);
if (!plan) {
return {
ok: false,
missingIds: creationPlanModules(catalog).map((m) => m.id),
duplicateIds: [],
unknownIds: [],
error: `本局创作方案必须是合法的 ${CREATION_PLAN_SCHEMA} JSON,且含 items 数组`,
};
}
const presentationMode = presentationModeOf(plan);
const required = creationPlanModules(catalog, presentationMode).map((m) => m.id);
if (required.length === 0) {
return {
ok: false,
missingIds: [],
duplicateIds: [],
unknownIds: [],
error: "能力目录不可用,无法校验本局创作方案",
};
}
const ids = plan.items
.map((item) => String(item?.module_id ?? "").trim())
.filter(Boolean);
const counts = new Map<string, number>();
for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
const allowed = new Set(required);
const missingIds = required.filter((id) => !counts.has(id));
const duplicateIds = [...counts]
.filter(([, count]) => count > 1)
.map(([id]) => id);
const unknownIds = [...counts.keys()].filter((id) => !allowed.has(id));
const invalidTierIds = plan.items
.filter(
(item) =>
!CREATION_PLAN_TIERS.includes(item?.tier as CreationPlanTier),
)
.map((item) => String(item?.module_id ?? "(空 id)"));
const problems: string[] = [];
if (missingIds.length) problems.push(`漏项:${missingIds.join("、")}`);
if (duplicateIds.length) problems.push(`重复:${duplicateIds.join("、")}`);
if (unknownIds.length) problems.push(`未知:${unknownIds.join("、")}`);
if (invalidTierIds.length) {
problems.push(`档位无效:${invalidTierIds.join("、")}`);
}
return {
ok: problems.length === 0,
missingIds,
duplicateIds,
unknownIds,
...(problems.length ? { error: `本局创作方案能力覆盖校验失败;${problems.join(";")}` } : {}),
plan,
};
}
export function formatCreationPlanCapabilityCatalog(
catalog: ModuleCatalog | null | undefined,
): string {
const allContent = catalogModulesForIntake(catalog ?? { modules: [] }, "dictate").filter(
(module) =>
!module.auto && !CREATION_PLAN_EXCLUDED_MODULE_IDS.has(module.id),
);
const formatModule = (module: ModuleCatalogEntry) => {
const boundary = compact(module.boundary ?? "", 180);
return [
`- ${module.id}(${module.name}):${compact(module.declaration, 120)}`,
...(boundary ? [` 边界:${boundary}`] : []),
].join("\n");
};
const commonLines = allContent
.filter((module) => !ALL_PRESENTATION_MODULE_IDS.has(module.id))
.map(formatModule);
const byId = new Map(allContent.map((module) => [module.id, module]));
const modeLines = (mode: CreationPresentationMode) =>
PRESENTATION_MODULE_IDS[mode]
.map((id) => byId.get(id))
.filter((module): module is ModuleCatalogEntry => Boolean(module))
.map(formatModule);
if (commonLines.length === 0) return "";
return [
"【本局创作方案 · 能力目录】",
"先选择 presentation_mode=`message|zero_layer`。items 必须覆盖全部公共能力,并且只覆盖所选呈现模式的三项能力;不得同时选两套。分档看能力对抽出的核心体验和可运行性的贡献:用户没点名但缺了体验会明显变差的,标进「必须」或「有必要」。数组顺序不代表执行顺序。",
"【公共能力】",
commonLines.join("\n"),
"【message · 普通消息楼层】",
modeLines("message").join("\n"),
"【zero_layer · 持久 0 层卡】",
modeLines("zero_layer").join("\n"),
].join("\n");
}
export function formatCreationPlanProgress(params: {
plan: CreationPlan;
catalog: ModuleCatalog | null | undefined;
products: readonly DictateProduct[];
}): string {
const modules = new Map(
creationPlanModules(
params.catalog,
presentationModeOf(params.plan),
).map((module) => [module.id, module]),
);
const productTags = params.products.map((product) => product.tag);
const productByTag = new Map(params.products.map((product) => [product.tag, product]));
const groups = new Map<CreationPlanTier, string[]>(
CREATION_PLAN_TIERS.map((tier) => [tier, []]),
);
for (const item of params.plan.items) {
const module = modules.get(item.module_id);
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
const landedTags = productTags.filter(
(tag) => tag === module.artifact || tag.startsWith(`${module.artifact}#`),
);
const scores = landedTags
.map((tag) => {
const product = productByTag.get(tag);
const metadataScores = product?.selfScore?.dims.map((dim) => dim.score) ?? [];
return metadataScores.length
? Math.min(...metadataScores)
: lowestSelfScore(product?.content);
})
.filter((score): score is number => score !== undefined);
const scoreText = scores.length ? `,最低自评分 ${Math.min(...scores)}` : "";
const state = landedTags.length ? `已落${scoreText}` : "未落";
const missing = item.missing?.trim() ? `;缺口:${item.missing.trim()}` : "";
const lines = [
`- ${item.module_id}(${module.name})〔${state}〕:${item.why}${missing}`,
];
if (item.tier !== "没有意义") {
const when = oneLine(module.when ?? "");
const whenNot = oneLine(module.when_not ?? "");
if (when) lines.push(` 何时用:${when}`);
if (whenNot) lines.push(` 何时不用:${whenNot}`);
}
groups.get(item.tier)!.push(...lines);
}
return [
"【本局创作方案 · 当前决策】",
JSON.stringify(
{
schema: params.plan.schema,
brief: params.plan.brief,
presentation_mode: presentationModeOf(params.plan),
recipe: params.plan.recipe,
},
null,
2,
),
"",
"【能力状态】未落的「必须」和「有必要」是收口前的核对清单,不是本轮待办。前三档附带何时用 / 何时不用,方案改写或补全后仍对照它们决定本轮写哪项;可反复能力已落一条也保留。「没有意义」只列名称,不附使用条件。",
...CREATION_PLAN_TIERS.flatMap((tier) => [
`### ${tier}`,
...(groups.get(tier)?.length ? groups.get(tier)! : ["- (无)"]),
]),
].join("\n");
}
export function requiredCreationPlanGaps(params: {
planRaw: string | undefined;
catalog: ModuleCatalog | null | undefined;
products: readonly DictateProduct[];
}): string[] {
const plan = parseCreationPlan(params.planRaw);
if (!plan) return [];
const modules = new Map(
creationPlanModules(
params.catalog,
presentationModeOf(plan),
).map((module) => [module.id, module]),
);
return plan.items
.filter((item) => item.tier === "必须")
.filter((item) => {
const artifact = modules.get(item.module_id)?.artifact;
return (
!artifact ||
!params.products.some(
(product) =>
product.tag === artifact || product.tag.startsWith(`${artifact}#`),
)
);
})
.map((item) => item.module_id);
}
function lowestSelfScore(raw: string | undefined): number | undefined {
if (!raw?.trim()) return undefined;
try {
const doc = JSON.parse(raw) as Record<string, unknown>;
const modern = (doc.self_score as { dims?: Array<{ score?: unknown }> } | undefined)
?.dims;
const legacy = (doc.自评 as { 维度?: Array<{ 分数?: unknown }> } | undefined)
?.维度;
const values = [
...(modern ?? []).map((dim) => dim.score),
...(legacy ?? []).map((dim) => dim.分数),
]
.map(Number)
.filter(Number.isFinite);
return values.length ? Math.min(...values) : undefined;
} catch {
return undefined;
}
}
function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function compact(text: string, max: number): string {
const line = oneLine(text);
if (line.length <= max) return line;
return `${line.slice(0, max - 1)}…`;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
+200
View File
@@ -0,0 +1,200 @@
/**
* 体验是否锚定:程序按三件打分,不读模型自评 / 完备度。
* 权威字段是产物里的「体验锚定」;缺则回退到美学正文里对应的短句。
*/
import { tryParseJsonDoc } from "../parse/json-doc.js";
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领与交互范式";
export const EXPERIENCE_ANCHOR_RECIPE_NAMES = ["快穿短局", "数据化跑团体验"] as const;
const STANCES = ["完全代入", "部分代入", "旁观", "操控", "跟着体验"] as const;
const PLACEHOLDER =
/^(未定|待定|无|没有|不知道|不详|未知|待补|待探|仍混沌|核心明确|稍后|以后再说|待写|…+|\.{2,}|TBD|tbd|—+|-+|null|undefined)$/i;
export type AnchorItemId = "站位" | "核心感觉" | "轮转停点";
export type ExperienceAnchorItem = {
id: AnchorItemId;
ok: boolean;
/** 通过时记下程序采信的短句 */
value?: string;
reason: string;
};
export type ExperienceAnchorScore = {
total: 3;
passed: number;
anchored: boolean;
items: ExperienceAnchorItem[];
};
export function recipeRequiresExperienceAnchor(recipeName?: string): boolean {
const name = recipeName?.trim() ?? "";
return (EXPERIENCE_ANCHOR_RECIPE_NAMES as readonly string[]).includes(name);
}
export function isDictateOpeningTag(tag: string): boolean {
const t = tag.trim();
return (
t === "设计.开场白" ||
t.startsWith("设计.开场白#") ||
t === "设计.开场白与开场变量" ||
t === "设计.0层开场白与初态"
);
}
export function scoreExperienceAnchor(content: string | undefined | null): ExperienceAnchorScore {
const raw = content?.trim() ?? "";
const doc = raw ? asRecord(tryParseJsonDoc(raw)) : undefined;
const anchor = asRecord(doc?.体验锚定) ?? asRecord(asRecord(doc?.正文)?.体验锚定);
const body = asRecord(doc?.正文);
const stance = scoreStance(
firstText(
anchor?.站位,
dig(body, ["设定逻辑", "参与方式", "用户与user关系"]),
dig(body, ["交互范式", "前置配置", "用户与user关系"]),
labeled(raw, "站位"),
),
);
const feeling = scoreFeeling(
firstText(
anchor?.核心感觉,
dig(body, ["设定逻辑", "内容维度", "核心感觉", "已知"]),
dig(body, ["美学纲领", "体验内核"]),
labeled(raw, "核心感觉"),
),
);
const turn = scoreTurnStop(
firstText(
anchor?.轮转停点,
dig(body, ["交互范式", "描写权限", "思考与抉择"]),
labeled(raw, "轮转停点"),
),
);
const items = [stance, feeling, turn];
const passed = items.filter((item) => item.ok).length;
return { total: 3, passed, anchored: passed === 3, items };
}
export function formatExperienceAnchorBlock(score: ExperienceAnchorScore): string {
const lines = [
"## 体验锚定(程序打分,不采用模型自评)",
`分数:${score.passed}/${score.total}`,
`已锚定:${score.anchored ? "是" : "否"}`,
];
for (const item of score.items) {
const shown = item.ok && item.value ? `(${clip(item.value)})` : `(${item.reason})`;
lines.push(`- ${item.id}:${item.ok ? "通过" : "未通过"}${shown}`);
}
lines.push(
score.anchored
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领与交互范式」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
);
return lines.join("\n");
}
export function experienceAnchorToolPayload(score: ExperienceAnchorScore): {
passed: number;
total: 3;
anchored: boolean;
missing: AnchorItemId[];
} {
return {
passed: score.passed,
total: 3,
anchored: score.anchored,
missing: score.items.filter((item) => !item.ok).map((item) => item.id),
};
}
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
const missing = score.items.filter((item) => !item.ok).map((item) => item.id);
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领与交互范式」的「体验锚定」,再写开场。`;
}
function scoreStance(raw: string | undefined): ExperienceAnchorItem {
const id: AnchorItemId = "站位";
const t = clean(raw);
if (!t) return { id, ok: false, reason: "空或未定" };
if (/还是|或者/.test(t)) return { id, ok: false, reason: "还是问句,没有选定" };
const hit = STANCES.find((s) => t === s || t.startsWith(s));
if (hit) return { id, ok: true, value: hit, reason: "选定" };
if (t.includes("棋子") || t.includes("观察")) {
return { id, ok: true, value: "旁观", reason: "选定" };
}
return { id, ok: false, reason: "须是完全代入、部分代入、旁观、操控之一" };
}
function scoreFeeling(raw: string | undefined): ExperienceAnchorItem {
const id: AnchorItemId = "核心感觉";
const t = clean(raw);
if (!t || t.length < 4) return { id, ok: false, reason: "空、未定,或短于一句" };
return { id, ok: true, value: t, reason: "有一句" };
}
function scoreTurnStop(raw: string | undefined): ExperienceAnchorItem {
const id: AnchorItemId = "轮转停点";
const t = clean(raw);
if (!t || t.length < 8) return { id, ok: false, reason: "空、未定,或没写清何时停" };
if (!/停|等/.test(t)) return { id, ok: false, reason: "没写停笔或等用户" };
return { id, ok: true, value: t, reason: "写了何时停" };
}
function clean(raw: string | undefined): string {
const t = raw?.trim().replace(/\s+/g, " ") ?? "";
if (!t || PLACEHOLDER.test(t)) return "";
return t;
}
function firstText(...candidates: unknown[]): string | undefined {
for (const candidate of candidates) {
const text = textOf(candidate);
if (text) return text;
}
return undefined;
}
function textOf(value: unknown): string | undefined {
if (typeof value === "string") {
const t = value.trim();
return t || undefined;
}
const row = asRecord(value);
if (!row) return undefined;
for (const key of ["结论", "已知", "体验内核"]) {
if (typeof row[key] === "string" && row[key].trim()) return row[key].trim();
}
return undefined;
}
function labeled(raw: string, label: string): string | undefined {
if (!raw || raw.startsWith("{") || raw.startsWith("[")) return undefined;
const re = new RegExp(`(?:^|\\n)\\s*${label}\\s*[::]\\s*([^\\n]+)`);
return re.exec(raw)?.[1]?.trim();
}
function dig(root: Record<string, unknown> | undefined, path: string[]): unknown {
let cur: unknown = root;
for (const key of path) {
const row = asRecord(cur);
if (!row) return undefined;
cur = row[key];
}
return cur;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function clip(text: string): string {
const t = text.trim();
return t.length > 36 ? `${t.slice(0, 36)}…` : t;
}
+50 -5
View File
@@ -1,8 +1,21 @@
export { buildDictateSystemPrompt, DICTATE_SYSTEM_PROMPT } from "./prompt.js"; export {
buildDictateReplyPrompt,
buildDictateSelectPrompt,
buildDictateSystemPrompt,
DICTATE_SYSTEM_PROMPT,
formatDictateRoundTail,
} from "./prompt.js";
export {
buildDictateMessages,
buildDictateContext,
extractDictateDialogue,
} from "./context.js";
export { export {
CREATION_INTAKE_MODE_TAG, CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE, DICTATE_MODE_VALUE,
DICTATE_LAYER_META_KEY,
DICTATE_ORDER_META_KEY, DICTATE_ORDER_META_KEY,
DICTATE_SELF_SCORE_META_KEY,
isDictateModeValue, isDictateModeValue,
isAllowedProductTag, isAllowedProductTag,
defaultDictateOrder, defaultDictateOrder,
@@ -11,14 +24,45 @@ export {
sortDictateProducts, sortDictateProducts,
parseDictateOrder, parseDictateOrder,
type DictateProduct, type DictateProduct,
type DictateSelfScore,
type DictateChatTurn, type DictateChatTurn,
} from "./types.js"; } from "./types.js";
export {
buildDictateMessages,
extractDictateDialogue,
} from "./context.js";
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
export { runDictateTurn, type DictateTurnResult } from "./turn.js"; export { runDictateTurn, type DictateTurnResult } from "./turn.js";
export {
CREATION_PLAN_TAG,
CREATION_PLAN_SCHEMA,
CREATION_PLAN_TIERS,
CREATION_PLAN_EXCLUDED_MODULE_IDS,
creationPlanModules,
parseCreationPlan,
validateCreationPlan,
formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress,
requiredCreationPlanGaps,
type CreationPlan,
type CreationPlanItem,
type CreationPresentationMode,
type CreationPlanTier,
type CreationPlanValidation,
} from "./creation-plan.js";
export {
DICTATE_MODULE_READ_STATE_TAG,
buildDictateModuleReadIndex,
parseDictateModuleReadState,
serializeDictateModuleReadState,
readLibraryEntries,
type DictateModuleReadPayload,
type DictateModuleReadState,
} from "./read-module.js";
export {
AESTHETICS_PRODUCT_TAG,
scoreExperienceAnchor,
formatExperienceAnchorBlock,
recipeRequiresExperienceAnchor,
isDictateOpeningTag,
type ExperienceAnchorScore,
} from "./experience-anchor.js";
export { export {
DICTATE_INSERT_TAG_ALIASES, DICTATE_INSERT_TAG_ALIASES,
DICTATE_MULTI_MODULE_REPLY_HINT, DICTATE_MULTI_MODULE_REPLY_HINT,
@@ -48,6 +92,7 @@ export {
mergeGenerationRulesArtifacts, mergeGenerationRulesArtifacts,
resolveRepeatableInsertTag, resolveRepeatableInsertTag,
sanitizeRepeatableSlot, sanitizeRepeatableSlot,
splitConcreteInstanceContents,
} from "./repeatable-tags.js"; } from "./repeatable-tags.js";
export { export {
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
+26 -10
View File
@@ -15,6 +15,7 @@ import {
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */ /** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = { export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
"设计.开场白": "opening-setup", "设计.开场白": "opening-setup",
"设计.开场白与开场变量": "opening-setup",
"设计.美学纲领": "aesthetics-interaction", "设计.美学纲领": "aesthetics-interaction",
"设计.文风": "narrative-guide", "设计.文风": "narrative-guide",
"设计.监控栏": "status-bar", "设计.监控栏": "status-bar",
@@ -22,7 +23,7 @@ export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */ /** 多模块同轮:写进每条 insert tool result 的 reply_hint */
export const DICTATE_MULTI_MODULE_REPLY_HINT = export const DICTATE_MULTI_MODULE_REPLY_HINT =
"本轮带回了多个 reply_module。末尾可见回复:每个模块各写一块 markdown,标题用 ## 模块 · {名称}(与该 reply_module 一致);块间空一行;每块只写该模块的确认 / 薄弱点 / 追问。全部模块写完后,可另起一段写 1 条「建议下一刀」(条件须已接近成立)。缺某一块则补上后再结束本轮。"; "本轮写入了多个产物。末尾用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。";
const MAX_PROBE_CHARS = 1200; const MAX_PROBE_CHARS = 1200;
const MAX_PRINCIPLES_CHARS = 900; const MAX_PRINCIPLES_CHARS = 900;
@@ -54,7 +55,8 @@ export function resolveModuleForInsertTag(
if (byFamily) return byFamily; if (byFamily) return byFamily;
} }
const aliasId = DICTATE_INSERT_TAG_ALIASES[t]; const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
const aliasId = DICTATE_INSERT_TAG_ALIASES[t] ?? DICTATE_INSERT_TAG_ALIASES[family];
if (aliasId) { if (aliasId) {
return catalog.modules.find((m) => m.id === aliasId) ?? null; return catalog.modules.find((m) => m.id === aliasId) ?? null;
} }
@@ -73,6 +75,15 @@ export function extractSelfScoreDimensionNames(outputFence: string): string[] {
return names.slice(0, 8); return names.slice(0, 8);
} }
function extractScoreFenceDimensionNames(scoreFence: string): string[] {
const names: string[] = [];
for (const match of scoreFence.matchAll(/^([^#\-\s][^:\n]{0,30}):\s*$/gm)) {
const name = match[1]?.trim();
if (name && !names.includes(name)) names.push(name);
}
return names.slice(0, 8);
}
function clip(text: string, max: number): string { function clip(text: string, max: number): string {
const t = text.trim(); const t = text.trim();
if (t.length <= max) return t; if (t.length <= max) return t;
@@ -91,7 +102,12 @@ export function formatDictateUserFacingBrief(params: {
const probe = getModuleSection(sections, "probe"); const probe = getModuleSection(sections, "probe");
const principles = getModuleSection(sections, "principles"); const principles = getModuleSection(sections, "principles");
const output = getModuleSection(sections, "output"); const output = getModuleSection(sections, "output");
const dims = output ? extractSelfScoreDimensionNames(output) : []; const score = getModuleSection(sections, "score");
const dims = score
? extractScoreFenceDimensionNames(score)
: output
? extractSelfScoreDimensionNames(output)
: [];
if (!probe && !principles && dims.length === 0) return ""; if (!probe && !principles && dims.length === 0) return "";
@@ -101,19 +117,19 @@ export function formatDictateUserFacingBrief(params: {
if (dims.length) includes.push(`自评关注(${dims.join("、")})`); if (dims.length) includes.push(`自评关注(${dims.join("、")})`);
const parts: string[] = [ const parts: string[] = [
`# 模块 · ${params.module.name}`, `# 本轮关注 · ${params.module.name}`,
"", "",
`回答时请附带本模块(落盘 tag:\`${params.tag}\`)。`, `用于组织写入 \`${params.tag}\` 后的自然回复,不要求固定标题或独立模块段。`,
"", "",
"本模块内容包括:", "本模块内容包括:",
...includes.map((x) => `- ${x}`), ...includes.map((x) => `- ${x}`),
"", "",
"## 写入用户回复时做什么", "## 写入用户回复时做什么",
`1. 用二级标题 \`## 模块 · ${params.module.name}\` 开一段(多模块时本段只写本模块)。`, "1. 自然回应用户本轮值得保留、需要纠正或存在矛盾的内容。",
"2. 确认:2~4 句复述本模块已落要点,让用户能判断对不对。", "2. 简要说明形成或改写了什么;完整 JSON 由产物卡展示。",
"3. 薄弱点:对照下方「自评关注」口头点 1 处不够硬的地方(不必交打分表或 JSON)。", "3. 结合 self_score 说明最强处与关键缺口,不复述整张评分表。",
"4. 追问:1~2 点;每点给建议选项或短场景,用户可直接采用或改写。材料已够则追问可空,改写「建议下一刀」。", "4. 只有低分缺口确需用户决定时才提问,并给具体选项或短场景。",
"5. 不粘贴产物全文;不复述 tool JSON。", "5. 同轮多个产物时合并回应,不逐模块套模板;不粘贴产物全文或 tool JSON。",
]; ];
if (probe) { if (probe) {
+123 -9
View File
@@ -1,7 +1,9 @@
/** /**
* 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 * 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。 * 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
* 保存时按上游产物指纹决定是否重跑:没改过就保留已有排序和运行配置。
*/ */
import { createHash } from "node:crypto";
import type { Blackboard } from "../blackboard/blackboard.js"; import type { Blackboard } from "../blackboard/blackboard.js";
import { import {
CONTEXT_ORDER_SCHEMA, CONTEXT_ORDER_SCHEMA,
@@ -15,6 +17,7 @@ import {
type ContextOrderProjection, type ContextOrderProjection,
type ContextOrderSlot, type ContextOrderSlot,
} from "../skills/context-order.js"; } from "../skills/context-order.js";
import { parseContextFragment } from "../skills/context-fragment.js";
import { import {
WORKER_SET_DRAFT_TAG, WORKER_SET_DRAFT_TAG,
WORKER_SET_FINAL_TAG, WORKER_SET_FINAL_TAG,
@@ -22,7 +25,6 @@ import {
import { import {
defaultPlaySlots, defaultPlaySlots,
enabledPlaySlotIds, enabledPlaySlotIds,
parsePlaySlots,
refForSlot, refForSlot,
type PlaySlotsConfig, type PlaySlotsConfig,
} from "../skills/play-slots.js"; } from "../skills/play-slots.js";
@@ -38,6 +40,12 @@ import {
VALUE_MAP_TAG, VALUE_MAP_TAG,
listValueMapTargetTags, listValueMapTargetTags,
} from "../skills/value-map.js"; } from "../skills/value-map.js";
import {
applyMaintenanceStrategyToPlaySlots,
MAINTENANCE_STRATEGY_TAG,
resolveMaintenanceStrategyFromBoard,
writeMaintenanceStrategy,
} from "../skills/maintenance-strategy.js";
import { import {
DICTATE_ORDER_META_KEY, DICTATE_ORDER_META_KEY,
effectiveDictateOrder, effectiveDictateOrder,
@@ -49,6 +57,7 @@ import {
const SKIP_BIND_TAGS = new Set([ const SKIP_BIND_TAGS = new Set([
"设计.开场白", "设计.开场白",
"设计.开场白与开场变量", "设计.开场白与开场变量",
"设计.0层开场白与初态",
"设计.worker集", "设计.worker集",
"设计.worker集.草稿", "设计.worker集.草稿",
CONTEXT_ORDER_TAG, CONTEXT_ORDER_TAG,
@@ -56,6 +65,7 @@ const SKIP_BIND_TAGS = new Set([
CREATION_SELECTED_RECIPE_TAG, CREATION_SELECTED_RECIPE_TAG,
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
VALUE_MAP_TAG, VALUE_MAP_TAG,
MAINTENANCE_STRATEGY_TAG,
]); ]);
function shouldSkipBindTag(tag: string): boolean { function shouldSkipBindTag(tag: string): boolean {
@@ -71,22 +81,32 @@ function shouldSkipBindTag(tag: string): boolean {
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */ /** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
const STYLE_TAG_RE = const STYLE_TAG_RE =
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/; /叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复呈现|回复格式|监控栏|篇幅|结构/;
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */ /** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]); const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
/** 上次编译时所依据的上游指纹;两边产物一致且未过期则跳过重跑 */
export const DICTATE_PLAY_BIND_INPUT_META = "dictatePlayBindInput";
export type DictatePlayBindResult = { export type DictatePlayBindResult = {
workerSetJson: string; workerSetJson: string;
contextOrder: ContextOrderDoc; contextOrder: ContextOrderDoc;
boundTags: string[]; boundTags: string[];
changed: boolean; changed: boolean;
/** 上游产物未改,保留已有上下文排序与运行配置 */
skipped: boolean;
}; };
function isStyleProductTag(tag: string): boolean { function isStyleProductTag(tag: string): boolean {
return STYLE_TAG_RE.test(tag); return STYLE_TAG_RE.test(tag);
} }
function isFrequentlyChangingProduct(product: DictateProduct): boolean {
const stability = parseContextFragment(product.content)?.稳变;
return stability === "semi" || stability === "volatile";
}
/** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */ /** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */
export function collectDictateBindProducts(board: Blackboard): DictateProduct[] { export function collectDictateBindProducts(board: Blackboard): DictateProduct[] {
const products: DictateProduct[] = []; const products: DictateProduct[] = [];
@@ -157,20 +177,34 @@ function buildGmInserts(params: {
insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"), insert(0, WORKER_PERSONA_REF, "fixed", "槽位人设"),
]; ];
let o = 1; let o = 1;
for (const p of params.products) { const included = params.products.filter(
if (!params.includeStyle && isStyleProductTag(p.tag)) continue; (p) => params.includeStyle || !isStyleProductTag(p.tag),
);
const stable = included.filter((p) => !isFrequentlyChangingProduct(p));
const changing = included.filter(isFrequentlyChangingProduct);
for (const p of stable) {
inserts.push( inserts.push(
insert( insert(
o++, o++,
p.tag, p.tag,
"full", "full",
`对话落盘固定产物〔order=${effectiveDictateOrder(p)}〕`, `稳定产物;同频率按重要性序〔order=${effectiveDictateOrder(p)}〕`,
), ),
); );
} }
inserts.push( inserts.push(
insert(o++, DIALOGUE_HISTORY_TAG, "summary", "对话历史(按投影裁剪)"), insert(o++, DIALOGUE_HISTORY_TAG, "summary", "对话历史(按投影裁剪)"),
); );
for (const p of changing) {
inserts.push(
insert(
o++,
p.tag,
"full",
`较常变化产物;置于历史后〔order=${effectiveDictateOrder(p)}〕`,
),
);
}
for (const tag of params.runtimeTags) { for (const tag of params.runtimeTags) {
inserts.push( inserts.push(
insert( insert(
@@ -280,7 +314,8 @@ export function buildDictateContextOrder(params: {
return renumberContextOrder({ return renumberContextOrder({
schema: CONTEXT_ORDER_SCHEMA, schema: CONTEXT_ORDER_SCHEMA,
brief: "对话落盘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入", brief:
"程序按变化频率分组,并在同组内按重要性排列:稳定产物 → 对话.历史 → 较常变化产物与当前状态 → 本轮输入",
play_slots: params.playSlots, play_slots: params.playSlots,
slots, slots,
}); });
@@ -293,7 +328,7 @@ function resolvePlaySlots(
const parsed = existingRaw?.trim() const parsed = existingRaw?.trim()
? parseWorkerSetYaml(existingRaw) ? parseWorkerSetYaml(existingRaw)
: null; : null;
const slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) }; let slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) };
const recipeRef = parseSelectedRecipeRef( const recipeRef = parseSelectedRecipeRef(
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG), board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
); );
@@ -301,6 +336,9 @@ function resolvePlaySlots(
slots.gm = true; slots.gm = true;
slots.narrator = true; slots.narrator = true;
} }
// 变量设计.维护复杂度 → auditor true/false(内联则关旁观)
const strategy = resolveMaintenanceStrategyFromBoard(board);
slots = applyMaintenanceStrategyToPlaySlots(slots, strategy);
return slots; return slots;
} }
@@ -315,6 +353,8 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() || board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
""; "";
const playSlots = resolvePlaySlots(existing || undefined, board); const playSlots = resolvePlaySlots(existing || undefined, board);
const strategy = resolveMaintenanceStrategyFromBoard(board);
writeMaintenanceStrategy(board, strategy, "runtime:play-bind");
const runtimeTags = collectRuntimeStateTags(board); const runtimeTags = collectRuntimeStateTags(board);
const contextOrder = buildDictateContextOrder({ const contextOrder = buildDictateContextOrder({
products, products,
@@ -350,26 +390,100 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
? row.brief ? row.brief
: "对话落盘:主世界出内容,叙事转述出文风"; : "对话落盘:主世界出内容,叙事转述出文风";
} }
if (playSlots.auditor) {
const base =
typeof row.brief === "string" && row.brief.trim() ? String(row.brief) : "对话落盘落档自动生成";
if (!base.includes("旁观")) {
row.brief = `${base};旁观副 LLM 填表(固定)`;
}
}
const withSlots = JSON.stringify(row, null, 2); const withSlots = JSON.stringify(row, null, 2);
const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder); const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
const boundTags = products.map((p) => p.tag); const boundTags = products.map((p) => p.tag);
const changed = workerSetJson.trim() !== existing.trim(); const changed = workerSetJson.trim() !== existing.trim();
return { workerSetJson, contextOrder, boundTags, changed }; return { workerSetJson, contextOrder, boundTags, changed, skipped: false };
} }
/** 写入黑板:设计.worker集 + 设计.上下文投影排序 */ /**
* 上游指纹:会挂进游玩上下文的产物正文与次序,加上会改变槽位的配方、变量目录、映射和已启用槽。
* 不含投影结果(变量.当前、上下文排序、worker 集正文),避免保存后自己把自己标脏。
*/
export function dictatePlayBindInputKey(board: Blackboard): string {
const products = collectDictateBindProducts(board).map((product) => ({
tag: product.tag,
order: product.order ?? null,
content: product.content,
}));
const existing =
board.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() ||
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
"";
const payload = {
products,
recipe: board.getContentByTag(CREATION_SELECTED_RECIPE_TAG)?.trim() ?? "",
catalog: board.getContentByTag(VARIABLE_CATALOG_TAG)?.trim() ?? "",
maps: board.getContentByTag(VALUE_MAP_TAG)?.trim() ?? "",
playSlots: resolvePlaySlots(existing || undefined, board),
};
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
function storedPlayBindKey(
board: Blackboard,
tag: string,
): string | undefined {
const stamp = board.getLatestByTag(tag)?.metadata?.[DICTATE_PLAY_BIND_INPUT_META];
return typeof stamp === "string" && stamp.trim() ? stamp : undefined;
}
function freshPlayBind(
board: Blackboard,
key: string,
): DictatePlayBindResult | null {
const orderItem = board.getLatestByTag(CONTEXT_ORDER_TAG);
const workerItem = board.getLatestByTag(WORKER_SET_FINAL_TAG);
const orderRaw = orderItem?.content?.trim() ?? "";
const workerRaw = workerItem?.content?.trim() ?? "";
if (!orderRaw || !workerRaw) return null;
if (storedPlayBindKey(board, CONTEXT_ORDER_TAG) !== key) return null;
if (storedPlayBindKey(board, WORKER_SET_FINAL_TAG) !== key) return null;
try {
const contextOrder = JSON.parse(orderRaw) as ContextOrderDoc;
if (!contextOrder || typeof contextOrder !== "object") return null;
return {
workerSetJson: workerItem!.content,
contextOrder,
boundTags: collectDictateBindProducts(board).map((product) => product.tag),
changed: false,
skipped: true,
};
} catch {
return null;
}
}
/** 写入黑板:设计.worker集 + 设计.上下文投影排序。上游未改则不覆盖。 */
export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult { export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult {
const key = dictatePlayBindInputKey(board);
const fresh = freshPlayBind(board, key);
if (fresh) return fresh;
const result = bindDictateProductsToPlaySpec(board); const result = bindDictateProductsToPlaySpec(board);
const orderItem = board.getLatestByTag(CONTEXT_ORDER_TAG);
const workerItem = board.getLatestByTag(WORKER_SET_FINAL_TAG);
const stamp = { [DICTATE_PLAY_BIND_INPUT_META]: key };
board.write({ board.write({
tag: WORKER_SET_FINAL_TAG, tag: WORKER_SET_FINAL_TAG,
content: result.workerSetJson, content: result.workerSetJson,
source: "runtime", source: "runtime",
metadata: { ...(workerItem?.metadata ?? {}), ...stamp },
}); });
board.write({ board.write({
tag: CONTEXT_ORDER_TAG, tag: CONTEXT_ORDER_TAG,
content: JSON.stringify(result.contextOrder, null, 2), content: JSON.stringify(result.contextOrder, null, 2),
source: "runtime", source: "runtime",
metadata: { ...(orderItem?.metadata ?? {}), ...stamp },
}); });
return result; return result;
} }
+113 -94
View File
@@ -1,50 +1,52 @@
/** /**
* 对话落盘 · 创作进料专用系统提示 * 对话落盘 · 创作系统提示。
* * 系统侧只放跨轮不变的规则。方案进度、能力目录和本轮输入放在请求末尾。
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
* 仅用于创作层;开玩后由游玩管线接棒。
*/ */
export function buildDictateSystemPrompt(opts?: { export type DictatePromptTailOpts = {
/** 已有方案的短进度;放在末尾,不进系统提示 */
creationPlanState?: string;
/** 尚无方案时的短能力目录 */
capabilityCatalog?: string;
recipeExamples?: string;
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
recipeName?: string; recipeName?: string;
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
recipeBrief?: string; recipeBrief?: string;
/** 来自 modules catalog meta:何时落盘 / insert 目标 */ /** @deprecated 使用 capabilityCatalog */
moduleGuide?: string; moduleGuide?: string;
}): string { /** select:只定计划。fill:按注入规格写正文。reply:只交用户可见回复。 */
const recipeBlock = phase?: "select" | "fill" | "reply";
opts?.recipeName || opts?.recipeBrief /** 本轮要落实或重写的 tag 与错误 */
? ` checklist?: string;
## 本局配方 };
名称:${opts.recipeName ?? "(未命名)"}
${opts.recipeBrief ? `要点:\n${opts.recipeBrief}` : ""}
按上述适用与原则整理产物;材料只够做本配方时,就只做本配方范围内的事。
能力「何时落盘」以【能力 · 何时落盘】为准;本配方只给顺序骨架,不代替各能力调用条件。
`
: "";
const moduleBlock = opts?.moduleGuide?.trim() /** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
? ` export function buildDictateSelectPrompt(): string {
${opts.moduleGuide.trim()} return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
`
: "";
return `你是对话落盘创作助手:把用户原话整理成可落盘规格,并用可见回复让用户确认、补洞、选下一刀。作用对象是本局创作上下文(对话可清空,产物保留)。 nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
这一步不写产物正文,不回复用户。`;
}
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。 **本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
## 落盘节奏(先读) **填写收尾:** 节点表产物写完后,同一次请求必须再调用 reply_user。只 insert、把话写在普通正文里,都不算完成。回复写法见文末「用户可见回复」。
1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物,改为短确认或问挡住的 1 点。
2. **同轮可多次 insert / declare_***(一步并行或多个 tool 轮次都行):用户本轮材料已够、且多个能力的「何时用」都成立时,把这些都落掉;每个成功的 insert 各回一份 \`reply_module\`,可见回复按模块分段附带。
3. **不抢跑未齐条件的下游**(这才是「别一口气铺满」的意思):条件未成立的能力本轮不落,写进「建议下一刀」等用户补料或上游齐了再落。配方顺序是骨架;能不能落以各能力「何时用 / 何时不用」为准。
## 落盘(仅允许 toolcall) ## 写入规则
设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。 设定、变量、映射和删除通过本步的工具调用落盘。reply_user 只写给用户看的回复。
### insert — 写入/覆盖固定产物 ### insert — 写入/覆盖固定产物
- position:\`用户.*\` / \`设计.*\`(优先用【能力 · 何时落盘】里的落盘 tag) - position:\`用户.*\` / \`设计.*\`
- content:正文;order 可选(越小越靠前;越常改越大) - content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大)
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map - 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
- **〔可反复〕须拆分**:生成规则 / 具体实例等每条增殖用 \`基名#唯一id\`(如 \`设计.生成规则#ex-girlfriend-card\`、\`设计.具体实例#lei-ying\`)。id 用 rule_id / batch_id / 姓名 slug。禁止反复 insert 基名覆盖旧条;改某一条才 insert 同一完整 tag。 - 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag
- 只有方案中该填的内容都完成后才写开场白。程序在这次填写前编译信息可见范围、游玩执行方式、上下文排序和运行配置;本步按清单写出开场正文
- 开场白与生成规则、具体实例一样可增殖:每条入口写入 \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
### delete — 删除固定产物 ### delete — 删除固定产物
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除 - position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
@@ -54,7 +56,6 @@ ${opts.moduleGuide.trim()}
### declare_variable — 跨轮真值 ### declare_variable — 跨轮真值
- key / type / initial / user_visible(用户是否可见) - key / type / initial / user_visible(用户是否可见)
- 只立必须跨轮记住的状态;派生文案不要立成可写变量 - 只立必须跨轮记住的状态;派生文案不要立成可写变量
- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不 declare
- 移除字段 → undeclare_variable - 移除字段 → undeclare_variable
### declare_map — 真值→投影上下文 ### declare_map — 真值→投影上下文
@@ -63,70 +64,88 @@ ${opts.moduleGuide.trim()}
- 槽位固定、内容随真值换档;旧档离开上下文 - 槽位固定、内容随真值换档;旧档离开上下文
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写) - 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
## 对用户可见回复 ## 推进原则
tool 跑完后写给用户看的 markdown。作用:确认本轮落了什么、点出薄弱处、用可选追问引导补洞或下一刀。 1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。尚无方案时,按末尾目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。有方案时,写哪项先对照该能力的何时用 / 何时不用。
2. 本轮只处理用户这句话已经说清的能力,通常 2~3 个。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。
3. 先定范围再写。用户的话按最窄的成立范围落盘:体验目标说明玩家追求的对象,举例和比方说明用户想要的感觉或基底,都不说明整个世界都是这样。例:体验是「在各个世界征服高洁女性、打败恶役反派」,落成每个世界里有这样的目标人物和对手;世界本身仍按题材常识(如普通西幻),不写成全世界女性都高洁,也不为此发明神殿戒律。用「和现实一样」时顺口提到的现实事实,只确认基底是现实,不单独成条。宽窄拿不准、且会写出不同产物时,按最窄范围写或先不写,在回复里请用户确认范围。
4. 填写时以「本轮注入」里的 task、principles、output、score 和库正文为规格。注入尚未给出的能力,不猜造其复杂结构。
5. 变量和映射不写入普通产物。每次产物写入都是独立的一份。
6. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
7. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
执行顺序: ## 用户可见回复
1. 先完成本轮需要的 insert / declare_*。 ${dictateReplyGuide()}
2. 收集各次 insert 成功返回的 \`reply_module\`(若有;每个模块一份独立 markdown)。
3. 按下列规格写自然语言(粘贴产物全文或 tool JSON 都算失败):
**有 1 个 reply_module**
- 用二级标题 \`## 模块 · {名称}\`(名称与该 reply_module 标题一致)
- 段内按该模块的「写入用户回复时做什么」写满:确认要点 → 口头薄弱点 → 追问 1~2 点(每点含建议选项或短场景)
**有 N 个 reply_module(N≥2)**
- 同轮多次 insert,或一步并行多个,都会各回一份 \`reply_module\`
- 每个模块各写一块,标题互不相同的 \`## 模块 · {名称}\`;块与块之间空一行
- 每一块只服务该模块;全部模块写完后,可另起一段写「建议下一刀」(仅 1 条、且条件已接近成立)
**没有 reply_module**
- 用 2~4 句确认本轮写入的 tag 要点,并建议条件已近的下一刀;若落盘被挡住,只问挡住的 1 点
## 分清落盘目标
- 正文组成(格式)→ insert \`设计.正文组成\`(须「回复呈现」等上游条件已满足,见能力表)
- 开场白(内容)→ insert \`设计.开场白\` 或 \`设计.开场白#场景短码\`(多开场互不覆盖;改某一条则 insert 同一完整 tag);落档/开玩同步 \`输出.开场白\`
- 代入名片 → insert \`设计.主角设定\` 或 \`设计.主角设定#同短码\`(与开场白同槽即绑定该开局;无槽则全局默认)
- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.故事推进\`(大纲扩写);文风细则另 insert \`设计.叙事指南\`
- 好感分档性格等 → declare_variable + declare_map;分档文案走映射,不 insert 整表散文
${recipeBlock}${moduleBlock}
## 按用户本轮意图择一为主(仍须过「何时用」)
落盘后的可见回复一律按「对用户可见回复」写;下列各节只约定本轮落什么。
### A · 钉体验 / 设定
优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。
### B · 钉正文格式
「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。
### C · 生成开场白
收口:体验已锚定。首条可用 \`设计.开场白\`;再写别的开局用 \`设计.开场白#场景短码\`(与生成规则同款:同 tag 覆盖,新短码新建)。禁止用 \`## 岔路\` 塞进同一份。
**形状:** 只要叙事正文。禁止把 \`用户角色.名字\` / \`用户角色.简介\` 写进开场白。代入名片 → \`设计.主角设定\` 或 \`设计.主角设定#同短码\`。
**正文格式:** 已有 \`设计.正文组成\` 时,正文必须是 present.v1 JSON(\`shell\` 与正文组成一致,主读写 \`blocks.body\`),不要只交裸 Markdown;尚未钉正文组成时才用 Markdown。
**content 规格:** 正文至少一处字面 \`@玩家\` 作用户角色名位;姓名位用 \`@玩家\`,不写死具体姓名;全文不能只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。
进玩时在游玩辅助「开场」Tab 点选开局;有绑定名片则可切到该预设。
### D · 钉变量与映射
仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。配方名带「数据化」也不自动开跑 D。
### E · 文本生成器(创作收料 → 开玩挂载)
必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.故事推进\`(用户输入用法:大纲扩写;扮演停笔关掉)。文风细则对照「叙事指南」何时用再 insert \`设计.叙事指南\`。缺范例/要点/故事推进任一就先补产物,再说可以开玩。创作期不写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。
### F · 快穿短局
仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介 → insert \`设计.主角设定\`(不要写进开场白)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。
## 常用序感
- 需求/美学等不变设定:靠前(负)
- 正文组成:中间(0)
- 开场白:靠后(正)
## 设计.正文组成
JSON:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`。文本局默认 prose;至少一块 body。
## 其它
对话明显过长时 clear_dialogue(产物与变量/映射保留)。
`; `;
} }
/** 方案进度、目录和本轮任务。拼在请求末尾。 */
export function formatDictateRoundTail(opts?: DictatePromptTailOpts): string {
const legacyExample =
opts?.recipeName || opts?.recipeBrief
? `【旧配方案例 · 兼容参考】
${opts.recipeName ? `- ${opts.recipeName}` : ""}
${opts.recipeBrief ?? ""}
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。`
: "";
const state = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,给 2~3 个选项来问。写开场或结束创作前,列出仍未落的「必须」和「有必要」。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`。`
: `【当前状态 · 尚无本局创作方案】
先抽出用户要的核心体验,再对照能力目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"}
${opts?.recipeExamples?.trim() ? `\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""}
${legacyExample}`;
if (opts?.phase === "reply") {
const state = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
写回复时用这份进度核对:用户这句没覆盖到的「必须」或「有必要」,给 2~3 个选项来问;已写入的不再强行追问。`
: "";
return [
"本步只调用 reply_user。对照本轮刚落盘产物和「本轮关注」写综合回复,含自评最稳处、缺口例子与选项。",
opts?.checklist?.trim() ?? "",
state,
]
.filter(Boolean)
.join("\n\n");
}
const phase =
opts?.phase === "fill"
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准),写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
: "本步是第一次请求。只调用 select_nodes:这段话用于填充哪些节点。这一步不写产物正文,不回复用户。";
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
return opts?.phase === "fill"
? [phase, opts?.checklist?.trim() ?? "", state.trim()].filter(Boolean).join("\n\n")
: [state.trim(), opts?.checklist?.trim() ?? "", phase].filter(Boolean).join("\n\n");
}
/** @deprecated 用 buildDictateSystemPrompt() */ /** @deprecated 用 buildDictateSystemPrompt() */
export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt(); export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt();
/** 用户可见回复正文要求(评分、缺口、建议)。填写与回复兜底共用。 */
export function dictateReplyBodyGuide(): string {
return `自然回应用户这句里值得保留、要纠正或互相矛盾的地方,再说明形成了什么。产物正文留在产物卡。正文组成、0 层正文组成和开场的画面也以产物卡为准。
用 self_score 点出最稳的一处,以及分数一般或还有更好写法的缺口。每个缺口写三样:一个从原话推出来的短例子、一句它会让体验变成什么样、2~3 个可以接着说的选项。这份产物没有 self_score 时,用这句里还没定的具体点代替缺口。
本轮按最窄范围写下、或因范围不明先没写的点,排在缺口最前面问:说明你按什么范围理解,给 2~3 个宽窄选项(如只是这个目标人物 / 这一类人物都这样 / 整个世界的规则)。
这句话没覆盖到的「必须」或「有必要」,写出下一步可以提及哪些,还可能缺哪些节点也写在这里;还没有方案时,按这句已经说清的设定给 2~3 个可继续的方向。
最后补一个和这句相关、用户可能想接着定的点,放在回复里,这一步不为它调用 insert。`;
}
/** 用户可见回复。只放在默认系统提示里,填写尾巴和工具说明不再抄一遍。 */
export function dictateReplyGuide(): string {
return `insert 全部完成后,同一次请求必须调用 reply_user,生成给用户看的综合回复。多个产物合成一段。
${dictateReplyBodyGuide()}`;
}
/** 程序侧兜底回复请求:只看本轮已落盘产物与能力回复口径,只调 reply_user。不向填写步透露。 */
export function buildDictateReplyPrompt(): string {
return `你只生成给用户看的综合回复。对照本轮刚落盘的产物、附带的「本轮关注」和用户原话,调用 reply_user。
多个产物合成一段,不要逐模块套标题。不要再 insert、delete 或改设定。
${dictateReplyBodyGuide()}`;
}
+258
View File
@@ -0,0 +1,258 @@
import { createHash } from "node:crypto";
import {
getModuleSection,
loadModulePrompt,
parseModulePromptSections,
type ModuleCatalog,
type ModuleCatalogEntry,
} from "../skills/creation-flow.js";
import {
loadShellRefCatalog,
type ShellRefEntry,
} from "../skills/shell-ref-catalog.js";
import {
formatMarkdownSafeSubsetForPrompt,
loadMarkdownSafeSubsetCatalog,
} from "../skills/markdown-safe-subset.js";
import { getLibrary } from "../libraries/registry.js";
import {
getPreference,
listActivePreferences,
} from "../preference/store.js";
import {
getStylePack,
listActiveStylePacks,
} from "../style-pack/store.js";
export const DICTATE_MODULE_READ_STATE_TAG = "运行.对话落盘.已读能力";
export type DictateModuleReadPayload = {
module_id: string;
name: string;
artifact: string;
version: string;
task: string;
principles: string;
output: string;
score: string;
score_dimensions: string[];
libraries: Array<{
library_id: string;
label: string;
entries: Array<{ id: string; name?: string; summary: string }>;
}>;
constraints: string[];
presentation_references?: {
shells: ShellRefEntry[];
markdown_safe_subset: string;
iframe_contract: string;
};
};
export type DictateModuleReadState = {
schema: "dictate-module-read.v1";
modules: Record<string, string>;
active_module_id?: string;
};
export async function buildDictateModuleReadIndex(params: {
skillPackRoot: string;
catalog: ModuleCatalog;
}): Promise<Map<string, DictateModuleReadPayload>> {
const index = new Map<string, DictateModuleReadPayload>();
const [shells, markdown] = await Promise.all([
loadShellRefCatalog(params.skillPackRoot),
loadMarkdownSafeSubsetCatalog(params.skillPackRoot),
]);
for (const module of params.catalog.modules) {
const prompt = await loadModulePrompt(params.skillPackRoot, module.id);
if (!prompt) continue;
const sections = parseModulePromptSections(prompt);
const payload: DictateModuleReadPayload = {
module_id: module.id,
name: module.name,
artifact: module.artifact,
version: modulePromptVersion(sections.blocks),
task: getModuleSection(sections, "task") ?? "",
principles: getModuleSection(sections, "principles") ?? "",
output: getModuleSection(sections, "output") ?? "",
score: getModuleSection(sections, "score") ?? "",
score_dimensions: extractScoreDimensions(
getModuleSection(sections, "score") ?? "",
),
libraries: libraryDirectories(module),
constraints: moduleConstraints(module),
...(module.id === "reply-format" || module.id === "zero-layer-reply-format"
? {
presentation_references: {
shells,
markdown_safe_subset: markdown
? formatMarkdownSafeSubsetForPrompt(markdown)
: "",
iframe_contract:
module.id === "reply-format"
? "正文组成生成一次 frontend 模板与数据契约;游玩每轮只传 present.v1 blocks,由程序装进已固定的壳。iframe 使用 sandbox=\"allow-scripts\",不含 allow-same-origin;模板只通过 present.onData(cb) 收数据,不提供 action 回传。html+css+js 合计不超过 60 KB;未知 blocks 键必须保留。"
: "0层正文组成生成一次持久前端;之后每轮只提交 slot_updates。模板通过 zeroLayer.onInit 与 zeroLayer.onPatch 收数据,不提供 action 回传。html+css+js 合计不超过 60 KB。",
},
}
: {}),
};
index.set(module.id, payload);
index.set(module.name, payload);
}
return index;
}
export function parseDictateModuleReadState(
raw: string | undefined | null,
): DictateModuleReadState {
if (raw?.trim()) {
try {
const row = JSON.parse(raw) as DictateModuleReadState;
if (
row?.schema === "dictate-module-read.v1" &&
row.modules &&
typeof row.modules === "object"
) {
return row;
}
} catch {
// Reset malformed internal state.
}
}
return { schema: "dictate-module-read.v1", modules: {} };
}
export function serializeDictateModuleReadState(
state: DictateModuleReadState,
): string {
return JSON.stringify(state);
}
export function readLibraryEntries(params: {
module: DictateModuleReadPayload;
libraryId: string;
entryIds: string[];
reason?: string;
}): { entries?: unknown[]; error?: string } {
const libraryId = params.libraryId.trim();
const bound = params.module.libraries.some(
(library) => library.library_id === libraryId,
);
if (!bound) {
return { error: `能力 ${params.module.module_id} 未绑定库 ${libraryId}` };
}
const ids = [...new Set(params.entryIds.map((id) => id.trim()).filter(Boolean))];
if (ids.length === 0) return { error: "entry_ids 不能为空" };
if (ids.length > 2 && !params.reason?.trim()) {
return { error: "默认最多读取两个库条目;读取更多时必须提供 reason" };
}
if (libraryId === "preferences") {
const entries = ids.map(getPreference);
const missing = ids.filter((_, index) => !entries[index]);
if (missing.length) return { error: `偏好库无条目:${missing.join("、")}` };
return {
entries: entries.map((entry) => ({
id: entry!.id,
content: entry!.content,
})),
};
}
if (libraryId === "style-packs") {
const entries = ids.map(getStylePack);
const missing = ids.filter((_, index) => !entries[index]);
if (missing.length) return { error: `文风库无条目:${missing.join("、")}` };
return {
entries: entries.map((entry) => ({
id: entry!.id,
name: entry!.name,
content: entry!.content,
...(entry!.samples ? { samples: entry!.samples } : {}),
})),
};
}
return { error: `库 ${libraryId} 尚不支持按条目读取` };
}
function modulePromptVersion(blocks: Record<string, string>): string {
const source = ["task", "principles", "output", "score"]
.map((id) => `${id}\n${blocks[id]?.trim() ?? ""}`)
.join("\n---\n");
return createHash("sha256").update(source).digest("hex").slice(0, 16);
}
function libraryDirectories(module: ModuleCatalogEntry): DictateModuleReadPayload["libraries"] {
return (module.libraries ?? []).map((id) => {
const provider = getLibrary(id);
if (id === "preferences") {
return {
library_id: id,
label: provider?.label ?? id,
entries: listActivePreferences().map((entry) => ({
id: entry.id,
summary: clip(entry.content),
})),
};
}
if (id === "style-packs") {
return {
library_id: id,
label: provider?.label ?? id,
entries: listActiveStylePacks().map((entry) => ({
id: entry.id,
name: entry.name,
summary: clip(entry.content),
})),
};
}
return { library_id: id, label: provider?.label ?? id, entries: [] };
});
}
function repeatableLandLine(module: ModuleCatalogEntry): string {
if (module.id === "opening-setup") {
return "产物可反复:每条开场使用 设计.开场白#场景短码;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
}
if (module.id === "zero-layer-opening-setup") {
return "产物可反复:每条开场使用 设计.0层开场白与初态#场景短码;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
}
if (module.id === "generation-rules") {
return "产物可反复:每条规则单独落盘,使用 设计.生成规则#规则名;同完整 tag 才覆盖";
}
if (module.id === "concrete-instances") {
return "产物可反复:每条实例单独落盘,使用 设计.具体实例#名称;同完整 tag 才覆盖";
}
if (module.repeatable) {
return `产物可反复:每条单独落盘,使用 ${module.artifact}#唯一id;同完整 tag 才覆盖`;
}
return `产物 tag:${module.artifact}`;
}
function moduleConstraints(module: ModuleCatalogEntry): string[] {
const constraints = [repeatableLandLine(module)];
if (module.id === "generation-rules") {
constraints.push(
"产物层由生命周期决定:只预生成=intermediate;仅游玩期 / 预生成并游玩期=final",
);
constraints.push("每个产物只写一条规则");
} else if (module.layer) {
constraints.push(`默认产物层:${module.layer}`);
}
if (module.mount?.length) constraints.push(`默认挂载:${module.mount.join("、")}`);
if (module.auto) constraints.push("机械程序节点,不受读取门禁");
return constraints;
}
function clip(text: string, max = 140): string {
const compact = text.replace(/\s+/g, " ").trim();
return compact.length <= max ? compact : `${compact.slice(0, max - 1)}…`;
}
function extractScoreDimensions(score: string): string[] {
const names: string[] = [];
for (const match of score.matchAll(/^([^#\-\s][^:\n]{0,30}):\s*$/gm)) {
const name = match[1]?.trim();
if (name && !names.includes(name)) names.push(name);
}
return names;
}
+179 -31
View File
@@ -1,7 +1,7 @@
/** /**
* 对话落盘 · 可增殖产物 tag 拆分 * 对话落盘 · 可增殖产物 tag 拆分
* *
* catalog 标 repeatable 的能力(生成规则 / 具体实例)共用同一 artifact 基名。 * catalog 标 repeatable 的能力(生成规则 / 具体实例 / 拓扑图谱等)共用同一 artifact 基名。
* 对话里若反复 insert 基 tag,后写会整份覆盖先写。 * 对话里若反复 insert 基 tag,后写会整份覆盖先写。
* 约定:基名#槽位(如 设计.具体实例#lei-ying),每条增殖各占一 tag。 * 约定:基名#槽位(如 设计.具体实例#lei-ying),每条增殖各占一 tag。
*/ */
@@ -48,6 +48,85 @@ export function buildRepeatableProductTag(
return s ? `${base}${REPEATABLE_TAG_SEP}${s}` : base; return s ? `${base}${REPEATABLE_TAG_SEP}${s}` : base;
} }
function nameFromRecord(row: unknown): string | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
const record = row as Record<string, unknown>;
return (
asString(record.姓名) ??
asString(record.名称) ??
asString(record.名字) ??
asString(record.name) ??
asString(record.id)
);
}
function concreteInstanceName(body: Record<string, unknown>): string | undefined {
const direct =
asString(body.名称) ??
asString(body.实例名) ??
asString(body.batch_id) ??
asString(body.批次id) ??
asString(body.id);
if (direct) return direct;
const fromRecord = nameFromRecord(body.记录);
if (fromRecord) return fromRecord;
const rows = Array.isArray(body.records)
? body.records
: Array.isArray(body.记录)
? body.记录
: [];
for (const row of rows) {
const name = nameFromRecord(row);
if (name) return name;
}
return undefined;
}
function concreteRecordRows(body: Record<string, unknown>): unknown[] | null {
if (Array.isArray(body.records) && body.records.length > 1) return body.records;
if (Array.isArray(body.记录) && body.记录.length > 1) return body.记录;
return null;
}
/** 一条具体实例正文里若含多条记录,拆成每条一份。不足两条时原样返回。 */
export function splitConcreteInstanceContents(content: string): string[] {
const parsed = tryParseJsonDoc(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return [content];
}
const doc = parsed as Record<string, unknown>;
const bodyRaw = doc.正文;
if (!bodyRaw || typeof bodyRaw !== "object" || Array.isArray(bodyRaw)) {
return [content];
}
const body = bodyRaw as Record<string, unknown>;
const rows = concreteRecordRows(body);
if (!rows) return [content];
const rule =
asString(body.规则) ?? asString(body.规则名) ?? asString(body.rule_id);
const used = new Set<string>();
return rows.map((row, index) => {
const base = nameFromRecord(row) ?? `第${index + 1}条`;
let name = base;
let n = 2;
while (used.has(name)) {
name = `${base}-${n}`;
n += 1;
}
used.add(name);
const nextBody: Record<string, unknown> = {
...(rule ? { 规则: rule } : {}),
名称: name,
记录: row && typeof row === "object" && !Array.isArray(row) ? row : { 内容: String(row) },
};
if (Array.isArray(body.批次条件) && body.批次条件.length) {
nextBody.批次条件 = body.批次条件;
}
return JSON.stringify({ ...doc, brief: name, 正文: nextBody }, null, 2);
});
}
function asString(v: unknown): string | undefined { function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined; if (typeof v !== "string") return undefined;
const t = v.trim(); const t = v.trim();
@@ -64,8 +143,76 @@ function bodyOfFragment(content: string): Record<string, unknown> | null {
return body as Record<string, unknown>; return body as Record<string, unknown>;
} }
/** 多开场切换用的落盘族。每条必须是 基名#场景短码。 */
export const OPENING_SLOT_FAMILY = "设计.开场白";
/** 旧整包 tag。insert 时改写到 OPENING_SLOT_FAMILY#槽,避免再并列一份。 */
export const LEGACY_OPENING_BUNDLE_FAMILY = "设计.开场白与开场变量";
const OPENING_SLOT_REQUIRED =
"开场每条单独落盘,position 用「设计.开场白#场景短码」(例如 设计.开场白#opening-cycle2),供之后左右切换。不要写裸「设计.开场白」,也不要写「设计.开场白与开场变量」。改某一条则 insert 同一完整 tag。没有变量的卡不写开场变量,tag 仍然带场景短码。";
/** 旧整包族改写成开场族,槽位原样保留。 */
export function rewriteLegacyOpeningTag(tag: string): string {
const raw = tag.trim();
if (dictateProductFamily(raw) !== LEGACY_OPENING_BUNDLE_FAMILY) return raw;
const slot = dictateProductSlot(raw);
return slot ? buildRepeatableProductTag(OPENING_SLOT_FAMILY, slot) : OPENING_SLOT_FAMILY;
}
function openingCandidateRows(body: Record<string, unknown>): Record<string, unknown>[] {
const raw = body.开场白候选 ?? body.候选;
if (!Array.isArray(raw)) return [];
return raw.filter(
(row): row is Record<string, unknown> =>
Boolean(row) && typeof row === "object" && !Array.isArray(row),
);
}
/** /**
* 从正文抽出建议槽位:生成规则 → rule_id;具体实例 → batch_id / 首条姓名。 * 从单张开场正文抽出场景短码。多条候选挤在一份正文里时拒绝,要求拆成多次 insert。
*/
export function extractOpeningSlotFromContent(
content: string,
): { slot?: string; error?: string } {
const parsed = tryParseJsonDoc(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const doc = parsed as Record<string, unknown>;
const nested = bodyOfFragment(content);
const body = nested ?? doc;
const direct =
asString(doc.id) ??
asString(doc.候选id) ??
asString(doc.场景短码) ??
asString(body.id) ??
asString(body.候选id) ??
asString(body.场景短码);
if (direct) {
const slot = sanitizeRepeatableSlot(direct);
return slot ? { slot } : {};
}
const rows = openingCandidateRows(body);
if (rows.length > 1) {
return {
error:
"多条开场不要写进同一份产物。每条 insert 一次「设计.开场白#场景短码」。",
};
}
const one = rows[0];
const fromOne = one
? asString(one.id) ?? asString(one.候选id) ?? asString(one.场景短码)
: undefined;
const fallback =
rows.length <= 1
? asString(body.默认候选id) ?? asString(body.默认候选ID)
: undefined;
const picked = fromOne ?? fallback;
if (!picked) return {};
const slot = sanitizeRepeatableSlot(picked);
return slot ? { slot } : {};
}
/**
* 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。
*/ */
export function extractRepeatableSlotFromContent( export function extractRepeatableSlotFromContent(
family: string, family: string,
@@ -77,7 +224,11 @@ export function extractRepeatableSlotFromContent(
if (base === "设计.生成规则" || /生成规则$/.test(base)) { if (base === "设计.生成规则" || /生成规则$/.test(base)) {
const top = const top =
asString(body.rule_id) ?? asString(body.id) ?? asString(body.规则id); asString(body.规则名) ??
asString(body.rule_id) ??
asString(body.id) ??
asString(body.规则id) ??
asString(body.名称);
if (top) return sanitizeRepeatableSlot(top) || undefined; if (top) return sanitizeRepeatableSlot(top) || undefined;
const rules = body.rules; const rules = body.rules;
if (Array.isArray(rules)) { if (Array.isArray(rules)) {
@@ -85,7 +236,10 @@ export function extractRepeatableSlotFromContent(
if (!row || typeof row !== "object" || Array.isArray(row)) continue; if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>; const r = row as Record<string, unknown>;
const id = const id =
asString(r.rule_id) ?? asString(r.id) ?? asString(r.规则id); asString(r.规则名) ??
asString(r.rule_id) ??
asString(r.id) ??
asString(r.规则id);
if (id) return sanitizeRepeatableSlot(id) || undefined; if (id) return sanitizeRepeatableSlot(id) || undefined;
} }
} }
@@ -95,27 +249,12 @@ export function extractRepeatableSlotFromContent(
} }
if (base === "设计.具体实例" || /具体实例$/.test(base)) { if (base === "设计.具体实例" || /具体实例$/.test(base)) {
const batch = const named = concreteInstanceName(body);
asString(body.batch_id) ?? if (named) return sanitizeRepeatableSlot(named) || undefined;
asString(body.批次id) ?? const ruleId =
asString(body.id); asString(body.规则) ?? asString(body.规则名) ?? asString(body.rule_id);
if (batch) return sanitizeRepeatableSlot(batch) || undefined;
const records = body.records;
if (Array.isArray(records)) {
for (const row of records) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>;
const name =
asString(r.姓名) ??
asString(r.name) ??
asString(r.名称) ??
asString(r.id);
if (name) return sanitizeRepeatableSlot(name) || undefined;
}
}
const ruleId = asString(body.rule_id);
if (ruleId) { if (ruleId) {
return sanitizeRepeatableSlot(`${ruleId}-batch`) || undefined; return sanitizeRepeatableSlot(`${ruleId}-1`) || undefined;
} }
return undefined; return undefined;
} }
@@ -136,8 +275,9 @@ export function resolveRepeatableInsertTag(params: {
existingTags: readonly string[]; existingTags: readonly string[];
repeatableFamilies: ReadonlySet<string>; repeatableFamilies: ReadonlySet<string>;
}): ResolveRepeatableInsertResult { }): ResolveRepeatableInsertResult {
const raw = params.tag.trim(); const requested = params.tag.trim();
if (!raw) return { error: "position 为空" }; if (!requested) return { error: "position 为空" };
const raw = rewriteLegacyOpeningTag(requested);
const family = dictateProductFamily(raw); const family = dictateProductFamily(raw);
if (!params.repeatableFamilies.has(family)) { if (!params.repeatableFamilies.has(family)) {
@@ -146,29 +286,37 @@ export function resolveRepeatableInsertTag(params: {
const explicit = dictateProductSlot(raw); const explicit = dictateProductSlot(raw);
const extracted = extractRepeatableSlotFromContent(family, params.content); const extracted = extractRepeatableSlotFromContent(family, params.content);
const slot = explicit || extracted || ""; const openingSlot =
family === OPENING_SLOT_FAMILY && !explicit
? extractOpeningSlotFromContent(params.content)
: {};
if (openingSlot.error) return { error: openingSlot.error };
const slot = explicit || extracted || openingSlot.slot || "";
const siblings = params.existingTags const siblings = params.existingTags
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t && dictateProductFamily(t) === family); .filter((t) => t && dictateProductFamily(t) === family);
if (!slot) { if (!slot) {
if (family === OPENING_SLOT_FAMILY) {
return { error: OPENING_SLOT_REQUIRED };
}
// 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id) // 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id)
const otherSiblings = siblings.filter((t) => t !== family); const otherSiblings = siblings.filter((t) => t !== family);
if (otherSiblings.length > 0 && raw === family) { if (otherSiblings.length > 0 && raw === family) {
return { return {
error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用 rule_id,具体实例用 batch_id/姓名,开场白/主角设定用场景短码),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`, error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用规则名,具体实例用名称,开场白/主角设定用场景短码),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`,
}; };
} }
return { tag: family }; return { tag: family };
} }
const resolved = buildRepeatableProductTag(family, slot); const resolved = buildRepeatableProductTag(family, slot);
if (raw === family || raw !== resolved) { if (requested !== resolved) {
return { return {
tag: resolved, tag: resolved,
remapped: raw !== resolved, remapped: true,
note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined, note: `已写入 ${resolved}。之后改这一条用同一完整 tag。`,
}; };
} }
return { tag: resolved }; return { tag: resolved };
+721
View File
@@ -0,0 +1,721 @@
/**
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
*/
import { randomUUID } from "node:crypto";
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
import type { AgentDriver } from "../runtime/driver.js";
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
import {
CREATION_PLAN_SCHEMA,
CREATION_PLAN_TAG,
formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress,
parseCreationPlan,
} from "./creation-plan.js";
import { isDictateOpeningTag } from "./experience-anchor.js";
import {
buildDictateReplyPrompt,
buildDictateSelectPrompt,
buildDictateSystemPrompt,
formatDictateRoundTail,
type DictatePromptTailOpts,
} from "./prompt.js";
import {
parseDictateModuleReadState,
readLibraryEntries,
serializeDictateModuleReadState,
DICTATE_MODULE_READ_STATE_TAG,
type DictateModuleReadPayload,
} from "./read-module.js";
import { resolveModuleForInsertTag } from "./insert-feedback.js";
import type { DictateChatTurn, DictateProduct } from "./types.js";
import { isAllowedProductTag } from "./types.js";
import { VALUE_MAP_TAG } from "../skills/value-map.js";
import { VARIABLE_CATALOG_TAG } from "../skills/variable-catalog.js";
const READ_EXEMPT = new Set([
"narrative",
"variable-context",
"context-order",
"worker-spec",
"refine",
]);
type Apply = (calls: DriverToolCall[]) => DriverStepOutcome | Promise<DriverStepOutcome>;
export type PlannedRoundParams = {
llm: LlmProvider;
dialogue: DictateChatTurn[];
handlers: {
listProducts: () => DictateProduct[];
readTag: (tag: string) => string | undefined;
writeTag: (tag: string, content: string) => void;
prepareOpening?: () => void;
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
lookupInsertFeedback?: (tag: string) => string | undefined;
onThinkingDelta?: (delta: string) => void;
onThinkingDone?: (text: string) => void;
};
moduleCatalog?: import("../skills/creation-flow.js").ModuleCatalog | null;
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
moduleGuide?: string;
recipeExamples?: string;
recipeName?: string;
recipeBrief?: string;
systemPrompt?: string;
};
type PlanInsert = {
tag: string;
moduleId: string;
libraryId?: string;
entryIds: string[];
reason?: string;
};
type SelectPlan = {
clarify: string[];
inserts: PlanInsert[];
deletes: string[];
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
keys: string[];
};
type ItemFailure = { tag: string; error: string };
const INSERT_ATTEMPTS = 4;
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
return {
async run(input) {
const reply = await runPlannedDictateRound(params, input.handleStep);
return { iterations: reply.modelCalls, stop: { kind: "text", content: reply.text } };
},
};
}
async function runPlannedDictateRound(
params: PlannedRoundParams,
apply: Apply,
): Promise<{ text: string; modelCalls: number }> {
const turnId = randomUUID();
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
const tailOpts = tailState(params);
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
const selectCtx = buildDictateContext({
systemPrompt: buildDictateSelectPrompt(),
products: params.handlers.listProducts(),
dialogue: params.dialogue,
phase: "select",
tail: selectTail,
});
const selected = await params.llm.completeWithTools(selectCtx.messages, {
caller: "dictate_agent",
label: "对话落盘 选题",
tools: [DICTATE_SELECT_TOOL],
toolChoice: { type: "function", function: { name: "select_nodes" } },
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
usageSink: sinkFor(turnId, "select"),
});
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
return {
modelCalls: 1,
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
};
}
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
return {
modelCalls: 1,
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
};
}
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
const inserts = plan.inserts.filter((item) => !item.tag.startsWith("!"));
const openings = inserts.filter((item) => isDictateOpeningTag(item.tag));
const rest = inserts.filter((item) => !isDictateOpeningTag(item.tag));
const blockedLines = blocked.map(
(item) => `${item.tag.slice(1)}(${item.reason || "计划未通过程序核验"})`,
);
if (openings.length > 0) params.handlers.prepareOpening?.();
const injected = injectGroup(params, [...rest, ...openings]);
const tail = formatDictateRoundTail({
...tailOpts,
phase: "fill",
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
});
const ctx = buildDictateContext({
systemPrompt: fillSystem,
products: params.handlers.listProducts(),
dialogue: params.dialogue,
phase: "fill",
injected: injected.text,
tail,
});
const messages: ChatMessage[] = [...ctx.messages];
const passed = new Set<string>();
const pending = new Set([...rest, ...openings].map((item) => item.tag));
const pendingDeletes = new Set(plan.deletes);
const errors = new Map<string, string>();
let replyFromTool = "";
let lastProse = "";
let modelCalls = 1;
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
const step = attempt === 0 ? "fill" : "fill-retry";
const filled = await params.llm.completeWithTools(messages, {
caller: "dictate_agent",
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
tools: DICTATE_WRITE_LOOP_TOOLS,
toolChoice: "required",
usageDetail: {
...detail(turnId, step, attempt, ctx.segmentChars),
plannedTags: [...pending, ...pendingDeletes],
},
usageSink: sinkFor(turnId, step),
});
modelCalls += 1;
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const prose = filled.content?.trim() ?? "";
if (prose) lastProse = prose;
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
id: call.id || `call-${attempt}-${index}`,
name: call.name,
arguments: call.arguments,
}));
if (calls.length === 0) break;
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
messages.push(assistantToolMessage(filled.content, calls));
for (const item of settled.results) {
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
}
if (pending.size === 0 && pendingDeletes.size === 0) {
replyFromTool = settled.reply;
break;
}
}
let reply = replyFromTool;
if (
!replyFromTool &&
pending.size === 0 &&
pendingDeletes.size === 0 &&
passed.size > 0
) {
reply = await runLeanReply(params, turnId, passed, plan.deletes, lastProse);
modelCalls += 1;
} else if (!reply) {
reply = lastProse;
}
const failed = [...pending, ...pendingDeletes].map(
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
);
const pendingLines = [...blockedLines, ...failed];
const text = pendingLines.length
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
: reply;
return { text, modelCalls };
}
async function runLeanReply(
params: PlannedRoundParams,
turnId: string,
passed: Set<string>,
plannedDeletes: string[],
fallbackProse: string,
): Promise<string> {
const writtenTags = [...passed].filter((tag) => !tag.startsWith("del:"));
const deletedTags = [...passed]
.filter((tag) => tag.startsWith("del:"))
.map((tag) => tag.slice(4))
.filter(Boolean);
const products = params.handlers
.listProducts()
.filter((product) => writtenTags.some((tag) => product.tag === tag || product.tag.startsWith(`${tag}#`)));
const feedbackBlocks = writtenTags
.map((tag) => params.handlers.lookupInsertFeedback?.(tag)?.trim())
.filter((text): text is string => Boolean(text));
if (writtenTags.length > 1 && feedbackBlocks.length > 1) {
feedbackBlocks.unshift(
"本轮写入了多个产物。用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。",
);
}
const tailOpts = tailState(params);
const deleteNote =
deletedTags.length || plannedDeletes.length
? `本轮已删除:${(deletedTags.length ? deletedTags : plannedDeletes).join("、")}`
: "";
const replyTail = [
formatDictateRoundTail({
...tailOpts,
phase: "reply",
checklist: [
writtenTags.length ? `本轮已写入:${writtenTags.join("、")}` : "",
deleteNote,
]
.filter(Boolean)
.join("\n"),
}),
feedbackBlocks.length ? `## 本轮关注(按能力回复口径)\n\n${feedbackBlocks.join("\n\n")}` : "",
]
.filter(Boolean)
.join("\n\n");
const replyCtx = buildDictateContext({
systemPrompt: buildDictateReplyPrompt(),
products,
dialogue: params.dialogue,
phase: "reply",
tail: replyTail,
});
const filled = await params.llm.completeWithTools(replyCtx.messages, {
caller: "dictate_agent",
label: "对话落盘 回复",
tools: [DICTATE_REPLY_TOOL],
toolChoice: { type: "function", function: { name: DICTATE_REPLY_TOOL.function.name } },
usageDetail: {
...detail(turnId, "reply", 0, replyCtx.segmentChars),
plannedTags: writtenTags,
},
usageSink: sinkFor(turnId, "reply"),
});
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const call = filled.toolCalls.find((item) => item.name === DICTATE_REPLY_TOOL.function.name);
if (call) {
const text = replyText({
id: call.id || "reply",
name: call.name,
arguments: call.arguments,
});
if (text) return text;
}
return filled.content?.trim() || fallbackProse;
}
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
return {
role: "assistant",
content,
tool_calls: calls.map((call) => ({
id: call.id,
type: "function" as const,
function: { name: call.name, arguments: call.arguments },
})),
};
}
async function settleWriteStep(
params: PlannedRoundParams,
apply: Apply,
calls: DriverToolCall[],
passed: Set<string>,
pending: Set<string>,
pendingDeletes: Set<string>,
errors: Map<string, string>,
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
const results: Array<{ callId: string; content: string }> = [];
const forward: DriverToolCall[] = [];
let reply = "";
for (const call of calls) {
if (call.name === DICTATE_REPLY_TOOL.function.name) continue;
const tag = callTag(call, params);
if (call.name === "insert" && tag && passed.has(tag)) {
results.push({
callId: call.id,
content: JSON.stringify({ error: "已通过,本步不要再写", tag }),
});
continue;
}
if (call.name === "delete" && tag && passed.has(`del:${tag}`)) {
results.push({
callId: call.id,
content: JSON.stringify({ error: "已通过,本步不要再删除", tag }),
});
continue;
}
if (call.name === "insert" && tag) {
const args = asRecord(safeJson(call.arguments));
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
continue;
}
forward.push(call);
}
const outcome = await apply(forward);
if (outcome.kind === "continue") {
for (const result of outcome.results) {
results.push(result);
const call = forward.find((item) => item.id === result.callId);
const payload = asRecord(safeJson(result.content));
const tag = call ? callTag(call, params) : "";
if (payload.ok === true && tag) {
if (call?.name === "delete") {
passed.add(`del:${tag}`);
pendingDeletes.delete(tag);
} else {
passed.add(tag);
for (const planned of [...pending]) {
if (planned === tag || tag.startsWith(`${planned}#`)) pending.delete(planned);
}
}
errors.delete(tag);
} else if (typeof payload.error === "string" && tag) {
const planned = [...pending, ...pendingDeletes].find(
(item) => item === tag || tag.startsWith(`${item}#`),
);
errors.set(planned ?? tag, payload.error);
}
}
}
for (const call of calls) {
if (call.name !== DICTATE_REPLY_TOOL.function.name) continue;
const text = replyText(call);
if (pending.size > 0 || pendingDeletes.size > 0) {
const left = [...pending, ...pendingDeletes].join("、");
results.push({
callId: call.id,
content: JSON.stringify({
error: `还有未通过:${left}。这份回复先不给用户。只再写入这些。`,
}),
});
continue;
}
reply = text;
results.push({ callId: call.id, content: JSON.stringify({ ok: true }) });
}
return { results, reply };
}
function callTag(call: DriverToolCall, params: PlannedRoundParams): string {
const args = asRecord(safeJson(call.arguments));
const raw = String(args.position ?? args.tag ?? "").trim();
if (!raw) return "";
return canonicalProductTag(raw, "", params.moduleCatalog)?.tag ?? raw;
}
function replyText(call: DriverToolCall): string {
const args = asRecord(safeJson(call.arguments));
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
}
function sinkFor(_turnId: string, _step: string): UsageSink {
return {};
}
function detail(
turnId: string,
step: UsageDetail["step"],
retryIndex: number,
segmentChars: DictateSegmentChars,
): UsageDetail {
return { turnId, step, retryIndex, segmentChars };
}
function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
const products = params.handlers.listProducts();
const plan = parseCreationPlan(
products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
);
if (plan) {
return {
creationPlanState: formatCreationPlanProgress({
plan,
catalog: params.moduleCatalog,
products,
}),
};
}
return {
capabilityCatalog:
formatCreationPlanCapabilityCatalog(params.moduleCatalog) || params.moduleGuide,
recipeExamples: params.recipeExamples,
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
moduleGuide: params.moduleGuide,
};
}
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
const row = asRecord(raw);
const clarify = stringList(row.clarify);
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
const inserts: PlanInsert[] = [];
let items = Array.isArray(row.nodes)
? row.nodes
: Array.isArray(row.inserts)
? row.inserts
: [];
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
const recovered = recoverSelectWork(row);
items = recovered.items;
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
}
for (const item of items) {
const rec = asRecord(item);
const moduleIdHint = String(rec.module_id ?? rec.moduleId ?? "").trim();
let tag = String(rec.tag ?? rec.position ?? "").trim();
if (!tag && moduleIdHint) {
tag =
params.moduleCatalog?.modules.find((module) => module.id === moduleIdHint)?.artifact?.trim() ??
"";
}
if (!tag) continue;
const canonical = canonicalProductTag(tag, moduleIdHint, params.moduleCatalog);
if (canonical) tag = canonical.tag;
const resolvedHint = canonical?.moduleId || moduleIdHint;
const module =
params.moduleReadIndex?.get(resolvedHint) ??
params.moduleReadIndex?.get(tag) ??
undefined;
const fromTag = resolveModuleForInsertTag(tag, params.moduleCatalog);
const moduleId = module?.module_id || canonical?.moduleId || fromTag?.id || moduleIdHint;
const entryIds = stringList(rec.entry_ids);
const reason = String(rec.reason ?? "").trim();
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
inserts.push({ tag: `!${tag || "空"}`, moduleId, entryIds, reason: "产物 tag 不合法" });
continue;
}
const payload = moduleId ? params.moduleReadIndex?.get(moduleId) : undefined;
if (payload && !READ_EXEMPT.has(payload.module_id) && payload.libraries.length) {
const libraryId = String(rec.library_id ?? payload.libraries[0]?.library_id ?? "").trim();
if (entryIds.length > 2 && !reason) {
inserts.push({
tag: `!${tag}`,
moduleId,
entryIds,
reason: "默认最多两个库条目",
});
continue;
}
if (libraryId && entryIds.length) {
const bound = payload.libraries.some((library) => library.library_id === libraryId);
if (!bound) {
inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: `能力未绑定库 ${libraryId}` });
continue;
}
}
}
inserts.push({
tag,
moduleId,
libraryId: String(rec.library_id ?? "").trim() || undefined,
entryIds,
reason: reason || undefined,
});
}
return { clarify, inserts, deletes, keys: Object.keys(row) };
}
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
function recoverSelectWork(row: Record<string, unknown>): {
items: unknown[];
deletes: string[];
} {
if (isCreationPlanShape(row)) {
return { items: [{ tag: CREATION_PLAN_TAG, module_id: "creation-plan" }], deletes: [] };
}
const items: unknown[] = [];
const deletes: string[] = [];
for (const list of [row.modules, row.selections, row.selected, row.writes, row.products, row.operations]) {
for (const item of asItemList(list)) {
const rec = flattenItem(item);
if (
typeof rec.tier === "string" &&
typeof rec.why === "string" &&
rec.tag == null &&
rec.position == null
) {
continue;
}
const op = operationName(rec);
if (op === "read_module" || op === "read_library_entry" || op === "prepare_opening") {
continue;
}
const moduleId = String(rec.module_id ?? rec.moduleId ?? "").trim();
const tag = String(rec.tag ?? rec.position ?? rec.artifact ?? "").trim();
if (op === "delete" || op === "remove") {
if (tag) deletes.push(tag);
continue;
}
if (
op === "declare_variable" ||
op === "undeclare_variable" ||
op === "declare_map" ||
op === "remove_map"
) {
continue;
}
if (!tag && !moduleId) continue;
items.push({
tag,
module_id: moduleId,
...(rec.library_id != null ? { library_id: rec.library_id } : {}),
...(rec.entry_ids != null ? { entry_ids: rec.entry_ids } : {}),
...(rec.reason != null ? { reason: rec.reason } : {}),
});
}
}
return { items, deletes };
}
function isCreationPlanShape(row: Record<string, unknown>): boolean {
if (row.schema === CREATION_PLAN_SCHEMA) return true;
if (!Array.isArray(row.items) || row.items.length === 0) return false;
return row.items.every((item) => {
const rec = asRecord(item);
return (
typeof rec.module_id === "string" &&
rec.module_id.trim().length > 0 &&
typeof rec.tier === "string" &&
rec.tag == null &&
rec.position == null
);
});
}
function injectGroup(
params: PlannedRoundParams,
group: PlanInsert[],
): { text: string } {
const blocks: string[] = [];
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
let changed = false;
for (const item of group) {
const payload = item.moduleId ? params.moduleReadIndex?.get(item.moduleId) : undefined;
if (!payload) continue;
const catalogEntry = resolveModuleForInsertTag(item.tag, params.moduleCatalog);
if (!READ_EXEMPT.has(payload.module_id) && !catalogEntry?.auto) {
state.modules[payload.module_id] = payload.version;
state.active_module_id = payload.module_id;
changed = true;
}
const parts = [
`### ${payload.module_id} @ ${payload.version}`,
`产物 tag:${item.tag}`,
payload.score_dimensions.length
? `self_score 维度(这次写入必填,name 必须一致):${payload.score_dimensions.join("、")}`
: "",
`#### task\n${payload.task}`,
`#### principles\n${payload.principles}`,
`#### output\n${payload.output}`,
`#### score\n${payload.score}`,
];
if (item.libraryId && item.entryIds.length) {
const loaded = readLibraryEntries({
module: payload,
libraryId: item.libraryId,
entryIds: item.entryIds,
reason: item.reason,
});
if (loaded.entries) {
for (const entry of loaded.entries) {
const row = entry as { id?: string; content?: string; name?: string };
parts.push(`#### 库 ${item.libraryId} / ${row.id ?? ""}\n${row.content ?? ""}`);
}
} else if (loaded.error) {
parts.push(`#### 库读取失败\n${loaded.error}`);
}
}
blocks.push(parts.filter(Boolean).join("\n\n"));
}
if (changed) {
params.handlers.writeTag(DICTATE_MODULE_READ_STATE_TAG, serializeDictateModuleReadState(state));
}
return { text: blocks.join("\n\n") };
}
function canonicalProductTag(
tag: string,
moduleIdHint: string,
catalog: PlannedRoundParams["moduleCatalog"],
): { tag: string; moduleId: string } | null {
const bare = tag.replace(/^(?:用户|设计)\./, "").split("#")[0]?.trim() ?? "";
const entry =
catalog?.modules.find((module) => module.id === moduleIdHint) ??
resolveModuleForInsertTag(tag, catalog) ??
catalog?.modules.find(
(module) =>
module.name === bare ||
module.id === bare ||
module.artifact === tag ||
module.artifact.endsWith(`.${bare}`),
);
if (!entry?.artifact?.trim()) return null;
const hash = tag.indexOf("#");
const suffix = hash > 0 ? tag.slice(hash) : "";
const next =
entry.repeatable && suffix.startsWith("#") && suffix.length > 1
? `${entry.artifact.trim()}${suffix}`
: entry.artifact.trim();
return { tag: next, moduleId: entry.id };
}
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
const lines = [
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
errors.length
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
: "",
];
return lines.filter(Boolean).join("\n");
}
function asItemList(value: unknown): unknown[] {
if (Array.isArray(value)) return value;
const rec = asRecord(value);
const values = Object.values(rec);
if (values.length > 0 && values.every((item) => item && typeof item === "object")) {
return values;
}
return [];
}
function flattenItem(item: unknown): Record<string, unknown> {
if (typeof item === "string") {
const text = item.trim();
if (!text) return {};
if (text.startsWith("设计.") || text.startsWith("用户.")) return { tag: text };
return { module_id: text };
}
const rec = asRecord(item);
const nested = asRecord(rec.arguments ?? rec.args ?? rec.payload);
return { ...rec, ...nested };
}
function operationName(rec: Record<string, unknown>): string {
return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim();
}
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
+159 -3
View File
@@ -3,7 +3,131 @@ import type { ToolDefinition } from "../llm/client.js";
/** /**
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。 * 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
*/ */
/** 第一次请求仅此一个工具:点出下一步要写的节点。 */
export const DICTATE_SELECT_TOOL: ToolDefinition = {
type: "function",
function: {
name: "select_nodes",
description:
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
nodes: {
type: "array",
description: "这段用户话要填充的节点。",
items: {
type: "object",
additionalProperties: false,
properties: {
module_id: { type: "string", description: "能力目录中的 id" },
},
required: ["module_id"],
},
},
},
required: ["nodes"],
},
},
};
/** 写入循环的收尾。未通过的节点还在时,程序不把这份正文给用户。 */
export const DICTATE_REPLY_TOOL: ToolDefinition = {
type: "function",
function: {
name: "reply_user",
description:
"本步必调。与 insert 放在同一次请求:节点表写完后立刻提交给用户的综合回复。正文按系统提示「用户可见回复」写。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
text: {
type: "string",
description:
"综合回复。先回应原话并说明形成了什么,再写自评最稳处;每个分数一般的缺口含短例子、一句体验变化和 2~3 个可接着说的选项;接着写下一步可以提及哪些,并补一个相关设定。多个产物合成一段。",
},
},
required: ["text"],
},
},
};
const WRITE_LOOP_TOOL_NAMES = new Set([
"insert",
"delete",
"declare_variable",
"undeclare_variable",
"declare_map",
"remove_map",
]);
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
{
type: "function",
function: {
name: "read_module",
description:
"读取一个内容能力当前版本的任务、原则、产物规格、评分规则、绑定库目录与程序约束。写该能力产物前必须先读取当前版本。",
parameters: {
type: "object",
properties: {
module_id: {
type: "string",
description: "能力 id 或中文名",
},
reason: {
type: "string",
description: "本轮为何读取(一句话,可选)",
},
},
required: ["module_id"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "read_library_entry",
description:
"读取当前能力绑定库中本轮选中的条目。默认 1~2;叙事指南等多文风确需第三项时须提供 reason 并说明适用场合。",
parameters: {
type: "object",
properties: {
library_id: {
type: "string",
description: "当前能力绑定的库 id",
},
entry_ids: {
type: "array",
items: { type: "string" },
description: "本轮选中的条目 id,默认最多两个",
},
reason: {
type: "string",
description: "读取超过两个条目时必填",
},
},
required: ["library_id", "entry_ids"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "prepare_opening",
description:
"用户该填的内容均已完成后、生成开场白之前单独调用。程序依次编译信息可见范围、游玩执行方式、上下文排序和运行配置;返回成功后再在下一步生成并 insert 开场白。",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{ {
type: "function", type: "function",
function: { function: {
@@ -16,17 +140,43 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
position: { position: {
type: "string", type: "string",
description: description:
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白、设计.开场白#dorm;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。勿用本工具写变量目录/映射。", "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白#opening-cycle2;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。开场必须带场景短码,不要写裸「设计.开场白」或「设计.开场白与开场变量」。勿用本工具写变量目录/映射。",
}, },
content: { content: {
type: "string", type: "string",
description: description:
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白[#短码]=叙事正文(有正文组成时为 present.v1 JSON;否则 Markdown),必须含字面 @玩家,禁止写入用户角色.名字/简介;设计.主角设定[#同短码]=名字/背景/特殊设定 JSON,与开场同短码即绑定该开局", "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白#场景短码=这一张开场的 present.v1,blocks 必须含正文组成数据契约里的全部模型生成区(用来灌进已固定前端并替换示例),不要只写 shell+body,也不要填程序投影区;必须含字面 @玩家,名字不进 blocks。这条开场的玩家角色写在 meta.用户角色(名字+一到三句客观事实),程序同步为同短码主角设定。这张开场有初值才在同一份正文里写开场变量,没有就不写。设计.主角设定=共通的名字/背景/特殊设定 JSON;各开场不同的玩家角色写进开场 meta,不另写主角设定#短码",
}, },
order: { order: {
type: "number", type: "number",
description: description:
"相对顺序(可负)。越小越靠前。越常改越大。省略则沿用原序或默认。", "同一变化频率组内的重要性顺序(可负)。基础材料用较小值,越接近生成时使用的合同越大;省略则沿用原序或默认。",
},
self_score: {
type: "object",
description:
"能力已有 score 规则时必填;维度名须完全一致,1~5 分,分数不高于 3 时必须写 gap。",
properties: {
dims: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
score: { type: "number" },
gap: { type: "string" },
options: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "score"],
additionalProperties: false,
},
},
},
required: ["dims"],
additionalProperties: false,
}, },
}, },
required: ["position", "content"], required: ["position", "content"],
@@ -193,3 +343,9 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
}, },
}, },
]; ];
/** 第二次起的工具。不含 read_module:写法已由程序注入。同一轮重试不改变这份清单。 */
export const DICTATE_WRITE_LOOP_TOOLS: ToolDefinition[] = [
...DICTATE_TOOL_DEFINITIONS.filter((tool) => WRITE_LOOP_TOOL_NAMES.has(tool.function.name)),
DICTATE_REPLY_TOOL,
];
+701 -46
View File
@@ -1,27 +1,54 @@
import type { LlmProvider } from "../llm/client.js"; import type { LlmProvider } from "../llm/client.js";
import { import {
createLocalLlmDriver, DriverIterationLimitError,
type AgentDriver, type AgentDriver,
} from "../runtime/driver.js"; } from "../runtime/driver.js";
import { createPlannedDictateDriver } from "./round.js";
import { buildDictateMessages } from "./context.js"; import { buildDictateMessages } from "./context.js";
import { buildDictateSystemPrompt } from "./prompt.js"; import { buildDictateSystemPrompt } from "./prompt.js";
import { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; import { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
import { import {
isAllowedProductTag, isAllowedProductTag,
parseDictateOrder, parseDictateOrder,
type DictateSelfScore,
type DictateChatTurn, type DictateChatTurn,
type DictateProduct, type DictateProduct,
} from "./types.js"; } from "./types.js";
import { DICTATE_MULTI_MODULE_REPLY_HINT } from "./insert-feedback.js"; import {
import { resolveRepeatableInsertTag } from "./repeatable-tags.js"; DICTATE_MULTI_MODULE_REPLY_HINT,
resolveModuleForInsertTag,
} from "./insert-feedback.js";
import { dictateProductFamily, resolveRepeatableInsertTag, splitConcreteInstanceContents } from "./repeatable-tags.js";
import type { ModuleCatalog } from "../skills/creation-flow.js";
import {
CREATION_PLAN_TAG,
formatCreationPlanCapabilityCatalog,
formatCreationPlanProgress,
parseCreationPlan,
requiredCreationPlanGaps,
validateCreationPlan,
} from "./creation-plan.js";
import {
DICTATE_MODULE_READ_STATE_TAG,
parseDictateModuleReadState,
readLibraryEntries,
serializeDictateModuleReadState,
type DictateModuleReadPayload,
} from "./read-module.js";
import { import {
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
parseVariableCatalog, parseVariableCatalog,
serializeVariableCatalog, serializeVariableCatalog,
upsertVariableField, upsertVariableField,
removeVariableField, removeVariableField,
seedVariablesFromCatalog,
type UpsertVariableInput, type UpsertVariableInput,
} from "../skills/variable-catalog.js"; } from "../skills/variable-catalog.js";
import {
VARIABLE_DESIGN_TAG,
syncVariableDesignToRuntime,
writeVariableDesignSync,
} from "../skills/variable-design-compile.js";
import { import {
VALUE_MAP_TAG, VALUE_MAP_TAG,
parseValueMapDoc, parseValueMapDoc,
@@ -34,11 +61,58 @@ import {
parseTableDoc, parseTableDoc,
stringifyTableDoc, stringifyTableDoc,
} from "../blackboard/table-cells.js"; } from "../blackboard/table-cells.js";
import { isDictateOpeningTag } from "./experience-anchor.js";
import { canonicalizeInsertedContent } from "../parse/json-doc.js";
import {
openingContractError,
parseReplyDataContract,
replyContractSummary,
type ReplyDataContract,
} from "../skills/reply-contract.js";
function seedCurrentFromCatalog(handlers: DictateTurnHandlers): string[] {
const seeded = seedVariablesFromCatalog({
catalogRaw: handlers.readTag(VARIABLE_CATALOG_TAG),
currentRaw: handlers.readTag("变量.当前"),
initialRaw: handlers.readTag("运行.初始变量"),
actor: "system:declare_variable",
});
const wrote: string[] = [];
if (seeded.initial) {
handlers.writeTag("运行.初始变量", seeded.initial);
wrote.push("运行.初始变量");
}
if (seeded.current) {
handlers.writeTag("变量.当前", seeded.current);
wrote.push("变量.当前");
}
return wrote;
}
function syncDesignProductToTable(
handlers: DictateTurnHandlers,
designContent?: string,
): string[] {
const sync = syncVariableDesignToRuntime({
designRaw: designContent ?? handlers.readTag(VARIABLE_DESIGN_TAG),
catalogRaw: handlers.readTag(VARIABLE_CATALOG_TAG),
currentRaw: handlers.readTag("变量.当前"),
initialRaw: handlers.readTag("运行.初始变量"),
});
return writeVariableDesignSync(sync, (tag, content) =>
handlers.writeTag(tag, content),
);
}
export type DictateTurnHandlers = { export type DictateTurnHandlers = {
listProducts: () => DictateProduct[]; listProducts: () => DictateProduct[];
/** order 省略时由实现沿用原序或默认 */ /** order 省略时由实现沿用原序或默认 */
writeProduct: (tag: string, content: string, order?: number) => void; writeProduct: (
tag: string,
content: string,
order?: number,
metadata?: { layer?: "intermediate" | "final"; selfScore?: DictateSelfScore },
) => void;
/** 删除固定产物 tag;返回是否曾存在 */ /** 删除固定产物 tag;返回是否曾存在 */
deleteProduct: (tag: string) => boolean; deleteProduct: (tag: string) => boolean;
/** 读/写变量目录与映射(程序校验后的 JSON) */ /** 读/写变量目录与映射(程序校验后的 JSON) */
@@ -46,6 +120,8 @@ export type DictateTurnHandlers = {
writeTag: (tag: string, content: string) => void; writeTag: (tag: string, content: string) => void;
/** 删除任意黑板 tag(投影 / 变量.当前 等) */ /** 删除任意黑板 tag(投影 / 变量.当前 等) */
deleteTag: (tag: string) => boolean; deleteTag: (tag: string) => boolean;
/** 写入开场白前运行确定性收口工具,保证运行配置先于开场落盘。 */
prepareOpening?: () => void;
clearDialogue: () => void; clearDialogue: () => void;
onToolCall?: (name: string, detail: string) => void; onToolCall?: (name: string, detail: string) => void;
onThinkingDelta?: (delta: string) => void; onThinkingDelta?: (delta: string) => void;
@@ -65,6 +141,16 @@ export type DictateTurnResult = {
clearedDialogue: boolean; clearedDialogue: boolean;
}; };
/**
* 一轮对话落盘的工具上限。
* 串行模型往往一轮只调一个工具:读能力再写入,方案里十余项,再加上收尾回复。
*/
export const DICTATE_DRIVER_MAX_ITERATIONS = 36;
/** 接近上限时写进工具结果,让模型停下来回复用户。 */
export const DICTATE_TURN_LIMIT_HINT =
"本轮工具次数已接近上限。停止调用工具,直接回复用户。已写入的保留;未写的能力下一轮继续。";
/** /**
* 跑一轮对话落盘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。 * 跑一轮对话落盘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。
*/ */
@@ -77,14 +163,42 @@ export async function runDictateTurn(params: {
recipeBrief?: string; recipeBrief?: string;
/** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */ /** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */
moduleGuide?: string; moduleGuide?: string;
/** 尚无本局方案时显示的旧配方案例目录 */
recipeExamples?: string;
/** 用于本局方案覆盖校验及方案态能力状态 */
moduleCatalog?: ModuleCatalog | null;
/** 当前能力提示的可读内容与版本;存在时启用写前读取门禁 */
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */ /** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
repeatableFamilies?: ReadonlySet<string> | readonly string[]; repeatableFamilies?: ReadonlySet<string> | readonly string[];
driver?: AgentDriver; driver?: AgentDriver;
}): Promise<DictateTurnResult> { }): Promise<DictateTurnResult> {
const driver = params.driver ?? createLocalLlmDriver(params.llm); const driver =
params.driver ??
createPlannedDictateDriver({
llm: params.llm,
dialogue: params.dialogue,
handlers: {
listProducts: params.handlers.listProducts,
readTag: params.handlers.readTag,
writeTag: params.handlers.writeTag,
prepareOpening: params.handlers.prepareOpening,
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
onThinkingDelta: params.handlers.onThinkingDelta,
onThinkingDone: params.handlers.onThinkingDone,
},
moduleCatalog: params.moduleCatalog,
moduleReadIndex: params.moduleReadIndex,
moduleGuide: params.moduleGuide,
recipeExamples: params.recipeExamples,
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
systemPrompt: params.systemPrompt,
});
const wroteTags: string[] = []; const wroteTags: string[] = [];
const deletedTags: string[] = []; const deletedTags: string[] = [];
let clearedDialogue = false; let clearedDialogue = false;
let openingPrepared = false;
let dialogue = [...params.dialogue]; let dialogue = [...params.dialogue];
const repeatableFamilies = new Set( const repeatableFamilies = new Set(
[...(params.repeatableFamilies ?? [])].map((t) => String(t).trim()).filter(Boolean), [...(params.repeatableFamilies ?? [])].map((t) => String(t).trim()).filter(Boolean),
@@ -92,33 +206,168 @@ export async function runDictateTurn(params: {
const systemPrompt = const systemPrompt =
params.systemPrompt ?? params.systemPrompt ??
buildDictateSystemPrompt({ (() => {
recipeName: params.recipeName, const products = params.handlers.listProducts();
recipeBrief: params.recipeBrief, const plan = parseCreationPlan(
moduleGuide: params.moduleGuide, products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
}); );
return buildDictateSystemPrompt({
...(plan
? {
creationPlanState: formatCreationPlanProgress({
plan,
catalog: params.moduleCatalog,
products,
}),
}
: {
capabilityCatalog:
formatCreationPlanCapabilityCatalog(params.moduleCatalog) ||
params.moduleGuide,
recipeExamples: params.recipeExamples,
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
}),
});
})();
const assembled = buildDictateMessages({ const assembled = buildDictateMessages({
systemPrompt, systemPrompt,
products: params.handlers.listProducts(), products: params.handlers.listProducts(),
dialogue, dialogue,
}); });
const userBlock = assembled.filter((m) => m.role !== "system"); const userBlock = assembled.filter((m) => m.role !== "system");
let toolSteps = 0;
const run = await driver.run({ let run;
try {
run = await driver.run({
system: systemPrompt, system: systemPrompt,
messages: userBlock, messages: userBlock,
tools: DICTATE_TOOL_DEFINITIONS, tools: DICTATE_TOOL_DEFINITIONS,
caller: "dictate_agent", caller: "dictate_agent",
label: "对话落盘", label: "对话落盘",
maxIterations: DICTATE_DRIVER_MAX_ITERATIONS,
onThinkingDelta: params.handlers.onThinkingDelta, onThinkingDelta: params.handlers.onThinkingDelta,
onThinkingDone: params.handlers.onThinkingDone, onThinkingDone: params.handlers.onThinkingDone,
handleStep: (calls) => { handleStep: (calls) => {
toolSteps += 1;
const moduleNamesThisStep: string[] = []; const moduleNamesThisStep: string[] = [];
const results = calls.map((call) => { const results = calls.map((call) => {
const args = safeParseArgs(call.arguments); const args = safeParseArgs(call.arguments);
if (
call.name === "declare_variable" ||
call.name === "declare_map" ||
call.name === "delete" ||
call.name === "undeclare_variable" ||
call.name === "remove_map"
) {
openingPrepared = false;
}
if (call.name === "read_module") {
const ref = String(args.module_id ?? "").trim();
const module = params.moduleReadIndex?.get(ref);
if (!module) {
const err = `未找到能力:${ref || "空"}`;
params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: err }) };
}
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
state.modules[module.module_id] = module.version;
state.active_module_id = module.module_id;
params.handlers.writeTag(
DICTATE_MODULE_READ_STATE_TAG,
serializeDictateModuleReadState(state),
);
params.handlers.onToolCall?.(
call.name,
`${module.module_id}@${module.version}`,
);
const replyContract =
module.module_id === "opening-setup" ||
module.module_id === "zero-layer-opening-setup"
? contractForOpeningTag(
module.module_id === "zero-layer-opening-setup"
? "设计.0层开场白与初态"
: "设计.开场白",
params.handlers.listProducts(),
)
: undefined;
return {
callId: call.id,
content: JSON.stringify({
ok: true,
...module,
...(replyContract
? { reply_contract: replyContractSummary(replyContract) }
: module.module_id === "opening-setup" ||
module.module_id === "zero-layer-opening-setup"
? {
reply_contract_missing:
"还没有可执行的正文组成数据契约。先写正文组成,再写开场。",
}
: {}),
}),
};
}
if (call.name === "read_library_entry") {
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
const module = state.active_module_id
? params.moduleReadIndex?.get(state.active_module_id)
: undefined;
if (!module) {
const err = "请先 read_module 读取要使用该库的能力";
params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: err }) };
}
const result = readLibraryEntries({
module,
libraryId: String(args.library_id ?? ""),
entryIds: Array.isArray(args.entry_ids)
? args.entry_ids.map(String)
: [],
reason: typeof args.reason === "string" ? args.reason : undefined,
});
params.handlers.onToolCall?.(
call.name,
result.error ?? `${args.library_id}: ${result.entries?.length ?? 0}`,
);
return {
callId: call.id,
content: JSON.stringify(
result.error
? { error: result.error }
: {
ok: true,
module_id: module.module_id,
library_id: String(args.library_id ?? ""),
entries: result.entries,
},
),
};
}
if (call.name === "prepare_opening") {
params.handlers.prepareOpening?.();
openingPrepared = true;
params.handlers.onToolCall?.(
call.name,
"信息可见范围 → 游玩执行方式 → 上下文排序 → 运行配置组装",
);
return {
callId: call.id,
content: JSON.stringify({
ok: true,
runtime_prepared: true,
next: "现在生成并 insert 开场白",
}),
};
}
if (call.name === "insert" || call.name === "write_product") { if (call.name === "insert" || call.name === "write_product") {
let tag = String(args.position ?? args.tag ?? "").trim(); let tag = String(args.position ?? args.tag ?? "").trim();
const content = String(args.content ?? ""); const content = canonicalizeInsertedContent(String(args.content ?? ""));
if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) { if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) {
const err = `请用 declare_variable / declare_map,不要 insert ${tag}`; const err = `请用 declare_variable / declare_map,不要 insert ${tag}`;
params.handlers.onToolCall?.(call.name, err); params.handlers.onToolCall?.(call.name, err);
@@ -134,24 +383,221 @@ export async function runDictateTurn(params: {
params.handlers.onToolCall?.(call.name, err); params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: err }) }; return { callId: call.id, content: JSON.stringify({ error: err }) };
} }
if (repeatableFamilies.size) { const targetModule = resolveModuleForInsertTag(
const resolved = resolveRepeatableInsertTag({ tag,
tag, params.moduleCatalog,
content, );
existingTags: params.handlers.listProducts().map((p) => p.tag), if (
repeatableFamilies, targetModule &&
}); params.moduleReadIndex &&
if ("error" in resolved) { requiresModuleRead(targetModule.id, targetModule.auto)
params.handlers.onToolCall?.(call.name, resolved.error); ) {
const current = params.moduleReadIndex.get(targetModule.id);
const state = parseDictateModuleReadState(
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
);
if (!current || state.modules[targetModule.id] !== current.version) {
const err = `写入 ${tag} 前必须先 read_module("${targetModule.id}") 读取当前提示版本`;
params.handlers.onToolCall?.(call.name, err);
return { return {
callId: call.id, callId: call.id,
content: JSON.stringify({ error: resolved.error }), content: JSON.stringify({
error: err,
required_module_id: targetModule.id,
required_version: current?.version,
}),
}; };
} }
tag = resolved.tag; }
const currentModule = targetModule
? params.moduleReadIndex?.get(targetModule.id)
: undefined;
const selfScore = parseAndValidateSelfScore(
args.self_score,
currentModule?.score_dimensions ?? [],
);
if ("error" in selfScore) {
params.handlers.onToolCall?.(call.name, selfScore.error);
return {
callId: call.id,
content: JSON.stringify({ error: selfScore.error }),
};
}
if (tag === CREATION_PLAN_TAG) {
const validation = validateCreationPlan(content, params.moduleCatalog);
if (!validation.ok) {
const err = validation.error ?? "本局创作方案校验失败";
params.handlers.onToolCall?.(call.name, err);
return {
callId: call.id,
content: JSON.stringify({
error: err,
missing_ids: validation.missingIds,
duplicate_ids: validation.duplicateIds,
unknown_ids: validation.unknownIds,
}),
};
}
}
if (repeatableFamilies.size) {
const family = dictateProductFamily(tag);
const pieces =
targetModule?.id === "concrete-instances" ||
family === "设计.具体实例"
? splitConcreteInstanceContents(content)
: [content];
const baseTag = pieces.length > 1 ? "设计.具体实例" : tag;
const positions: string[] = [];
const resolvedPieces: Array<{ tag: string; content: string }> = [];
for (const piece of pieces) {
const resolved = resolveRepeatableInsertTag({
tag: baseTag,
content: piece,
existingTags: [
...params.handlers.listProducts().map((p) => p.tag),
...resolvedPieces.map((item) => item.tag),
],
repeatableFamilies,
});
if ("error" in resolved) {
params.handlers.onToolCall?.(call.name, resolved.error);
return {
callId: call.id,
content: JSON.stringify({ error: resolved.error }),
};
}
resolvedPieces.push({ tag: resolved.tag, content: piece });
}
let replyModule: string | undefined;
for (const [index, piece] of resolvedPieces.entries()) {
const pieceTag = piece.tag;
const order = parseDictateOrder(args.order);
const productsBeforeWrite = params.handlers.listProducts();
const isOpening = isDictateOpeningTag(pieceTag);
const contractMiss = openingContractMiss(
pieceTag,
piece.content,
params.handlers.listProducts(),
);
if (contractMiss) {
params.handlers.onToolCall?.(call.name, contractMiss.error);
return {
callId: call.id,
content: JSON.stringify(contractMiss),
};
}
const explicitlyPrepared = openingPrepared;
if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.();
}
params.handlers.writeProduct(pieceTag, piece.content, order, {
...(targetModule
? {
layer: resolveInsertLayer({
moduleId: targetModule.id,
catalogLayer: targetModule.layer,
content: piece.content,
}),
}
: {}),
...(selfScore.value ? { selfScore: selfScore.value } : {}),
});
if (!isOpening) openingPrepared = false;
wroteTags.push(pieceTag);
positions.push(pieceTag);
const detail =
order === undefined ? pieceTag : `${pieceTag} order=${order}`;
params.handlers.onToolCall?.(call.name, detail);
replyModule =
params.handlers.lookupInsertFeedback?.(pieceTag)?.trim() ||
replyModule;
const requiredGaps = isDictateOpeningTag(pieceTag)
? requiredCreationPlanGaps({
planRaw: productsBeforeWrite.find(
(product) => product.tag === CREATION_PLAN_TAG,
)?.content,
catalog: params.moduleCatalog,
products: [
...productsBeforeWrite.filter((product) => product.tag !== pieceTag),
{ tag: pieceTag, content: piece.content, order },
],
})
: [];
if (index === resolvedPieces.length - 1) {
if (replyModule) {
const title =
replyModule.match(/^#\s*(?:模块|本轮关注)\s*·\s*(.+)$/m)?.[1]?.trim() ||
pieceTag;
moduleNamesThisStep.push(title);
}
return {
callId: call.id,
content: JSON.stringify({
ok: true,
position: positions[0],
...(positions.length > 1 ? { positions, split: positions.length } : {}),
...(isOpening
? {
runtime_prepared_before_opening: true,
preparation_mode: explicitlyPrepared
? "prepare_opening"
: "implicit_fallback",
}
: {}),
...(order !== undefined ? { order } : {}),
...(requiredGaps.length
? {
warning: `仍有“必须”能力未落:${requiredGaps.join("、")}。开场白已写入,请在准备开玩前提醒用户补齐或调整方案。`,
missing_required_module_ids: requiredGaps,
}
: {}),
...(replyModule
? {
reply_module: replyModule,
reply_hint: DICTATE_MULTI_MODULE_REPLY_HINT,
}
: {}),
}),
};
}
}
} }
const order = parseDictateOrder(args.order); const order = parseDictateOrder(args.order);
params.handlers.writeProduct(tag, content, order); const productsBeforeWrite = params.handlers.listProducts();
const isOpening = isDictateOpeningTag(tag);
const contractMiss = openingContractMiss(
tag,
content,
params.handlers.listProducts(),
);
if (contractMiss) {
params.handlers.onToolCall?.(call.name, contractMiss.error);
return {
callId: call.id,
content: JSON.stringify(contractMiss),
};
}
const explicitlyPrepared = openingPrepared;
if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.();
}
params.handlers.writeProduct(tag, content, order, {
...(targetModule
? {
layer: resolveInsertLayer({
moduleId: targetModule.id,
catalogLayer: targetModule.layer,
content,
}),
}
: {}),
...(selfScore.value ? { selfScore: selfScore.value } : {}),
});
if (tag === VARIABLE_DESIGN_TAG || tag.startsWith(`${VARIABLE_DESIGN_TAG}#`)) {
const synced = syncDesignProductToTable(params.handlers, content);
wroteTags.push(...synced);
}
if (!isOpening) openingPrepared = false;
wroteTags.push(tag); wroteTags.push(tag);
const detail = const detail =
order === undefined ? tag : `${tag} order=${order}`; order === undefined ? tag : `${tag} order=${order}`;
@@ -160,15 +606,41 @@ export async function runDictateTurn(params: {
params.handlers.lookupInsertFeedback?.(tag)?.trim() || undefined; params.handlers.lookupInsertFeedback?.(tag)?.trim() || undefined;
if (replyModule) { if (replyModule) {
const title = const title =
replyModule.match(/^#\s*模块\s*·\s*(.+)$/m)?.[1]?.trim() || tag; replyModule.match(/^#\s*(?:模块|本轮关注)\s*·\s*(.+)$/m)?.[1]?.trim() || tag;
moduleNamesThisStep.push(title); moduleNamesThisStep.push(title);
} }
const requiredGaps = isDictateOpeningTag(tag)
? requiredCreationPlanGaps({
planRaw: productsBeforeWrite.find(
(product) => product.tag === CREATION_PLAN_TAG,
)?.content,
catalog: params.moduleCatalog,
products: [
...productsBeforeWrite.filter((product) => product.tag !== tag),
{ tag, content, order },
],
})
: [];
return { return {
callId: call.id, callId: call.id,
content: JSON.stringify({ content: JSON.stringify({
ok: true, ok: true,
position: tag, position: tag,
...(isOpening
? {
runtime_prepared_before_opening: true,
preparation_mode: explicitlyPrepared
? "prepare_opening"
: "implicit_fallback",
}
: {}),
...(order !== undefined ? { order } : {}), ...(order !== undefined ? { order } : {}),
...(requiredGaps.length
? {
warning: `仍有“必须”能力未落:${requiredGaps.join("、")}。开场白已写入,请在准备开玩前提醒用户补齐或调整方案。`,
missing_required_module_ids: requiredGaps,
}
: {}),
...(replyModule ...(replyModule
? { ? {
reply_module: replyModule, reply_module: replyModule,
@@ -202,6 +674,8 @@ export async function runDictateTurn(params: {
serializeVariableCatalog(doc), serializeVariableCatalog(doc),
); );
wroteTags.push(VARIABLE_CATALOG_TAG); wroteTags.push(VARIABLE_CATALOG_TAG);
const seeded = seedCurrentFromCatalog(params.handlers);
wroteTags.push(...seeded);
const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`; const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`;
params.handlers.onToolCall?.(call.name, detail); params.handlers.onToolCall?.(call.name, detail);
return { return {
@@ -376,33 +850,36 @@ export async function runDictateTurn(params: {
} }
} }
if (toolSteps >= DICTATE_DRIVER_MAX_ITERATIONS - 2) {
noteTurnLimit(results);
}
return { kind: "continue" as const, results }; return { kind: "continue" as const, results };
}, },
}); });
} catch (err) {
let reply = ""; if (!(err instanceof DriverIterationLimitError)) throw err;
if (run.stop.kind === "text") { return {
reply = run.stop.content.trim(); reply: composeDictateReply({
} wroteTags,
if (!reply) { deletedTags,
if (wroteTags.length || deletedTags.length) { clearedDialogue,
const parts: string[] = []; capped: true,
if (wroteTags.length) { }),
parts.push(`已写入:${[...new Set(wroteTags)].join("、")}`); iterations: err.iterations,
} wroteTags: [...new Set(wroteTags)],
if (deletedTags.length) { deletedTags: [...new Set(deletedTags)],
parts.push(`已删除:${[...new Set(deletedTags)].join("、")}`); clearedDialogue,
} };
reply = `${parts.join(";")}。还有要补充的吗?`;
} else if (clearedDialogue) {
reply = "对话已清空,产物都还在。可以继续说。";
} else {
reply = "我在听,请继续说你想要的体验或设定。";
}
} }
return { return {
reply, reply: composeDictateReply({
stopText: run.stop.kind === "text" ? run.stop.content : "",
wroteTags,
deletedTags,
clearedDialogue,
}),
iterations: run.iterations, iterations: run.iterations,
wroteTags: [...new Set(wroteTags)], wroteTags: [...new Set(wroteTags)],
deletedTags: [...new Set(deletedTags)], deletedTags: [...new Set(deletedTags)],
@@ -410,6 +887,52 @@ export async function runDictateTurn(params: {
}; };
} }
function noteTurnLimit(
results: Array<{ callId: string; content: string }>,
): void {
const last = results.at(-1);
if (!last) return;
try {
const parsed = JSON.parse(last.content) as Record<string, unknown>;
parsed.turn_limit = DICTATE_TURN_LIMIT_HINT;
last.content = JSON.stringify(parsed);
} catch {
last.content = `${last.content}\n${DICTATE_TURN_LIMIT_HINT}`;
}
}
function composeDictateReply(params: {
stopText?: string;
wroteTags: string[];
deletedTags: string[];
clearedDialogue: boolean;
capped?: boolean;
}): string {
let reply = params.stopText?.trim() ?? "";
if (!reply) {
if (params.wroteTags.length || params.deletedTags.length) {
const parts: string[] = [];
if (params.wroteTags.length) {
parts.push(`已写入:${[...new Set(params.wroteTags)].join("、")}`);
}
if (params.deletedTags.length) {
parts.push(`已删除:${[...new Set(params.deletedTags)].join("、")}`);
}
reply = `${parts.join(";")}。还有要补充的吗?`;
} else if (params.clearedDialogue) {
reply = "对话已清空,产物都还在。可以继续说。";
} else if (params.capped) {
reply = "这轮工具次数已用完,还没有新的产物。可以说要先补哪一块。";
} else {
reply = "我在听,请继续说你想要的体验或设定。";
}
}
if (params.capped && !reply.includes("工具次数已用完")) {
reply = `${reply} 这轮工具次数已用完,其余下一轮继续。`;
}
return reply;
}
function safeParseArgs(raw: string): Record<string, unknown> { function safeParseArgs(raw: string): Record<string, unknown> {
try { try {
const parsed = JSON.parse(raw || "{}"); const parsed = JSON.parse(raw || "{}");
@@ -421,3 +944,135 @@ function safeParseArgs(raw: string): Record<string, unknown> {
return {}; return {};
} }
} }
function contractForOpeningTag(
tag: string,
products: DictateProduct[],
): ReplyDataContract | undefined {
const want = tag.includes("0层") ? "设计.0层正文组成" : "设计.正文组成";
const raw =
products.find((product) => product.tag === want)?.content ??
products.find((product) => product.tag === "设计.回复格式")?.content;
return parseReplyDataContract(raw);
}
function openingContractMiss(
tag: string,
content: string,
products: DictateProduct[],
): { error: string; reply_contract?: ReturnType<typeof replyContractSummary> } | undefined {
if (!isDictateOpeningTag(tag)) return undefined;
const contract = contractForOpeningTag(tag, products);
if (!contract) return undefined;
const error = openingContractError(content, contract);
if (!error) return undefined;
return { error, reply_contract: replyContractSummary(contract) };
}
function requiresModuleRead(moduleId: string, auto?: boolean): boolean {
if (auto) return false;
return !new Set([
"narrative",
"variable-context",
"context-order",
"worker-spec",
"refine",
]).has(moduleId);
}
function resolveInsertLayer(params: {
moduleId: string;
catalogLayer?: "intermediate" | "final";
content: string;
}): "intermediate" | "final" {
if (params.moduleId === "generation-rules") {
const lifecycle = normalizeGenerationRuleLifecycle(
extractGenerationRuleLifecycle(params.content),
);
if (lifecycle === "seed_only") return "intermediate";
if (lifecycle === "runtime_only" || lifecycle === "seed_and_runtime") {
return "final";
}
}
return params.catalogLayer ?? "final";
}
function extractGenerationRuleLifecycle(
content: string,
): string | undefined {
try {
const parsed = JSON.parse(content) as {
正文?: { 生命周期?: unknown };
生命周期?: unknown;
};
const raw = parsed.正文?.生命周期 ?? parsed.生命周期;
return typeof raw === "string" ? raw.trim() : undefined;
} catch {
return undefined;
}
}
function normalizeGenerationRuleLifecycle(
raw: string | undefined,
): string | undefined {
if (!raw) return undefined;
if (raw === "只预生成" || raw === "seed_only") return "seed_only";
if (raw === "仅游玩期" || raw === "runtime_only") return "runtime_only";
if (raw === "预生成并游玩期" || raw === "seed_and_runtime") {
return "seed_and_runtime";
}
return raw;
}
function parseAndValidateSelfScore(
raw: unknown,
expectedNames: readonly string[],
): { value?: DictateSelfScore } | { error: string } {
if (expectedNames.length === 0) return {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { error: `self_score 必填;维度:${expectedNames.join("、")}` };
}
const dims = (raw as { dims?: unknown }).dims;
if (!Array.isArray(dims)) {
return { error: "self_score.dims 必须是数组" };
}
const parsed: DictateSelfScore["dims"] = [];
for (const item of dims) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return { error: "self_score.dims 含无效条目" };
}
const row = item as Record<string, unknown>;
const name = typeof row.name === "string" ? row.name.trim() : "";
const score = Number(row.score);
const gap = typeof row.gap === "string" ? row.gap.trim() : "";
const options = Array.isArray(row.options)
? row.options.map(String).map((option) => option.trim()).filter(Boolean)
: [];
if (!name || !Number.isInteger(score) || score < 1 || score > 5) {
return { error: "self_score 每项须含合法 name 与 1~5 整数 score" };
}
if (score <= 3 && !gap) {
return { error: `self_score「${name}」不高于 3 分时必须写 gap` };
}
if (options.length > 0 && (options.length < 2 || options.length > 3)) {
return { error: `self_score「${name}」的 options 必须为 2~3 项` };
}
parsed.push({
name,
score,
...(gap ? { gap } : {}),
...(options.length ? { options } : {}),
});
}
const actual = parsed.map((dim) => dim.name);
if (
actual.length !== expectedNames.length ||
expectedNames.some((name) => !actual.includes(name)) ||
actual.some((name) => !expectedNames.includes(name))
) {
return {
error: `self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
};
}
return { value: { dims: parsed } };
}
+19 -3
View File
@@ -5,11 +5,25 @@ export const DICTATE_MODE_VALUE = "dictate";
/** 产物相对序写在黑板 item.metadata 里 */ /** 产物相对序写在黑板 item.metadata 里 */
export const DICTATE_ORDER_META_KEY = "dictateOrder"; export const DICTATE_ORDER_META_KEY = "dictateOrder";
export const DICTATE_LAYER_META_KEY = "dictateLayer";
export const DICTATE_SELF_SCORE_META_KEY = "dictateSelfScore";
export type DictateSelfScore = {
dims: Array<{
name: string;
score: number;
gap?: string;
options?: string[];
}>;
};
export type DictateProduct = { export type DictateProduct = {
tag: string; tag: string;
content: string; content: string;
/** 相对顺序:负数靠前,正数靠后;缺省时按 tag 启发式 */ /** 产物用途层;目录默认值可被具体产物生命周期覆盖 */
layer?: "intermediate" | "final";
selfScore?: DictateSelfScore;
/** 同一变化频率组内的重要性顺序:负数靠前,正数更靠近实际使用位置 */
order?: number; order?: number;
}; };
@@ -31,13 +45,15 @@ export function isAllowedProductTag(tag: string): boolean {
} }
/** /**
* 未显式给 order 时的默认相对序。 * 未显式给 order 时的默认重要性序。
* 越稳定/重要越靠前(更负);越常改越靠后(更正)。 * 变化频率由产物的「稳变」另行分组;同组内基础材料在前,
* 越接近本轮生成合同的内容越靠后(更正)。
* 可增殖拆分 tag(基名#槽)按基名归类。 * 可增殖拆分 tag(基名#槽)按基名归类。
*/ */
export function defaultDictateOrder(tag: string): number { export function defaultDictateOrder(tag: string): number {
const t = tag.trim(); const t = tag.trim();
const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t; const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
if (family === "设计.本局创作方案") return -50;
if (family === "用户.需求" || t === "用户.需求") return -40; if (family === "用户.需求" || t === "用户.需求") return -40;
if (family === "设计.变量目录" || family === "设计.变量映射") return -25; if (family === "设计.变量目录" || family === "设计.变量映射") return -25;
if (family === "设计.模仿范例" || family === "设计.模仿要点") return -28; if (family === "设计.模仿范例" || family === "设计.模仿要点") return -28;
+113
View File
@@ -0,0 +1,113 @@
/**
* 仓库内库种子 ↔ 用户数据目录库文件的导入/导出。
* 导入:缺 id 才补,不覆盖用户已改同 id 正文。
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
loadPreferenceStore,
savePreferenceStore,
type PreferenceStoreFile,
} from "../preference/store.js";
import {
loadStylePackStore,
saveStylePackStore,
type StylePackStoreFile,
} from "../style-pack/store.js";
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
export const LIBRARY_SEED_DIR = path.join(ROOT, "seeds", "libraries");
function readJsonFile(file: string): unknown {
return JSON.parse(readFileSync(file, "utf8")) as unknown;
}
function isPreferenceStore(raw: unknown): raw is PreferenceStoreFile {
return (
!!raw &&
typeof raw === "object" &&
!Array.isArray(raw) &&
(raw as PreferenceStoreFile).version === 1 &&
Array.isArray((raw as PreferenceStoreFile).entries)
);
}
function isStylePackStore(raw: unknown): raw is StylePackStoreFile {
return (
!!raw &&
typeof raw === "object" &&
!Array.isArray(raw) &&
(raw as StylePackStoreFile).version === 1 &&
Array.isArray((raw as StylePackStoreFile).entries)
);
}
export function restoreLibrarySeedsFromRepo(): {
preferencesAdded: number;
stylePacksAdded: number;
} {
const prefPath = path.join(LIBRARY_SEED_DIR, "preferences.json");
const stylePath = path.join(LIBRARY_SEED_DIR, "style-packs.json");
let preferencesAdded = 0;
let stylePacksAdded = 0;
if (existsSync(prefPath)) {
const seed = readJsonFile(prefPath);
if (isPreferenceStore(seed)) {
const store = loadPreferenceStore();
const have = new Set(store.entries.map((entry) => entry.id));
for (const entry of seed.entries) {
if (!entry.id || have.has(entry.id)) continue;
store.entries.push(entry);
have.add(entry.id);
preferencesAdded += 1;
}
if (preferencesAdded) savePreferenceStore(store);
}
}
if (existsSync(stylePath)) {
const seed = readJsonFile(stylePath);
if (isStylePackStore(seed)) {
const store = loadStylePackStore();
const have = new Set(store.entries.map((entry) => entry.id));
for (const entry of seed.entries) {
if (!entry.id || have.has(entry.id)) continue;
store.entries.push(entry);
have.add(entry.id);
stylePacksAdded += 1;
}
if (stylePacksAdded) saveStylePackStore(store);
}
}
return { preferencesAdded, stylePacksAdded };
}
export function exportLibrarySeedsToRepo(): {
preferences: number;
stylePacks: number;
} {
mkdirSync(LIBRARY_SEED_DIR, { recursive: true });
const preferences = loadPreferenceStore();
const stylePacks = loadStylePackStore();
writeFileSync(
path.join(LIBRARY_SEED_DIR, "preferences.json"),
`${JSON.stringify(preferences, null, 2)}\n`,
"utf8",
);
writeFileSync(
path.join(LIBRARY_SEED_DIR, "style-packs.json"),
`${JSON.stringify(stylePacks, null, 2)}\n`,
"utf8",
);
return {
preferences: preferences.entries.length,
stylePacks: stylePacks.entries.length,
};
}
+173
View File
@@ -0,0 +1,173 @@
import type { ChatMessage, LlmProvider } from "../llm/client.js";
import { tryParseJsonDoc } from "../parse/json-doc.js";
import {
intakeSystemPrompt,
nextWaveSystemPrompt,
outlineSystemPrompt,
redirectSystemPrompt,
sectionSystemPrompt,
} from "./prompt.js";
import type { ListenSerialState } from "./types.js";
export type IntakeLlmResult =
| { type: "ask"; reply: string }
| {
type: "propose";
reply: string;
thrill: string;
world: string;
structure: string;
};
export type OutlineLlmResult = {
titles: string[];
payoffTitle: string;
reply: string;
thrill?: string;
};
export type SectionLlmResult = {
text: string;
summary: string;
fulfilled: boolean;
};
export type RedirectLlmResult = {
titles: string[];
payoffTitle: string;
thrillOverride: string;
reply: string;
};
async function completeJson(
llm: LlmProvider,
system: string,
user: string,
caller: string,
): Promise<Record<string, unknown>> {
const messages: ChatMessage[] = [
{ role: "system", content: system },
{ role: "user", content: user },
];
const result = await llm.complete(messages, {
responseFormat: "json_object",
caller,
label: "听书连载",
});
const parsed = tryParseJsonDoc(result.content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("听书连载:模型未返回合法 JSON 对象");
}
return parsed as Record<string, unknown>;
}
function str(v: unknown): string {
return typeof v === "string" ? v.trim() : v == null ? "" : String(v).trim();
}
function strList(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.map((x) => str(x)).filter(Boolean);
}
export async function llmIntake(
llm: LlmProvider,
dialogue: Array<{ role: "user" | "assistant"; text: string }>,
): Promise<IntakeLlmResult> {
const transcript = dialogue
.map((t) => `${t.role === "user" ? "用户" : "助手"}:${t.text}`)
.join("\n\n");
const obj = await completeJson(
llm,
intakeSystemPrompt(),
transcript || "(用户尚未说话)",
"listen_serial_intake",
);
const type = str(obj.type) === "propose" ? "propose" : "ask";
const reply = str(obj.reply) || (type === "ask" ? "还需要一点材料。" : "请确认本局草案。");
if (type === "ask") return { type, reply };
return {
type: "propose",
reply,
thrill: str(obj.thrill),
world: str(obj.world),
structure: str(obj.structure),
};
}
export async function llmOutline(
llm: LlmProvider,
state: ListenSerialState,
userNote?: string,
): Promise<OutlineLlmResult> {
const obj = await completeJson(
llm,
outlineSystemPrompt(state),
userNote?.trim() || "请生成本段大纲。",
"listen_serial_outline",
);
const titles = strList(obj.titles);
const payoffTitle = str(obj.payoff_title) || titles[titles.length - 1] || "";
return {
titles,
payoffTitle,
reply: str(obj.reply) || "大纲已就绪,开始写正文。",
};
}
export async function llmNextWave(
llm: LlmProvider,
state: ListenSerialState,
): Promise<OutlineLlmResult> {
const obj = await completeJson(
llm,
nextWaveSystemPrompt(state),
"请开下一波爽点大纲。",
"listen_serial_next_wave",
);
const titles = strList(obj.titles);
return {
titles,
payoffTitle: str(obj.payoff_title) || titles[titles.length - 1] || "",
reply: str(obj.reply) || "下一波大纲已就绪。",
thrill: str(obj.thrill) || undefined,
};
}
export async function llmSection(
llm: LlmProvider,
state: ListenSerialState,
nextTitle: string,
): Promise<SectionLlmResult> {
const obj = await completeJson(
llm,
sectionSystemPrompt(state, nextTitle),
`请写标题「${nextTitle}」的正文。`,
"listen_serial_section",
);
return {
text: str(obj.text),
summary: str(obj.summary),
fulfilled: Boolean(obj.fulfilled),
};
}
export async function llmRedirect(
llm: LlmProvider,
state: ListenSerialState,
opinion: string,
): Promise<RedirectLlmResult> {
const obj = await completeJson(
llm,
redirectSystemPrompt(state, opinion),
opinion,
"listen_serial_redirect",
);
const titles = strList(obj.titles);
return {
titles,
payoffTitle: str(obj.payoff_title) || titles[titles.length - 1] || "",
thrillOverride: str(obj.thrill_override),
reply: str(obj.reply) || "已按你的意见改后面的走向。",
};
}
+41
View File
@@ -0,0 +1,41 @@
export {
DEFAULT_BUFFER_TARGET_CHARS,
LISTEN_SERIAL_MODE_VALUE,
LISTEN_PHASE_TAG,
LISTEN_THRILL_TAG,
LISTEN_WORLD_TAG,
LISTEN_STRUCTURE_TAG,
LISTEN_OUTLINE_TAG,
LISTEN_SECTIONS_TAG,
LISTEN_SUMMARIES_TAG,
isListenSerialModeValue,
type ListenPhase,
type ListenSerialState,
type ListenSerialView,
type OutlineItem,
type SectionItem,
} from "./types.js";
export {
readListenState,
toListenView,
unreadChars,
ensureBufferTarget,
isConfirmText,
isNextWaveText,
isEndText,
makeOutlineItems,
applyRedirectCut,
writeListenBrief,
writeListenOutline,
writeListenPhase,
writeListenSections,
writeListenSummaries,
} from "./state.js";
export {
runListenSerialTurn,
markListenConsumed,
type ListenChatTurn,
type ListenSerialTurnResult,
} from "./turn.js";
+139
View File
@@ -0,0 +1,139 @@
import type { ListenSerialState } from "./types.js";
import { MAX_SUMMARIES_IN_CONTEXT } from "./types.js";
const ROLE = `你是「听书连载」写手:快速产出打发时间的廉价爽文(短剧/短篇量级)。
文笔可以平、设定可以薄、前后可以略矛盾;但核心爽点必须兑现,节奏要直接。
不要写成长篇网文铺垫;允许跳过前史,直接切到打脸/兑现段。
输出必须是单个 JSON 对象,不要 markdown 围栏。`;
export function intakeSystemPrompt(): string {
return `${ROLE}
当前阶段:开书收集。根据用户几句话,要么轻回问(最多 1~2 个关键缺口),要么给出可确认的本局草案。
JSON schema:
{
"type": "ask" | "propose",
"reply": "给用户看的短回复(中文)",
"thrill": "核心爽点一句话(propose 必填)",
"world": "一句世界:基础世界+变造(propose 必填)",
"structure": "结构调味,如起承转合/三段式+本局怎么用(propose 必填)"
}
规则:
- type=ask 时不要编造 thrill/world/structure。
- type=propose 时三项都要具体、短;爽点要一眼能懂(身份反差/法律制裁/天道报应等)。
- 用户材料已够就直接 propose,别为问而问。`;
}
export function outlineSystemPrompt(state: ListenSerialState): string {
return `${ROLE}
当前阶段:为本段生成大纲标题链,规划到「爽点兑现」为止(不要一次规划整本人生)。
已钉死:
- 核心爽点:${state.thrill}
- 一句世界:${state.world}
- 结构:${state.structure}
JSON schema:
{
"titles": ["标题1", "标题2", "..."],
"payoff_title": "其中作为本段兑现的那条标题(通常是最后一条)",
"reply": "给用户看的一句说明"
}
规则:
- titles 6~12 条为宜,每条是短标题,不是正文。
- 允许跳过最低境界等前史,直接切到兑现相关桥段。
- payoff_title 必须是 titles 中的一项。`;
}
export function sectionSystemPrompt(state: ListenSerialState, nextTitle: string): string {
const summaries = state.summaries.slice(-MAX_SUMMARIES_IN_CONTEXT);
return `${ROLE}
当前阶段:写下一节正文,并附小总结。
已钉死:
- 核心爽点:${state.thrill}
- 一句世界:${state.world}
- 结构:${state.structure}
近期小总结(仅此记忆,勿要求全文一致):
${summaries.length ? summaries.map((s, i) => `${i + 1}. ${s}`).join("\n") : "(尚无)"}
本节大纲标题:${nextTitle}
本节是否兑现节点:${
state.outline.find((o) => o.title === nextTitle && o.status === "pending")
?.payoff
? "是——写到爽点落地"
: "否——推进并留下钩子"
}
JSON schema:
{
"text": "本节正文(约 600~1200 字,中文)",
"summary": "本节小总结(一两句,供后文上下文)",
"fulfilled": true/false
}
规则:
- fulfilled=true 仅当本节已兑现核心爽点(通常是兑现标题那节)。
- 文笔廉价直白即可;不要长篇内心独白。`;
}
export function redirectSystemPrompt(
state: ListenSerialState,
opinion: string,
): string {
const pending = state.outline
.filter((o) => o.status === "pending")
.map((o) => o.title);
const done = state.outline
.filter((o) => o.status === "done")
.map((o) => o.title);
return `${ROLE}
当前阶段:用户插嘴改向。只重排「尚未消费」的后续大纲;已听过的情节可不动,允许前后矛盾。
已钉死爽点(尽量仍朝它收,除非用户意见明确改爽点):${state.thrill}
一句世界:${state.world}
用户意见:${opinion}
已完成标题:${done.length ? done.join(" / ") : "(无)"}
原未写标题(将被替换):${pending.length ? pending.join(" / ") : "(无)"}
JSON schema:
{
"titles": ["新的后续标题1", "..."],
"payoff_title": "新的兑现标题",
"thrill_override": "若用户改了爽点则给新爽点,否则空字符串",
"reply": "给用户看的短确认"
}
规则:
- titles 规划到新的兑现为止;短、直接、服务用户意见。
- 不要复述已完成标题。`;
}
export function nextWaveSystemPrompt(state: ListenSerialState): string {
const recent = state.summaries.slice(-MAX_SUMMARIES_IN_CONTEXT);
return `${ROLE}
上一波爽点已兑现,用户要「下一波」。在同一句世界下开新的兑现段大纲。
核心爽点(可沿用或小幅升级):${state.thrill}
一句世界:${state.world}
结构:${state.structure}
近期小总结:
${recent.length ? recent.map((s, i) => `${i + 1}. ${s}`).join("\n") : "(无)"}
JSON schema:
{
"titles": ["标题1", "..."],
"payoff_title": "本波兑现标题",
"thrill": "本波核心爽点(可与上波相同或升级)",
"reply": "给用户看的一句说明"
}`;
}
+251
View File
@@ -0,0 +1,251 @@
import { randomUUID } from "node:crypto";
import type { Blackboard } from "../blackboard/blackboard.js";
import {
DEFAULT_BUFFER_TARGET_CHARS,
LISTEN_BUFFER_TARGET_TAG,
LISTEN_CONSUMED_CHARS_TAG,
LISTEN_OUTLINE_TAG,
LISTEN_PHASE_TAG,
LISTEN_SECTIONS_TAG,
LISTEN_STRUCTURE_TAG,
LISTEN_SUMMARIES_TAG,
LISTEN_THRILL_TAG,
LISTEN_WORLD_TAG,
MAX_SUMMARIES_IN_CONTEXT,
type ListenPhase,
type ListenSerialState,
type ListenSerialView,
type OutlineItem,
type SectionItem,
} from "./types.js";
function parseJson<T>(raw: string | undefined, fallback: T): T {
if (!raw?.trim()) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function parsePhase(raw: string | undefined): ListenPhase {
const v = (raw ?? "").trim();
if (
v === "intake" ||
v === "awaiting_confirm" ||
v === "running" ||
v === "soft_stop"
) {
return v;
}
return "intake";
}
export function readListenState(board: Blackboard): ListenSerialState {
const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
const bufferTargetChars = Math.max(
500,
Number.parseInt(bufferRaw ?? "", 10) || DEFAULT_BUFFER_TARGET_CHARS,
);
const consumedRaw = board.getContentByTag(LISTEN_CONSUMED_CHARS_TAG);
const consumedChars = Math.max(0, Number.parseInt(consumedRaw ?? "", 10) || 0);
return {
phase: parsePhase(board.getContentByTag(LISTEN_PHASE_TAG)),
thrill: (board.getContentByTag(LISTEN_THRILL_TAG) ?? "").trim(),
world: (board.getContentByTag(LISTEN_WORLD_TAG) ?? "").trim(),
structure: (board.getContentByTag(LISTEN_STRUCTURE_TAG) ?? "").trim(),
outline: parseJson<OutlineItem[]>(board.getContentByTag(LISTEN_OUTLINE_TAG), []),
sections: parseJson<SectionItem[]>(
board.getContentByTag(LISTEN_SECTIONS_TAG),
[],
),
summaries: parseJson<string[]>(
board.getContentByTag(LISTEN_SUMMARIES_TAG),
[],
),
bufferTargetChars,
consumedChars,
};
}
function writeTag(board: Blackboard, tag: string, content: string): void {
board.write({ tag, content, source: "agent" });
}
export function writeListenPhase(board: Blackboard, phase: ListenPhase): void {
writeTag(board, LISTEN_PHASE_TAG, phase);
}
export function writeListenBrief(
board: Blackboard,
brief: { thrill: string; world: string; structure: string },
): void {
writeTag(board, LISTEN_THRILL_TAG, brief.thrill.trim());
writeTag(board, LISTEN_WORLD_TAG, brief.world.trim());
writeTag(board, LISTEN_STRUCTURE_TAG, brief.structure.trim());
}
export function writeListenOutline(board: Blackboard, outline: OutlineItem[]): void {
writeTag(board, LISTEN_OUTLINE_TAG, JSON.stringify(outline));
}
export function writeListenSections(board: Blackboard, sections: SectionItem[]): void {
writeTag(board, LISTEN_SECTIONS_TAG, JSON.stringify(sections));
}
export function writeListenSummaries(board: Blackboard, summaries: string[]): void {
const trimmed = summaries
.map((s) => s.trim())
.filter(Boolean)
.slice(-MAX_SUMMARIES_IN_CONTEXT);
writeTag(board, LISTEN_SUMMARIES_TAG, JSON.stringify(trimmed));
}
export function writeConsumedChars(board: Blackboard, n: number): void {
writeTag(board, LISTEN_CONSUMED_CHARS_TAG, String(Math.max(0, Math.floor(n))));
}
export function ensureBufferTarget(board: Blackboard): number {
const existing = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
if (!existing?.trim()) {
writeTag(board, LISTEN_BUFFER_TARGET_TAG, String(DEFAULT_BUFFER_TARGET_CHARS));
return DEFAULT_BUFFER_TARGET_CHARS;
}
return Math.max(
500,
Number.parseInt(existing, 10) || DEFAULT_BUFFER_TARGET_CHARS,
);
}
export function unreadChars(state: ListenSerialState): number {
const total = state.sections.reduce((n, s) => n + s.text.length, 0);
return Math.max(0, total - state.consumedChars);
}
export function toListenView(state: ListenSerialState): ListenSerialView {
return {
phase: state.phase,
thrill: state.thrill,
world: state.world,
structure: state.structure,
outline: state.outline.map((o) => ({
id: o.id,
title: o.title,
status: o.status,
payoff: o.payoff,
})),
sections: state.sections.map((s) => ({
id: s.id,
outlineId: s.outlineId,
title: s.title,
text: s.text,
summary: s.summary,
consumedChars: s.consumedChars,
})),
unreadChars: unreadChars(state),
bufferTargetChars: state.bufferTargetChars,
consumedChars: state.consumedChars,
};
}
/** 未写完的大纲项 */
export function nextPendingOutline(state: ListenSerialState): OutlineItem | undefined {
return state.outline.find((o) => o.status === "pending");
}
export function makeOutlineItems(
titles: string[],
payoffTitle?: string,
): OutlineItem[] {
const payoff = (payoffTitle ?? titles[titles.length - 1] ?? "").trim();
return titles
.map((t) => t.trim())
.filter(Boolean)
.map((title) => ({
id: randomUUID(),
title,
status: "pending" as const,
payoff: payoff ? title === payoff : false,
}));
}
/** 插嘴:丢掉未消费正文尾部,并把对应大纲改回 pending / 用新标题替换未完成段 */
export function applyRedirectCut(
state: ListenSerialState,
newPendingTitles: string[],
): Pick<ListenSerialState, "outline" | "sections" | "summaries" | "consumedChars"> {
let keptChars = 0;
const keptSections: SectionItem[] = [];
for (const sec of state.sections) {
const remaining = sec.text.length - sec.consumedChars;
if (sec.consumedChars <= 0 && remaining === sec.text.length) {
// 整节未消费:丢弃
continue;
}
if (sec.consumedChars >= sec.text.length) {
keptSections.push(sec);
keptChars += sec.text.length;
continue;
}
// 部分消费:截断到已消费
const cutText = sec.text.slice(0, sec.consumedChars);
keptSections.push({
...sec,
text: cutText,
summary: sec.summary,
consumedChars: cutText.length,
});
keptChars += cutText.length;
}
const doneOutlineIds = new Set(
keptSections.map((s) => s.outlineId).filter(Boolean),
);
const keptOutline = state.outline
.filter((o) => o.status === "done" && doneOutlineIds.has(o.id))
.map((o) => ({ ...o, status: "done" as const }));
const newItems = makeOutlineItems(newPendingTitles);
// 最后一节标为兑现
if (newItems.length) {
for (const item of newItems) item.payoff = false;
newItems[newItems.length - 1]!.payoff = true;
}
const summaries = keptSections
.map((s) => s.summary)
.filter(Boolean)
.slice(-MAX_SUMMARIES_IN_CONTEXT);
return {
outline: [...keptOutline, ...newItems],
sections: keptSections,
summaries,
consumedChars: keptChars,
};
}
export function markOutlineDone(
outline: OutlineItem[],
outlineId: string,
): OutlineItem[] {
return outline.map((o) =>
o.id === outlineId ? { ...o, status: "done" as const } : o,
);
}
export function isConfirmText(text: string): boolean {
const t = text.trim();
if (!t) return false;
return /^(确认|就这样|开始|可以|好的|没问题|开写|写吧)([!!。.\s]*)$/u.test(t);
}
export function isNextWaveText(text: string): boolean {
const t = text.trim();
return /下一波|再来一波|继续下一|开下一波/u.test(t);
}
export function isEndText(text: string): boolean {
const t = text.trim();
return /^(收工|结束|完结|停)([!!。.\s]*)$/u.test(t);
}
+343
View File
@@ -0,0 +1,343 @@
import { randomUUID } from "node:crypto";
import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import {
llmIntake,
llmNextWave,
llmOutline,
llmRedirect,
llmSection,
} from "./generate.js";
import {
applyRedirectCut,
ensureBufferTarget,
isConfirmText,
isEndText,
isNextWaveText,
makeOutlineItems,
markOutlineDone,
nextPendingOutline,
readListenState,
unreadChars,
writeConsumedChars,
writeListenBrief,
writeListenOutline,
writeListenPhase,
writeListenSections,
writeListenSummaries,
} from "./state.js";
import { MAX_SECTIONS_PER_BURST, type ListenSerialState } from "./types.js";
export type ListenChatTurn = { role: "user" | "assistant"; text: string };
export type ListenSerialTurnResult = {
reply: string;
/** 本轮是否进入/保持软停 */
softStop?: boolean;
};
function persistState(
board: Blackboard,
state: ListenSerialState,
patch: Partial<ListenSerialState>,
): ListenSerialState {
const next: ListenSerialState = { ...state, ...patch };
if (patch.phase) writeListenPhase(board, patch.phase);
if (patch.thrill != null || patch.world != null || patch.structure != null) {
writeListenBrief(board, {
thrill: next.thrill,
world: next.world,
structure: next.structure,
});
}
if (patch.outline) writeListenOutline(board, next.outline);
if (patch.sections) writeListenSections(board, next.sections);
if (patch.summaries) writeListenSummaries(board, next.summaries);
if (patch.consumedChars != null) writeConsumedChars(board, next.consumedChars);
return next;
}
async function fillBuffer(
llm: LlmProvider,
board: Blackboard,
state: ListenSerialState,
): Promise<{ state: ListenSerialState; notes: string[]; softStop: boolean }> {
let cur = state;
const notes: string[] = [];
let softStop = false;
let wrote = 0;
while (
unreadChars(cur) < cur.bufferTargetChars &&
wrote < MAX_SECTIONS_PER_BURST
) {
const pending = nextPendingOutline(cur);
if (!pending) {
softStop = true;
break;
}
const section = await llmSection(llm, cur, pending.title);
if (!section.text) {
notes.push(`「${pending.title}」正文为空,已跳过。`);
cur = persistState(board, cur, {
outline: markOutlineDone(cur.outline, pending.id),
});
wrote += 1;
continue;
}
const item = {
id: randomUUID(),
outlineId: pending.id,
title: pending.title,
text: section.text,
summary: section.summary || pending.title,
consumedChars: 0,
};
const summaries = [...cur.summaries, item.summary];
let outline = markOutlineDone(cur.outline, pending.id);
cur = persistState(board, cur, {
outline,
sections: [...cur.sections, item],
summaries,
phase: "running",
});
notes.push(`已写:${pending.title}(${section.text.length} 字)`);
wrote += 1;
if (pending.payoff || section.fulfilled) {
// 兑现后把剩余 pending 标 cut,进入软停(缓冲里已有兑现节可听完)
outline = cur.outline.map((o) =>
o.status === "pending" ? { ...o, status: "cut" as const } : o,
);
cur = persistState(board, cur, { outline, phase: "soft_stop" });
softStop = true;
notes.push("本段爽点已兑现,听完缓冲后可「下一波」或「收工」。");
break;
}
}
if (!softStop && !nextPendingOutline(cur) && unreadChars(cur) > 0) {
cur = persistState(board, cur, { phase: "soft_stop" });
softStop = true;
}
return { state: cur, notes, softStop };
}
function formatUnreadLine(state: ListenSerialState): string {
const n = unreadChars(state);
return `目前约有 ${n} 字未读/未听(目标缓冲 ${state.bufferTargetChars} 字)。`;
}
function formatOutlineLine(state: ListenSerialState): string {
if (!state.outline.length) return "";
return (
"大纲:\n" +
state.outline
.map((o) => {
const mark =
o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·";
const pay = o.payoff ? "(兑现)" : "";
return `${mark} ${o.title}${pay}`;
})
.join("\n")
);
}
/**
* 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。
*/
export async function runListenSerialTurn(params: {
llm: LlmProvider;
board: Blackboard;
dialogue: ListenChatTurn[];
userText: string;
}): Promise<ListenSerialTurnResult> {
const { llm, board, dialogue, userText } = params;
ensureBufferTarget(board);
let state = readListenState(board);
const text = userText.trim();
// —— 软停:收工 / 下一波 ——
if (state.phase === "soft_stop") {
if (isEndText(text)) {
return {
reply: "本局收工。想再开可以新建作品,或再说「下一波」。",
softStop: true,
};
}
if (isNextWaveText(text) || /继续|再来/u.test(text)) {
const wave = await llmNextWave(llm, state);
if (wave.thrill) {
state = persistState(board, state, { thrill: wave.thrill });
}
const outline = makeOutlineItems(wave.titles, wave.payoffTitle);
state = persistState(board, state, {
outline,
phase: "running",
});
const filled = await fillBuffer(llm, board, state);
state = filled.state;
const parts = [
wave.reply,
formatOutlineLine(state),
filled.notes.join("\n"),
formatUnreadLine(state),
].filter(Boolean);
if (filled.softStop) {
parts.push("本波已兑现。可输入「下一波」或「收工」。");
}
return { reply: parts.join("\n\n"), softStop: filled.softStop };
}
// 软停时其他话当作插嘴改向再开波
}
// —— 等待确认 ——
if (state.phase === "awaiting_confirm") {
if (!isConfirmText(text) && text && !/确认|开始|开写/u.test(text)) {
// 用户继续改需求 → 回到 intake 再提案
const intake = await llmIntake(llm, dialogue);
if (intake.type === "ask") {
state = persistState(board, state, { phase: "intake" });
return { reply: intake.reply };
}
state = persistState(board, state, {
phase: "awaiting_confirm",
thrill: intake.thrill,
world: intake.world,
structure: intake.structure,
});
return {
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」开始生成大纲与正文。`,
};
}
const outlined = await llmOutline(llm, state);
const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle);
state = persistState(board, state, { outline, phase: "running" });
const filled = await fillBuffer(llm, board, state);
state = filled.state;
return {
reply: [
outlined.reply,
formatOutlineLine(state),
filled.notes.join("\n"),
formatUnreadLine(state),
filled.softStop
? "本段已兑现。可「下一波」或「收工」。"
: "不满意某桥段就直接说一句意见,我会改后面的走向。",
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop,
};
}
// —— 连载中:空输入/继续 = 补货;有意见 = 改向 ——
if (state.phase === "running" || state.phase === "soft_stop") {
const isContinue =
!text ||
/^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text);
if (!isContinue && text) {
const redirect = await llmRedirect(llm, state, text);
const cut = applyRedirectCut(state, redirect.titles);
// 重新标记 payoff
const outline = cut.outline.map((o, i, arr) => ({
...o,
payoff:
o.status === "pending" &&
(o.title === redirect.payoffTitle || i === arr.length - 1),
}));
// 只让最后一个 pending 为 payoff
let lastPending = -1;
outline.forEach((o, i) => {
if (o.status === "pending") lastPending = i;
if (o.status === "pending") o.payoff = false;
});
if (lastPending >= 0) outline[lastPending]!.payoff = true;
state = persistState(board, state, {
...cut,
outline,
phase: "running",
...(redirect.thrillOverride
? { thrill: redirect.thrillOverride }
: {}),
});
const filled = await fillBuffer(llm, board, state);
state = filled.state;
return {
reply: [
redirect.reply,
formatOutlineLine(state),
filled.notes.join("\n"),
formatUnreadLine(state),
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop,
};
}
const filled = await fillBuffer(llm, board, state);
state = filled.state;
if (!filled.notes.length && unreadChars(state) >= state.bufferTargetChars) {
return {
reply: `${formatUnreadLine(state)}\n缓冲仍充足。听/读一些后再说「继续」,或直接一句意见改向。`,
softStop: state.phase === "soft_stop",
};
}
return {
reply: [
filled.notes.join("\n") || "已尝试补货。",
formatOutlineLine(state),
formatUnreadLine(state),
filled.softStop ? "本段已兑现。可「下一波」或「收工」。" : "",
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop,
};
}
// —— intake ——
const intake = await llmIntake(llm, dialogue);
if (intake.type === "ask") {
state = persistState(board, state, { phase: "intake" });
return { reply: intake.reply };
}
if (!intake.thrill || !intake.world) {
return {
reply: intake.reply || "还需要核心爽点和一句世界,请再补一句。",
};
}
state = persistState(board, state, {
phase: "awaiting_confirm",
thrill: intake.thrill,
world: intake.world,
structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现",
});
return {
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」开始生成大纲与正文。`,
};
}
/** 前端标记已读:推进消费指针 */
export function markListenConsumed(
board: Blackboard,
absoluteConsumedChars: number,
): ListenSerialState {
const state = readListenState(board);
const total = state.sections.reduce((n, s) => n + s.text.length, 0);
const next = Math.max(0, Math.min(total, Math.floor(absoluteConsumedChars)));
let remaining = next;
const sections = state.sections.map((sec) => {
const take = Math.min(sec.text.length, Math.max(0, remaining));
remaining -= take;
return { ...sec, consumedChars: take };
});
writeListenSections(board, sections);
writeConsumedChars(board, next);
return { ...state, sections, consumedChars: next };
}
+81
View File
@@ -0,0 +1,81 @@
/** 听书连载 · 类型与常量 */
export const LISTEN_SERIAL_MODE_VALUE = "listen_serial";
export const LISTEN_PHASE_TAG = "听书.阶段";
export const LISTEN_THRILL_TAG = "听书.核心爽点";
export const LISTEN_WORLD_TAG = "听书.一句世界";
export const LISTEN_STRUCTURE_TAG = "听书.结构";
export const LISTEN_OUTLINE_TAG = "听书.大纲";
export const LISTEN_SECTIONS_TAG = "听书.正文节";
export const LISTEN_SUMMARIES_TAG = "听书.小总结";
export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数";
export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数";
/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */
export const DEFAULT_BUFFER_TARGET_CHARS = 4000;
/** 单次用户触发最多连写几节,避免一次请求过长 */
export const MAX_SECTIONS_PER_BURST = 3;
/** 进模型的滚动小总结条数上限 */
export const MAX_SUMMARIES_IN_CONTEXT = 8;
export type ListenPhase =
| "intake"
| "awaiting_confirm"
| "running"
| "soft_stop";
export type OutlineItem = {
id: string;
title: string;
/** pending=未写;done=已有正文;cut=被插嘴砍掉 */
status: "pending" | "done" | "cut";
/** 本段爽点兑现节点 */
payoff?: boolean;
};
export type SectionItem = {
id: string;
outlineId: string;
title: string;
text: string;
summary: string;
/** 用户已听/读过的字数(相对本节);改向时整节未消费可丢 */
consumedChars: number;
};
export type ListenSerialState = {
phase: ListenPhase;
thrill: string;
world: string;
structure: string;
outline: OutlineItem[];
sections: SectionItem[];
summaries: string[];
bufferTargetChars: number;
/** 全局已消费正文字数(用于 UI「已听」) */
consumedChars: number;
};
export type ListenSerialView = {
phase: ListenPhase;
thrill: string;
world: string;
structure: string;
outline: Array<{ id: string; title: string; status: string; payoff?: boolean }>;
sections: Array<{
id: string;
outlineId: string;
title: string;
text: string;
summary: string;
consumedChars: number;
}>;
unreadChars: number;
bufferTargetChars: number;
consumedChars: number;
};
export function isListenSerialModeValue(raw: string | undefined | null): boolean {
return (raw ?? "").trim() === LISTEN_SERIAL_MODE_VALUE;
}
+15 -1
View File
@@ -4,9 +4,12 @@
*/ */
import { import {
applyMandatoryReasoningFloor,
applyRejectedGenerationField, applyRejectedGenerationField,
forceReasoningEffortNoneWhenTools, forceReasoningEffortNoneWhenTools,
isReasoningMandatoryError,
isToolsReasoningEffortConflictError, isToolsReasoningEffortConflictError,
modelRejectsDisabledReasoning,
parseRejectedGenerationField, parseRejectedGenerationField,
} from "./generation-compat.js"; } from "./generation-compat.js";
@@ -170,7 +173,8 @@ async function postOnce(
/** /**
* 探测请求始终带 reasoning_effort=low。 * 探测请求始终带 reasoning_effort=low。
* 若供应商拒收该字段,剥掉后再试;tools 冲突则改 none 再试。 * Gemini 3 同时写 reasoning.effort。拒收字段则剥掉再试;
* 必须开思考则抬到 low;tools 冲突则改 none 再试。
*/ */
async function postChat( async function postChat(
config: ProbeConfig, config: ProbeConfig,
@@ -180,12 +184,22 @@ async function postChat(
...body, ...body,
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT, reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
}; };
if (modelRejectsDisabledReasoning(config.model)) {
withEffort.reasoning = { effort: CAPABILITY_PROBE_REASONING_EFFORT };
}
const first = await postOnce(config, withEffort); const first = await postOnce(config, withEffort);
if (first.ok || (first.status !== 400 && first.status !== 422)) return first; if (first.ok || (first.status !== 400 && first.status !== 422)) return first;
const retryBody = { ...withEffort }; const retryBody = { ...withEffort };
const tried = new Set<string>(); const tried = new Set<string>();
if (isReasoningMandatoryError(first.text, first.status)) {
if (applyMandatoryReasoningFloor(retryBody, tried)) {
return postOnce(config, retryBody);
}
return first;
}
if (isToolsReasoningEffortConflictError(first.text, first.status)) { if (isToolsReasoningEffortConflictError(first.text, first.status)) {
forceReasoningEffortNoneWhenTools(retryBody, config.model); forceReasoningEffortNoneWhenTools(retryBody, config.model);
tried.add("reasoning_effort_none"); tried.add("reasoning_effort_none");
+180 -29
View File
@@ -57,8 +57,33 @@ export type TokenUsage = {
cachedTokens?: number; cachedTokens?: number;
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */ /** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
cacheMissTokens?: number; cacheMissTokens?: number;
/** 上游若单独返回缓存写入 token(如 cache_creation_input_tokens) */
cacheWriteTokens?: number;
}; };
/** 用量行上的调度信息。不包含提示词或产物正文。 */
export type DictateUsageStep = "select" | "fill" | "fill-retry" | "reply" | "play";
export type UsageDetail = {
turnId?: string;
step?: DictateUsageStep;
retryIndex?: number;
segmentChars?: {
rules: number;
history: number;
products: number;
injected: number;
tail: number;
};
plannedTags?: string[];
succeededTags?: string[];
failedTags?: string[];
finish?: string;
};
/** complete 返回后由统计层写入 recordId,供补记成功或失败的 tag。 */
export type UsageSink = { recordId?: string };
export type CompleteResult = { export type CompleteResult = {
content: string; content: string;
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */ /** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
@@ -84,6 +109,14 @@ export type CompleteOptions = {
label?: string; label?: string;
/** 取消进行中的 LLM 请求(停止并重试) */ /** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal; signal?: AbortSignal;
/**
* 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游,
* 避免开头 message 一变就换缓存节点。其它渠道不发送。
*/
sessionId?: string;
/** 统计补充。不进上游请求体。 */
usageDetail?: UsageDetail;
usageSink?: UsageSink;
}; };
export type ToolChoice = export type ToolChoice =
@@ -131,14 +164,77 @@ export type LlmProvider = {
): Promise<CompleteWithToolsResult>; ): Promise<CompleteWithToolsResult>;
}; };
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
const OPENROUTER_SESSION_ID_MAX = 256;
/** OpenRouter 用 `session_id` 做粘滞路由;其它 OpenAI 兼容渠道不认识这个字段。 */
export function isOpenRouterBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) return false;
try {
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
const host = new URL(withProtocol).hostname.toLowerCase();
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
} catch {
return false;
}
}
/** 合法则返回要写入请求体的 session_id;非 OpenRouter 或空 id 则不写。 */
export function openRouterSessionId(
baseUrl: string,
sessionId: string | undefined,
): string | undefined {
if (!isOpenRouterBaseUrl(baseUrl)) return undefined;
const id = sessionId?.trim();
if (!id) return undefined;
return id.slice(0, OPENROUTER_SESSION_ID_MAX);
}
function messagesMentionJson(messages: ChatMessage[]): boolean {
for (const message of messages) {
if (typeof message.content === "string" && /json/i.test(message.content)) {
return true;
}
}
return false;
}
/**
* OpenAI `response_format: json_object` 要求 messages 里出现 json,否则 400。
* 偏好/文风等提示不写这个词;预设夹心也可能丢掉带 JSON 的任务契约。
*/
export function ensureJsonObjectPrompt(messages: ChatMessage[]): ChatMessage[] {
if (messagesMentionJson(messages)) return messages;
const systemIndex = messages.findIndex((message) => message.role === "system");
if (systemIndex >= 0) {
const current = messages[systemIndex];
if (current?.role === "system") {
const next = messages.slice();
next[systemIndex] = {
role: "system",
content: `${current.content}\n\n${JSON_OBJECT_HINT}`,
};
return next;
}
}
return [{ role: "system", content: JSON_OBJECT_HINT }, ...messages];
}
export function buildRequestBody( export function buildRequestBody(
config: LlmConfig, config: LlmConfig,
messages: ChatMessage[], messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean }, options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
): Record<string, unknown> { ): Record<string, unknown> {
const requestMessages =
options?.responseFormat === "json_object"
? ensureJsonObjectPrompt(messages)
: messages;
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: config.model, model: config.model,
messages, messages: requestMessages,
}; };
if (options?.tools?.length) { if (options?.tools?.length) {
@@ -150,7 +246,7 @@ export function buildRequestBody(
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, { applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
reasoningEffort: config.reasoningEffort, reasoningEffort: config.reasoningEffort,
}); });
// 对齐 imyai:带 tools 时按型号改写思考参数(非 GLM 强制 none) // 带 tools 时按型号改写思考参数(Gemini 3 / GLM-5.3 保留合法档,其余强制 none)
forceReasoningEffortNoneWhenTools(body, config.model); forceReasoningEffortNoneWhenTools(body, config.model);
if (options?.responseFormat === "json_object") { if (options?.responseFormat === "json_object") {
@@ -174,6 +270,9 @@ export function buildRequestBody(
body.stream_options = { include_usage: true }; body.stream_options = { include_usage: true };
} }
const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId);
if (sessionId) body.session_id = sessionId;
return body; return body;
} }
@@ -196,6 +295,20 @@ function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens); return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
} }
function readCacheWriteTokens(u: Record<string, unknown>): number | undefined {
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
if (details && typeof details === "object") {
const row = details as Record<string, unknown>;
const nested = readFiniteNumber(row.cache_write_tokens ?? row.cacheWriteTokens);
if (nested != null) return nested;
}
return readFiniteNumber(
u.cache_creation_input_tokens ??
u.cache_write_input_tokens ??
u.cacheWriteTokens,
);
}
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */ /** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
export function parseUsage(raw: unknown): TokenUsage | undefined { export function parseUsage(raw: unknown): TokenUsage | undefined {
if (!raw || typeof raw !== "object") return undefined; if (!raw || typeof raw !== "object") return undefined;
@@ -207,6 +320,7 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
const cachedTokens = readCachedTokens(u); const cachedTokens = readCachedTokens(u);
const cacheMissTokens = readCacheMissTokens(u); const cacheMissTokens = readCacheMissTokens(u);
const cacheWriteTokens = readCacheWriteTokens(u);
return { return {
promptTokens: Number.isFinite(prompt) ? prompt : 0, promptTokens: Number.isFinite(prompt) ? prompt : 0,
@@ -217,22 +331,66 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
(Number.isFinite(completion) ? completion : 0), (Number.isFinite(completion) ? completion : 0),
...(cachedTokens != null ? { cachedTokens } : {}), ...(cachedTokens != null ? { cachedTokens } : {}),
...(cacheMissTokens != null ? { cacheMissTokens } : {}), ...(cacheMissTokens != null ? { cacheMissTokens } : {}),
...(cacheWriteTokens != null ? { cacheWriteTokens } : {}),
}; };
} }
function extractMessageParts(message: Record<string, unknown> | undefined): { function stringifyToolArguments(value: unknown): string {
if (typeof value === "string" && value.trim()) return value;
if (value && typeof value === "object") return JSON.stringify(value);
return "{}";
}
function toolCallFromRow(row: Record<string, unknown>, index: number): ParsedToolCall | null {
const fn = row.function;
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : null;
const source = nested ?? row;
const name = typeof source.name === "string" ? source.name.trim() : "";
if (!name) return null;
const rawId = typeof row.id === "string" ? row.id.trim() : "";
return {
id: rawId || `call-${index}`,
name,
arguments: stringifyToolArguments(
source.arguments ?? source.args ?? row.arguments ?? row.args ?? row.input,
),
};
}
function embeddedContent(raw: unknown): { text: string | null; calls: ParsedToolCall[] } {
if (typeof raw === "string") return { text: raw.trim() || null, calls: [] };
if (!Array.isArray(raw)) return { text: raw == null ? null : "", calls: [] };
const texts: string[] = [];
const calls: ParsedToolCall[] = [];
raw.forEach((part, index) => {
if (!part || typeof part !== "object") return;
const row = part as Record<string, unknown>;
if (typeof row.text === "string" && row.text.trim()) texts.push(row.text.trim());
const fn = row.functionCall ?? row.function_call;
if (row.type === "tool_use" || (fn && typeof fn === "object")) {
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : row;
const parsed = toolCallFromRow(
{
id: row.id,
name: nested.name ?? row.name,
arguments: nested.args ?? nested.arguments ?? row.input ?? row.arguments,
},
index,
);
if (parsed) calls.push(parsed);
}
});
return { text: texts.join("\n").trim() || null, calls };
}
/** 从一条 assistant message 取出正文和工具调用。对象参数、缺 id、content 片段里的调用都收。 */
export function extractMessageParts(message: Record<string, unknown> | undefined): {
content: string | null; content: string | null;
reasoning?: string; reasoning?: string;
toolCalls: ParsedToolCall[]; toolCalls: ParsedToolCall[];
} { } {
if (!message) return { content: "", toolCalls: [] }; if (!message) return { content: "", toolCalls: [] };
const rawContent = message.content; const embedded = embeddedContent(message.content);
const content =
typeof rawContent === "string"
? rawContent.trim() || null
: rawContent == null
? null
: "";
const reasoningRaw = const reasoningRaw =
(typeof message.reasoning_content === "string" && (typeof message.reasoning_content === "string" &&
message.reasoning_content.trim()) || message.reasoning_content.trim()) ||
@@ -244,21 +402,18 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
const toolCalls: ParsedToolCall[] = []; const toolCalls: ParsedToolCall[] = [];
const rawCalls = message.tool_calls; const rawCalls = message.tool_calls;
if (Array.isArray(rawCalls)) { if (Array.isArray(rawCalls)) {
for (const call of rawCalls) { rawCalls.forEach((call, index) => {
if (!call || typeof call !== "object") continue; if (!call || typeof call !== "object") return;
const c = call as Record<string, unknown>; const parsed = toolCallFromRow(call as Record<string, unknown>, index);
const fn = c.function; if (parsed) toolCalls.push(parsed);
if (!fn || typeof fn !== "object") continue; });
const f = fn as Record<string, unknown>; }
const name = typeof f.name === "string" ? f.name : ""; for (const call of embedded.calls) {
const id = typeof c.id === "string" ? c.id : ""; if (!toolCalls.some((item) => item.id === call.id && item.name === call.name)) {
const args = toolCalls.push(call);
typeof f.arguments === "string" ? f.arguments : "{}";
if (name && id) {
toolCalls.push({ id, name, arguments: args });
}
} }
} }
const content = embedded.text;
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
return { content, reasoning: reasoning || undefined, toolCalls }; return { content, reasoning: reasoning || undefined, toolCalls };
@@ -313,10 +468,8 @@ export class OpenAiCompatibleProvider implements LlmProvider {
usage?: unknown; usage?: unknown;
choices?: Array<{ message?: Record<string, unknown> }>; choices?: Array<{ message?: Record<string, unknown> }>;
}; };
// 空正文不抛错:游玩/Worker 会落「本轮场面未写完」等占位,仍可重 roll
const parts = extractMessageParts(data.choices?.[0]?.message); const parts = extractMessageParts(data.choices?.[0]?.message);
if (!parts.content && parts.toolCalls.length === 0) {
throw new Error("LLM returned empty content");
}
return { return {
content: parts.content ?? "", content: parts.content ?? "",
reasoning: parts.reasoning, reasoning: parts.reasoning,
@@ -347,10 +500,8 @@ export class OpenAiCompatibleProvider implements LlmProvider {
throw new Error("LLM stream response has no body"); throw new Error("LLM stream response has no body");
} }
// 空流不抛错:下游会落占位正文,消息正常渲染以便重 roll
const parts = await consumeOpenAiToolStream(response.body, callbacks); const parts = await consumeOpenAiToolStream(response.body, callbacks);
if (!parts.content && !parts.reasoning) {
throw new Error("LLM stream returned empty content");
}
return { return {
content: parts.content ?? parts.reasoning ?? "", content: parts.content ?? parts.reasoning ?? "",
reasoning: parts.reasoning || undefined, reasoning: parts.reasoning || undefined,
+181
View File
@@ -0,0 +1,181 @@
import { createHash } from "node:crypto";
import type { ToolDefinition } from "./client.js";
/** 一段请求的位置标签。只存标签、字数和哈希,不存正文。 */
export type ContextSegment = {
tag: string;
chars: number;
hash: string;
};
/** 挂在一条回复上的各次请求轮廓,选题和填写都保留。 */
export type RequestOutline = {
at: string;
caller: string;
step?: string;
turnId?: string;
model?: string;
promptTokens?: number;
completionTokens?: number;
cachedTokens?: number;
cacheMissTokens?: number;
cacheWriteTokens?: number;
segments: ContextSegment[];
};
/** 和同会话上一条请求比,前缀从哪里开始对不上。 */
export type CacheBreakInfo = {
/** 本窗口里没有更早的同会话请求 */
cold: boolean;
/** -1:本条在上一请求的前缀里整段对上 */
breakAt: number;
tag?: string;
/** 断点之前、和上一请求逐段相同的段数 */
matched: number;
};
const SPLIT =
/(?:^|\n)(#{2,4} [^\n]+|最后一轮用户输入|【用户】|【助手】)/g;
function hashText(text: string): string {
return createHash("sha256").update(text).digest("hex").slice(0, 12);
}
function segment(tag: string, text: string): ContextSegment {
return { tag, chars: text.length, hash: hashText(text) };
}
function headingTag(title: string): string {
const beforeOrder = title.split("〔")[0]?.trim() ?? title;
const beforeParen = beforeOrder.split(/(|\(/)[0]?.trim() ?? beforeOrder;
const beforeAt = beforeParen.split(" @ ")[0]?.trim() ?? beforeParen;
const tag = (beforeAt || title).trim();
return tag.length > 48 ? `${tag.slice(0, 48)}…` : tag;
}
function collectMarks(content: string): Array<{ index: number; tag: string }> {
const marks: Array<{ index: number; tag: string }> = [];
let section = "";
for (const match of content.matchAll(SPLIT)) {
const raw = match[1] ?? "";
const start = (match.index ?? 0) + (match[0].startsWith("\n") ? 1 : 0);
if (raw.startsWith("#")) {
const level = /^#+/.exec(raw)?.[0].length ?? 2;
const title = raw.replace(/^#+\s*/, "");
const tag = headingTag(title);
if (level === 2) section = tag;
marks.push({ index: start, tag });
continue;
}
if (raw === "最后一轮用户输入" && section.startsWith("本轮")) {
marks.push({ index: start, tag: "最后一轮用户输入" });
continue;
}
if (
(raw === "【用户】" || raw === "【助手】") &&
section.startsWith("更早的对话")
) {
marks.push({
index: start,
tag: raw === "【用户】" ? "更早·用户" : "更早·助手",
});
}
}
return marks;
}
/** 把一块正文切成有序标签。前缀里的标题与更早对话轮次分开,方便对上断点。 */
export function outlineBlock(fallbackTag: string, content: string): ContextSegment[] {
if (!content) return [];
const marks = collectMarks(content);
if (marks.length === 0) return [segment(fallbackTag, content)];
const out: ContextSegment[] = [];
if (marks[0]!.index > 0) {
const head = content.slice(0, marks[0]!.index);
if (head.trim()) out.push(segment(fallbackTag, head));
}
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1]!.index : content.length;
out.push(segment(marks[i]!.tag, content.slice(marks[i]!.index, end)));
}
return out;
}
function roleTag(role: string): string {
if (role === "system") return "规则";
if (role === "user") return "用户";
if (role === "assistant") return "助手";
if (role === "tool") return "工具结果";
return role || "消息";
}
/** 按实际送出的消息切段。工具定义若有,放在正文之前。 */
export function outlineRequest(
messages: Array<{ role: string; content?: string | null }>,
tools?: ToolDefinition[],
): ContextSegment[] {
const out: ContextSegment[] = [];
if (tools?.length) {
out.push(segment("工具", JSON.stringify(tools)));
}
for (const message of messages) {
const content = typeof message.content === "string" ? message.content : "";
out.push(...outlineBlock(roleTag(message.role), content));
}
return out;
}
/**
* 返回下一请求里第一段对不上的下标。
* 下一请求比上一请求更短、但已有段全部相同,视为整段接上(-1)。
*/
export function firstCacheBreak(
prev: Array<Pick<ContextSegment, "tag" | "hash">>,
next: Array<Pick<ContextSegment, "tag" | "hash">>,
): number {
const shared = Math.min(prev.length, next.length);
for (let i = 0; i < shared; i++) {
if (prev[i]!.tag !== next[i]!.tag || prev[i]!.hash !== next[i]!.hash) return i;
}
if (next.length > prev.length) return prev.length;
return -1;
}
export function annotateCacheBreaks<
T extends { id: string; at: string; sessionId?: string; segments?: ContextSegment[] },
>(records: T[]): Map<string, CacheBreakInfo> {
const bySession = new Map<string, T[]>();
for (const record of records) {
const key = record.sessionId?.trim() || `__none__:${record.id}`;
const list = bySession.get(key) ?? [];
list.push(record);
bySession.set(key, list);
}
const marks = new Map<string, CacheBreakInfo>();
for (const list of bySession.values()) {
list.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
let prev: ContextSegment[] | undefined;
for (const record of list) {
const segments = record.segments ?? [];
if (!prev) {
marks.set(record.id, {
cold: true,
breakAt: segments.length ? 0 : -1,
tag: segments[0]?.tag,
matched: 0,
});
prev = segments;
continue;
}
const breakAt = firstCacheBreak(prev, segments);
marks.set(record.id, {
cold: false,
breakAt,
tag: breakAt >= 0 ? segments[breakAt]?.tag : undefined,
matched: breakAt < 0 ? segments.length : breakAt,
});
prev = segments;
}
}
return marks;
}
+135 -3
View File
@@ -7,8 +7,10 @@ import type { GenerationParameters } from "../types/preset.js";
* - 推理模型去掉 temperature / top_p / penalty * - 推理模型去掉 temperature / top_p / penalty
* - o 系列与 gpt-5 用 max_completion_tokens * - o 系列与 gpt-5 用 max_completion_tokens
* - GLM:按型号映射 thinking / reasoning_effort(5.3 拒收 none;旧版拒收 effort) * - GLM:按型号映射 thinking / reasoning_effort(5.3 拒收 none;旧版拒收 effort)
* - 带 tools 时:非 GLM 强制 reasoning_effort=none(gpt-5.6-luna 等);GLM-5.3 保留合法档 * - Gemini 3(如 gemini-3.8-flash):思考不能关,none/minimal 抬到 low
* - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则按型号改写再试 * - 带 tools 时:非上述型号强制 reasoning_effort=none(gpt-5.6-luna 等);始终开启的型号保留合法档
* - 仍 400 时按错误信息剥掉不支持字段再试;tools 冲突改 none,
* “Reasoning is mandatory … cannot be disabled” 则抬到 low 再试
*/ */
const REASONING_EFFORT_VALUES = [ const REASONING_EFFORT_VALUES = [
@@ -60,6 +62,7 @@ export const STRIPPABLE_GENERATION_FIELDS = new Set([
"stop", "stop",
"response_format", "response_format",
"stream_options", "stream_options",
"reasoning",
]); ]);
export type ChatModelCompat = { export type ChatModelCompat = {
@@ -109,6 +112,42 @@ export function classifyGlmThinking(model: string): GlmThinkingKind {
return "thinking_only"; return "thinking_only";
} }
/**
* Gemini 3:OpenRouter 拒绝关闭思考。
* 3.8 合法档只有 low / medium / high(minimal 也会 400)。
*/
export function modelRejectsDisabledReasoning(model: string): boolean {
const id = modelLeaf(model);
return /^gemini-3(?:[.\-]|$)/.test(id);
}
function isGemini38(model: string): boolean {
return /gemini-3\.8/.test(modelLeaf(model));
}
/** 落到 Gemini 思考档。未设置或 none 视为最低合法档 low。 */
function mapGeminiEffort(
model: string,
effort: ReasoningEffort | undefined,
): "minimal" | "low" | "medium" | "high" {
if (!effort || effort === "none") return "low";
if (effort === "minimal") return isGemini38(model) ? "low" : "minimal";
if (effort === "low") return "low";
if (effort === "medium") return "medium";
return "high";
}
function writeGeminiReasoning(
body: Record<string, unknown>,
model: string,
effort: ReasoningEffort | undefined,
): void {
const mapped = mapGeminiEffort(model, effort);
body.reasoning_effort = mapped;
body.reasoning = { effort: mapped };
if (isThinkingDisabled(body.thinking)) delete body.thinking;
}
function mapGlm53Effort( function mapGlm53Effort(
effort: ReasoningEffort, effort: ReasoningEffort,
): "low" | "high" | "max" { ): "low" | "high" | "max" {
@@ -136,6 +175,10 @@ function applyReasoningToBody(
const glm = classifyGlmThinking(model); const glm = classifyGlmThinking(model);
if (glm === "none") { if (glm === "none") {
if (modelRejectsDisabledReasoning(model)) {
writeGeminiReasoning(body, model, effort);
return;
}
body.reasoning_effort = effort; body.reasoning_effort = effort;
return; return;
} }
@@ -240,7 +283,8 @@ export function applyOpenAiGeneration(
/** /**
* 带 function tools 时改写思考参数。 * 带 function tools 时改写思考参数。
* - 非 GLM(如 gpt-5.6-luna):须显式 reasoning_effort=none * - 非 GLM、且允许关闭思考(如 gpt-5.6-luna):须显式 reasoning_effort=none
* - Gemini 3:不能 none/minimal,缺省或关闭档抬到 low
* - GLM-5.3:不能 none,保留/压到合法档(默认 low) * - GLM-5.3:不能 none,保留/压到合法档(默认 low)
* - GLM-5.2 / 旧版:thinking disabled,去掉 effort * - GLM-5.2 / 旧版:thinking disabled,去掉 effort
*/ */
@@ -262,6 +306,14 @@ export function forceReasoningEffortNoneWhenTools(
body.reasoning_effort = mapGlm53Effort(current); body.reasoning_effort = mapGlm53Effort(current);
return; return;
} }
if (modelRejectsDisabledReasoning(modelId)) {
writeGeminiReasoning(
body,
modelId,
sanitizeReasoningEffort(body.reasoning_effort),
);
return;
}
if (glm === "effort_full" || glm === "thinking_only") { if (glm === "effort_full" || glm === "thinking_only") {
body.thinking = { type: "disabled" }; body.thinking = { type: "disabled" };
delete body.reasoning_effort; delete body.reasoning_effort;
@@ -302,6 +354,79 @@ export function isToolsReasoningEffortConflictError(
return mentionsTools && mentionsReasoningEffort && conflict; return mentionsTools && mentionsReasoningEffort && conflict;
} }
/** OpenRouter 等:该端点必须开思考,显式 none / disabled 会被 400。 */
export function isReasoningMandatoryError(
errorText: string,
status?: number,
): boolean {
if (status != null && status !== 400 && status !== 422) return false;
let parsed: unknown;
try {
parsed = JSON.parse(errorText);
} catch {
parsed = undefined;
}
const message = `${readErrorMessage(parsed)}\n${errorText}`.toLowerCase();
return (
/reasoning is mandatory/.test(message) && /cannot be disabled/.test(message)
);
}
function isThinkingDisabled(thinking: unknown): boolean {
return (
!!thinking &&
typeof thinking === "object" &&
(thinking as { type?: unknown }).type === "disabled"
);
}
/**
* 把关闭思考的字段抬到合法最低档 low。
* 只改 none / minimal / disabled;已经是 low 及以上则不动,避免空转重试。
*/
export function applyMandatoryReasoningFloor(
body: Record<string, unknown>,
tried: Set<string>,
): boolean {
if (tried.has("reasoning_mandatory_floor")) return false;
let changed = false;
if (isThinkingDisabled(body.thinking)) {
body.thinking = { type: "enabled" };
changed = true;
}
const effort = sanitizeReasoningEffort(body.reasoning_effort);
if (effort === "none" || effort === "minimal") {
body.reasoning_effort = "low";
body.reasoning = { effort: "low" };
changed = true;
}
if (body.reasoning && typeof body.reasoning === "object") {
const reasoning = { ...(body.reasoning as Record<string, unknown>) };
const effortValue =
typeof reasoning.effort === "string"
? reasoning.effort.trim().toLowerCase()
: "";
if (
reasoning.enabled === false ||
effortValue === "none" ||
effortValue === "minimal"
) {
delete reasoning.enabled;
reasoning.effort = "low";
body.reasoning = reasoning;
changed = true;
}
}
if (!changed) return false;
tried.add("reasoning_mandatory_floor");
return true;
}
function readErrorMessage(raw: unknown): string { function readErrorMessage(raw: unknown): string {
if (!raw || typeof raw !== "object") return ""; if (!raw || typeof raw !== "object") return "";
const err = (raw as { error?: unknown }).error; const err = (raw as { error?: unknown }).error;
@@ -450,6 +575,13 @@ export async function fetchWithGenerationCompat(
const text = await last.text(); const text = await last.text();
const next = { ...body }; const next = { ...body };
if (isReasoningMandatoryError(text, last.status)) {
if (applyMandatoryReasoningFloor(next, tried)) {
body = next;
continue;
}
}
if (isToolsReasoningEffortConflictError(text, last.status)) { if (isToolsReasoningEffortConflictError(text, last.status)) {
if (applyToolsReasoningEffortNone(next, tried)) { if (applyToolsReasoningEffortNone(next, tried)) {
body = next; body = next;
+153
View File
@@ -0,0 +1,153 @@
/**
* 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。
*/
const MAX_BODY_CHARS = 2_000_000;
const MAX_MODELS = 4000;
export function normalizeBaseUrl(url: string): string {
return url.trim().replace(/\/+$/, "").toLowerCase();
}
/** 按常见兼容网关尝试 models 地址。base 已含 /v1 时只打一次。 */
export function modelListUrls(baseUrl: string): string[] {
const base = baseUrl.trim().replace(/\/+$/, "");
if (!base) return [];
const urls: string[] = [];
const push = (url: string) => {
if (!urls.includes(url)) urls.push(url);
};
push(`${base}/models`);
const lower = base.toLowerCase();
if (!/\/v1(?:beta)?(?:\/|$)/.test(lower)) {
push(`${base}/v1/models`);
push(`${base}/api/v1/models`);
}
return urls;
}
function modelIdOf(item: unknown): string | null {
if (typeof item === "string") {
const id = item.trim();
return id && id.length <= 200 ? id : null;
}
if (!item || typeof item !== "object") return null;
const rec = item as { id?: unknown; name?: unknown };
const raw = typeof rec.id === "string" ? rec.id : rec.name;
if (typeof raw !== "string") return null;
const id = raw.trim();
return id && id.length <= 200 ? id : null;
}
/** 识别 { data: [{id}] }、{ models } 或纯数组。无法识别返回 null。 */
export function parseModelsPayload(data: unknown): string[] | null {
let list: unknown[] | null = null;
if (Array.isArray(data)) list = data;
else if (data && typeof data === "object") {
const rec = data as { data?: unknown; models?: unknown; error?: unknown };
if (rec.error && !Array.isArray(rec.data) && !Array.isArray(rec.models)) {
return null;
}
if (Array.isArray(rec.data)) list = rec.data;
else if (Array.isArray(rec.models)) list = rec.models;
}
if (!list) return null;
const seen = new Set<string>();
const ids: string[] = [];
for (const item of list) {
const id = modelIdOf(item);
if (!id || seen.has(id)) continue;
seen.add(id);
ids.push(id);
if (ids.length >= MAX_MODELS) break;
}
return ids;
}
type FetchOutcome =
| { ok: true; models: string[] }
| { ok: false; stop: boolean; message: string };
async function fetchModelList(url: string, apiKey: string): Promise<FetchOutcome> {
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const message = err instanceof Error ? err.message : "请求失败";
return { ok: false, stop: false, message: `${url} ${message}` };
}
if (response.status === 401 || response.status === 403) {
return {
ok: false,
stop: true,
message: `API Key 被拒绝(${response.status})`,
};
}
if (!response.ok) {
return {
ok: false,
stop: false,
message: `${url} → ${response.status}`,
};
}
let text: string;
try {
text = await response.text();
} catch (err) {
const message = err instanceof Error ? err.message : "读取失败";
return { ok: false, stop: false, message: `${url} ${message}` };
}
if (text.length > MAX_BODY_CHARS) {
return { ok: false, stop: false, message: `${url} 响应过大` };
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return { ok: false, stop: false, message: `${url} 不是 JSON` };
}
const models = parseModelsPayload(parsed);
if (!models) {
return { ok: false, stop: false, message: `${url} 没有模型列表` };
}
return { ok: true, models };
}
export async function discoverRemoteModels(
baseUrl: string,
apiKey: string,
): Promise<{ models: string[]; url: string }> {
const key = apiKey.trim();
if (!key) throw new Error("未设置 API Key");
const urls = modelListUrls(baseUrl);
if (!urls.length) throw new Error("缺少 Base URL");
const errors: string[] = [];
for (let i = 0; i < urls.length; i += 1) {
const url = urls[i]!;
const result = await fetchModelList(url, key);
if (!result.ok) {
if (result.stop) throw new Error(result.message);
errors.push(result.message);
continue;
}
const last = i === urls.length - 1;
if (result.models.length === 0 && !last) {
errors.push(`${url} 返回空列表`);
continue;
}
return { models: result.models, url };
}
throw new Error(
errors.length ? `未能嗅探模型。${errors.join(";")}` : "未能嗅探模型",
);
}

Some files were not shown because too many files have changed in this diff Show More