dsh-lifecard 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -1
- package/lib/career.js +488 -0
- package/lib/client.js +699 -33
- package/lib/game.js +373 -0
- package/lib/index.js +558 -35
- package/lib/novel.js +109 -10
- package/lib/presets.js +424 -0
- package/lib/store.js +86 -15
- package/lib/tavern.js +56 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
> **三十秒看懂**:把家里人、老朋友、你自己,一件一件问清楚——他做过什么、怎么说话、你们之间发生过什么——写成一张能一直改的卡。卡就在你自己的文件夹里,用记事本就能打开,换台电脑也能带走。不联网、不上传,也不替你编:每条都要能在你写过的话里找到出处,找不到就丢掉。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**打开先选一扇门。** 三扇门是三件不同的事,进去之后随时能换(右上角「换门」),卡和世界书都在同一个文件夹里,不会因为换门丢掉:
|
|
8
|
+
|
|
9
|
+
| 门 | 用来做什么 | 里面有什么 |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **真实人物记录** | 记现实里的人:家里人、朋友、你自己 | 访谈、材料整理、导角色卡、简历与规划、看工作 |
|
|
12
|
+
| **写小说** | 立虚构角色、写一本书 | 边聊边立角色、大纲、逐章写、盲审(只看正文挑毛病)、把酒馆一场对话或游戏一段变成一章 |
|
|
13
|
+
| **酒馆(角色扮演)** | 拿一张卡,和 TA 你一句我一句地演 | 角色卡 + 世界书命中、开场白、收尾备忘(记到「到这儿为止」)、挑几句进卡的「对话示例」 |
|
|
14
|
+
|
|
15
|
+
**真实人物记录那扇门里,五个入口组成两条链路:**
|
|
8
16
|
|
|
9
17
|
```
|
|
10
18
|
① 导入 角色卡 PNG / JSON / CHARX → 直接出卡 (纯格式搬运,不调模型)
|
|
@@ -152,6 +160,59 @@ dsh plugin --profile desktop add /path/to/LifeCard/plugin
|
|
|
152
160
|
- **导出 PNG**:`chara`(V2)与 `ccv3`(V3)两个 chunk 来自**同一个源对象**,V3 是 V2 的严格超集。SillyTavern 的 `read()` 以 `ccv3` 优先,两份独立投影会让同一张卡在两个前端里变成两个人。
|
|
153
161
|
- **导出 JSON**:CCv3 格式。
|
|
154
162
|
|
|
163
|
+
### 写小说
|
|
164
|
+
|
|
165
|
+
**写小说** 那扇门里先选一本(或新建一本),一本书就是三个文件:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
books/<书名>/book.md 书名、题材、一句话
|
|
169
|
+
books/<书名>/outline.md 大纲:主线、结局、第 N 章各写什么(这一个人能直接改)
|
|
170
|
+
books/<书名>/chapters/001.md 正文,一章一个文件
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
三步:
|
|
174
|
+
|
|
175
|
+
1. **先想清楚这本书怎么走**——点「让 AI 想大纲」,或者自己在 `outline.md` 里写。大纲是纯文本,你想怎么改就怎么改。
|
|
176
|
+
2. **写第 N 章**——点「让 AI 写这一章」。它一次会拿到四样东西:这一章的计划(大纲里对应的那一句)、整本大纲、上一章结尾、挂在这本书上的角色卡。出来的是一份草稿,你看了点「收下,写进这一章」才落盘。
|
|
177
|
+
3. **挑挑毛病**——只把正文给它,**不给书名、不给大纲、不给「本来想写什么」**。这样它挑的是「写出来是什么」,而不是按你的意图夸你写得对。四条固定要求:卡在哪、哪不信、哪想跳过、哪句是空话(要引原句)。
|
|
178
|
+
|
|
179
|
+
章号**不重排**:删了第 3 章,第 4 章还是第 4 章。你手里说的「第四章」不会突然变成第三章。
|
|
180
|
+
|
|
181
|
+
一本书还能挂「文字小说游戏」:挑一章当起点,它写这一刻发生了什么,给你 A/B/C 三个方向,你选一个,它接着往下推。玩出来的东西也能一键变成小说的一章,**你选过的那几步会保住**。
|
|
182
|
+
|
|
183
|
+
### 酒馆(角色扮演)
|
|
184
|
+
|
|
185
|
+
**酒馆** 那扇门里挑一张虚构角色卡,点一张就开一场。开场白是第一条,之后你说一句它回一句。
|
|
186
|
+
|
|
187
|
+
- **世界书按关键词进**:常驻条目每轮都带;其余条目只有最近六句话里出现了它的关键词才带。不像写小说那样整本设定全塞进去——演的时候塞满了,它就只会复述设定。
|
|
188
|
+
- **现实里的卡默认收起来**。真人有真人的记录,拿去演会**复制一份独立的角色卡**(`-role` 后缀),之后你把副本的性格改成完全相反都动不到原来那张。
|
|
189
|
+
- **收个尾**:聊到一段落点「收个尾」,它把到这儿为止发生的事、定下来的事、还没解决的事记成一段备忘,存进这场对话的 `## 到这儿为止`。不满八句不收,因为三句话总结出来的东西比没有更误导。下次接着说,这段备忘每轮都带着。
|
|
190
|
+
- **挑几句进卡里**:演出来好用的说话方式,可以挑 4~8 句写回那张卡的「对话示例」小节,下次演就更像。
|
|
191
|
+
- **变成小说的一章**:一场对话可以整段改写成小说正文,存进某本书的下一章。
|
|
192
|
+
|
|
193
|
+
对话存在 `chats/` 下,一个 Markdown 文件一场,用记事本就能打开看。
|
|
194
|
+
|
|
195
|
+
### 简历与规划
|
|
196
|
+
|
|
197
|
+
**真实人物记录** 门里的工具行有「简历与规划」。两边分开:
|
|
198
|
+
|
|
199
|
+
- **左半边**是你自己:基本情况、学过什么、做过什么、会用哪些工具、想去哪、还差什么。照这个可以直接写一份简历(它会按你填的「想投哪儿」调重点),也能看「差在哪」。
|
|
200
|
+
- **右半边**是路怎么走:终点长什么样、分几步、现在这一步、可能会卡在哪。
|
|
201
|
+
|
|
202
|
+
### 不想要某个功能,可以整块删掉
|
|
203
|
+
|
|
204
|
+
写小说、酒馆、文字游戏、预设、简历与规划,这五块是**可拆卸**的:删掉对应的文件,别的功能照常能用,被删掉的那扇门会自己从界面上消失,别人点进来也不会报错,只会说「这个功能没装」。
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
plugin/lib/novel.js 删了 = 没有「写小说」(文字游戏也一起没了)
|
|
208
|
+
plugin/lib/tavern.js 删了 = 没有「酒馆」
|
|
209
|
+
plugin/lib/game.js 删了 = 没有文字游戏,「写小说」还在
|
|
210
|
+
plugin/lib/presets.js 删了 = 三种玩法不能挑演法/写法,但都能用
|
|
211
|
+
plugin/lib/career.js 删了 = 没有「简历与规划」
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
实现上:这五个模块是按需 `await import()` 的,不是文件顶上的静态 import。静态 import 一个不存在的文件会让整个插件起不来,那就不是「能删」而是「删了就全废」。
|
|
215
|
+
|
|
155
216
|
### 看工作(求职)
|
|
156
217
|
|
|
157
218
|
首页点 **看工作**。把一份招聘说明整段贴进来,点 **看看这份工作**。
|
package/lib/career.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* career —— 写简历、规划以后往哪走。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一层:简历和「人物卡」不是一回事。卡记的是「一个人是谁」,
|
|
5
|
+
* 这里记的是「他学过什么、做过什么、下一步去哪」——这些内容会变,而且用户要能自己改。
|
|
6
|
+
*
|
|
7
|
+
* 落盘形状(用户拿 Finder 就能看、能改):
|
|
8
|
+
* ```
|
|
9
|
+
* career/
|
|
10
|
+
* profile.md 现在有什么:基本情况、学过什么、做过什么、会用哪些工具、想去哪、还差什么
|
|
11
|
+
* purpose.md 以后怎么走:终点长什么样、分几步走、现在这一步、可能会卡在哪
|
|
12
|
+
* ```
|
|
13
|
+
* 只有两份文件,不按日期分文件夹:简历只有一份,规划也只有一份,改了就覆盖。
|
|
14
|
+
* 想留旧版本,让用户自己复制一份。
|
|
15
|
+
*
|
|
16
|
+
* 这一层只碰磁盘与文字:不调模型、不碰 HTTP。路由在 index.js,界面在 client.js。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs/promises'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import * as core from './core.js'
|
|
22
|
+
import { localDate } from './store.js'
|
|
23
|
+
|
|
24
|
+
/** 和简历有关的东西都放在工作区的这个文件夹里。 */
|
|
25
|
+
export const CAREER_DIR = 'career'
|
|
26
|
+
|
|
27
|
+
/** 「现在有什么」那份文件。路径写死,不从外面接收。 */
|
|
28
|
+
export const PROFILE_REL = 'career/profile.md'
|
|
29
|
+
|
|
30
|
+
/** 「以后怎么走」那份文件。路径写死,不从外面接收。 */
|
|
31
|
+
export const PURPOSE_REL = 'career/purpose.md'
|
|
32
|
+
|
|
33
|
+
/** 简历正文的六个小节。这个顺序就是写进文件的顺序。 */
|
|
34
|
+
export const PROFILE_SECTIONS = ['基本情况', '学过什么', '做过什么', '会用哪些工具', '想去哪', '还差什么']
|
|
35
|
+
|
|
36
|
+
/** 简历里一行一条的那几个小节,其余小节是一段自由文字。 */
|
|
37
|
+
export const PROFILE_LIST_SECTIONS = ['学过什么', '做过什么', '会用哪些工具', '还差什么']
|
|
38
|
+
|
|
39
|
+
/** 规划正文的四个小节。 */
|
|
40
|
+
export const PLAN_SECTIONS = ['终点长什么样', '分几步走', '现在这一步', '可能会卡在哪']
|
|
41
|
+
|
|
42
|
+
/** 规划里一行一条的那个小节。 */
|
|
43
|
+
export const PLAN_LIST_SECTIONS = ['分几步走']
|
|
44
|
+
|
|
45
|
+
/** frontmatter 里的 kind 值。用来分清这个文件到底是简历还是规划。 */
|
|
46
|
+
const PROFILE_KIND = 'career-profile'
|
|
47
|
+
const PLAN_KIND = 'career-plan'
|
|
48
|
+
|
|
49
|
+
// ── 一行一条的小节:写入 / 读出 ──────────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// 一条内容里如果带换行,直接写进文件会被读成两条。所以写入前把换行记成 `\n`
|
|
52
|
+
// 这两个字符,读回来再还原;反斜杠本身也要先转义,否则分不清哪个 `\` 是原来的。
|
|
53
|
+
// 用户自己手写的内容不带这两个字符,写进去就是原样。
|
|
54
|
+
|
|
55
|
+
function escapeItem(text) {
|
|
56
|
+
return String(text ?? '')
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/\\/g, '\\\\')
|
|
59
|
+
.replace(/\r\n|\r|\n/g, '\\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unescapeItem(text) {
|
|
63
|
+
return String(text ?? '').replace(/\\(\\|n|r)/g, (_, c) => (c === 'n' ? '\n' : c === 'r' ? '\r' : '\\'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 把「学过什么」这类小节的值统一成一个字符串数组。 */
|
|
67
|
+
function asItems(value) {
|
|
68
|
+
if (Array.isArray(value)) return value.map((v) => String(v ?? '').trim()).filter((v) => v !== '')
|
|
69
|
+
const text = String(value ?? '').trim()
|
|
70
|
+
if (text === '') return []
|
|
71
|
+
return text
|
|
72
|
+
.split(/\r?\n/)
|
|
73
|
+
.map((line) => line.replace(/^\s*(?:[-*+]|\d+\s*[.、).])\s*/, '').trim())
|
|
74
|
+
.filter((line) => line !== '')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 写文件用:每条一行,内容里的换行已经转义过。 */
|
|
78
|
+
function renderList(value) {
|
|
79
|
+
return asItems(value)
|
|
80
|
+
.map(escapeItem)
|
|
81
|
+
.filter((line) => line !== '')
|
|
82
|
+
.map((line) => `- ${line}`)
|
|
83
|
+
.join('\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 拼提示词用:原样输出,不转义,让模型看到的是正常的多行文字。 */
|
|
87
|
+
function plainList(value) {
|
|
88
|
+
return asItems(value)
|
|
89
|
+
.map((item) => `- ${item}`)
|
|
90
|
+
.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 一段自由文字。多行就原样保留。 */
|
|
94
|
+
function freeText(value) {
|
|
95
|
+
if (Array.isArray(value)) return value.map((v) => String(v ?? '').trim()).filter((v) => v !== '').join('\n')
|
|
96
|
+
return String(value ?? '').trim()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 读一个「一行一条」的小节:去掉行首的 `-`、`*`、`1.` 这些记号,再还原转义。 */
|
|
100
|
+
function parseList(body) {
|
|
101
|
+
const out = []
|
|
102
|
+
for (const raw of String(body ?? '').split(/\r?\n/)) {
|
|
103
|
+
let line = raw.trim()
|
|
104
|
+
if (line === '') continue
|
|
105
|
+
if (/^#{1,6}\s/.test(line)) continue
|
|
106
|
+
line = line.replace(/^\s*(?:[-*+]|\d+\s*[.、).])\s*/, '').trim()
|
|
107
|
+
const text = unescapeItem(line)
|
|
108
|
+
if (text !== '') out.push(text)
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── 整份文件:写入 / 读出 ──────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 一份文件:frontmatter + 若干 `## 小节`。
|
|
117
|
+
*
|
|
118
|
+
* 空小节也把标题写出来,用户打开文件就知道还能填什么,不用对着空白想字段名。
|
|
119
|
+
*/
|
|
120
|
+
function renderDoc(head, data, order, listSections) {
|
|
121
|
+
const blocks = order.map((title) => {
|
|
122
|
+
const body = listSections.includes(title) ? renderList(data?.[title]) : freeText(data?.[title])
|
|
123
|
+
return body === '' ? `## ${title}` : `## ${title}\n\n${body}`
|
|
124
|
+
})
|
|
125
|
+
return `---\n${core.stringifyYaml(head)}\n---\n\n${blocks.join('\n\n')}\n`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 读一份文件。读不成的时候给的是人话,不是解析器的报错。
|
|
130
|
+
* @param {string} text
|
|
131
|
+
* @param {{kind: string, sections: string[], listSections: string[], keep: string[], error: string, wrongKind: string}} spec
|
|
132
|
+
*/
|
|
133
|
+
function parseDoc(text, spec) {
|
|
134
|
+
const parsed = core.parseCard(String(text ?? ''))
|
|
135
|
+
if (parsed.ok !== true) return { ok: false, error: spec.error }
|
|
136
|
+
const head = parsed.data ?? {}
|
|
137
|
+
const kind = String(head.kind ?? '').trim()
|
|
138
|
+
if (kind !== '' && kind !== spec.kind) return { ok: false, error: spec.wrongKind }
|
|
139
|
+
|
|
140
|
+
const data = {}
|
|
141
|
+
for (const key of spec.keep) {
|
|
142
|
+
const value = head[key]
|
|
143
|
+
if (value !== undefined && value !== null) data[key] = String(value).trim()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sections = {}
|
|
147
|
+
for (const title of spec.sections) {
|
|
148
|
+
const body = core.sectionBody(parsed.sections ?? [], title)
|
|
149
|
+
sections[title] = spec.listSections.includes(title) ? parseList(body) : String(body ?? '').trim()
|
|
150
|
+
data[title] = sections[title]
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, data, sections }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 读一份文件。找不到是正常状态(还没写过),不报错。 */
|
|
156
|
+
async function readDoc(root, rel, parse, label) {
|
|
157
|
+
let text
|
|
158
|
+
try {
|
|
159
|
+
text = await fs.readFile(path.join(root, rel), 'utf8')
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error?.code === 'ENOENT') return { ok: true, data: null, sections: null, missing: true }
|
|
162
|
+
return { ok: false, error: `${label}读不出来。检查一下这个文件夹的权限。` }
|
|
163
|
+
}
|
|
164
|
+
const parsed = parse(text)
|
|
165
|
+
if (parsed.ok !== true) return parsed
|
|
166
|
+
return { ok: true, path: rel, text, data: parsed.data, sections: parsed.sections }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 写一份文件。
|
|
171
|
+
*
|
|
172
|
+
* 先写临时文件再改名:改名是一次替换,掉电或者磁盘写满的时候,
|
|
173
|
+
* 原来那份要么还是旧的完整版本,要么已经是新的完整版本,不会留下半份。
|
|
174
|
+
* `rel` 只由本文件里的常量传进来,外面传的路径一律不用。
|
|
175
|
+
*/
|
|
176
|
+
async function writeDoc(root, rel, text, label) {
|
|
177
|
+
const abs = path.join(root, rel)
|
|
178
|
+
const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
179
|
+
try {
|
|
180
|
+
await fs.mkdir(path.dirname(abs), { recursive: true })
|
|
181
|
+
await fs.writeFile(tmp, text, 'utf8')
|
|
182
|
+
await fs.rename(tmp, abs)
|
|
183
|
+
} catch {
|
|
184
|
+
await fs.rm(tmp, { force: true }).catch(() => {})
|
|
185
|
+
return { ok: false, error: `${label}没能存下来。检查一下磁盘是不是满了。` }
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, path: rel, chars: text.length }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── 简历:profile.md ─────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 简历写成 Markdown。
|
|
194
|
+
*
|
|
195
|
+
* 只写用户自己填进去的东西。没填的小节留一个标题空着,方便他下次接着写。
|
|
196
|
+
*/
|
|
197
|
+
export function renderProfileMarkdown(data) {
|
|
198
|
+
const d = data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {}
|
|
199
|
+
const name = String(d.name ?? '').trim()
|
|
200
|
+
const updated = String(d.updated_at ?? '').trim()
|
|
201
|
+
const head = {
|
|
202
|
+
lifecard: core.DATA_VERSION,
|
|
203
|
+
kind: PROFILE_KIND,
|
|
204
|
+
name: name === '' ? '未命名' : name,
|
|
205
|
+
updated_at: updated === '' ? localDate() : updated,
|
|
206
|
+
}
|
|
207
|
+
return renderDoc(head, d, PROFILE_SECTIONS, PROFILE_LIST_SECTIONS)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 反过来:把 profile.md 读成 `{data, sections}`。 */
|
|
211
|
+
export function parseProfileText(text) {
|
|
212
|
+
return parseDoc(String(text ?? ''), {
|
|
213
|
+
kind: PROFILE_KIND,
|
|
214
|
+
sections: PROFILE_SECTIONS,
|
|
215
|
+
listSections: PROFILE_LIST_SECTIONS,
|
|
216
|
+
keep: ['name', 'updated_at'],
|
|
217
|
+
error: '这份简历读不出来,格式不对',
|
|
218
|
+
wrongKind: '这个文件不是简历,是别的文件。',
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 读简历。还没写过返回 `{ok: true, data: null}`——没简历是正常状态,不是出错。 */
|
|
223
|
+
export async function readProfile(root) {
|
|
224
|
+
return readDoc(root, PROFILE_REL, parseProfileText, '简历')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 写简历。
|
|
229
|
+
*
|
|
230
|
+
* 只收 `root` 和内容,不收路径:路径固定是 `career/profile.md`,
|
|
231
|
+
* 内容里就算带了 path 也不会被用到,写不到 `career/` 外面去。
|
|
232
|
+
*/
|
|
233
|
+
export async function writeProfile(root, data, now = new Date()) {
|
|
234
|
+
const input = data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {}
|
|
235
|
+
const next = { ...input }
|
|
236
|
+
next.updated_at = localDate(now)
|
|
237
|
+
const text = renderProfileMarkdown(next)
|
|
238
|
+
const written = await writeDoc(root, PROFILE_REL, text, '简历')
|
|
239
|
+
if (written.ok !== true) return written
|
|
240
|
+
return { ...written, data: next }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 规划:purpose.md ─────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/** 规划写成 Markdown。`horizon` 是这段规划管多久(3个月、1年、4年这种)。 */
|
|
246
|
+
export function renderPlanMarkdown(data) {
|
|
247
|
+
const d = data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {}
|
|
248
|
+
const updated = String(d.updated_at ?? '').trim()
|
|
249
|
+
const head = {
|
|
250
|
+
lifecard: core.DATA_VERSION,
|
|
251
|
+
kind: PLAN_KIND,
|
|
252
|
+
horizon: String(d.horizon ?? '').trim(),
|
|
253
|
+
updated_at: updated === '' ? localDate() : updated,
|
|
254
|
+
}
|
|
255
|
+
return renderDoc(head, d, PLAN_SECTIONS, PLAN_LIST_SECTIONS)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 反过来:把 purpose.md 读成 `{data, sections}`。 */
|
|
259
|
+
export function parsePlanText(text) {
|
|
260
|
+
return parseDoc(String(text ?? ''), {
|
|
261
|
+
kind: PLAN_KIND,
|
|
262
|
+
sections: PLAN_SECTIONS,
|
|
263
|
+
listSections: PLAN_LIST_SECTIONS,
|
|
264
|
+
keep: ['horizon', 'updated_at'],
|
|
265
|
+
error: '这份规划读不出来,格式不对',
|
|
266
|
+
wrongKind: '这个文件不是规划,是别的文件。',
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 读规划。还没写过返回 `{ok: true, data: null}`。 */
|
|
271
|
+
export async function readPlan(root) {
|
|
272
|
+
return readDoc(root, PURPOSE_REL, parsePlanText, '规划')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 写规划。路径同样写死,只写 `career/purpose.md`。 */
|
|
276
|
+
export async function writePlan(root, data, now = new Date()) {
|
|
277
|
+
const input = data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {}
|
|
278
|
+
const next = { ...input }
|
|
279
|
+
next.updated_at = localDate(now)
|
|
280
|
+
const text = renderPlanMarkdown(next)
|
|
281
|
+
const written = await writeDoc(root, PURPOSE_REL, text, '规划')
|
|
282
|
+
if (written.ok !== true) return written
|
|
283
|
+
return { ...written, data: next }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── 让模型干活的三段话 ────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/** 把简历的各小节排成模型看得懂的一段文字。空的小节不占地方。 */
|
|
289
|
+
function profileBlock(profile) {
|
|
290
|
+
const lines = []
|
|
291
|
+
for (const title of PROFILE_SECTIONS) {
|
|
292
|
+
const body = PROFILE_LIST_SECTIONS.includes(title) ? plainList(profile?.[title]) : freeText(profile?.[title])
|
|
293
|
+
if (body === '') continue
|
|
294
|
+
lines.push(`【${title}】`, body, '')
|
|
295
|
+
}
|
|
296
|
+
if (lines.length === 0) return '(他还没填任何材料。)'
|
|
297
|
+
return lines.join('\n').trimEnd()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 写简历的那两段话。只准写发生过的事。 */
|
|
301
|
+
export function buildResumePrompt({ profile, target } = {}) {
|
|
302
|
+
const want = String(target ?? '').trim()
|
|
303
|
+
const system = [
|
|
304
|
+
'你在帮一个大学生写简历。',
|
|
305
|
+
'你只写简历上该有的东西:学校专业、时间、做过的事、做出来的结果、会用哪些工具。',
|
|
306
|
+
'',
|
|
307
|
+
'写的时候守这几条:',
|
|
308
|
+
'- 不许编造没发生过的事。材料里没写的就留空,不要替他补一段听起来不错的经历。',
|
|
309
|
+
'- 句子要短,一句话说清一件事。',
|
|
310
|
+
'- 不用「负责」「参与」「协助」这种看不出到底做了什么的话,直接写他做了什么。',
|
|
311
|
+
'- 能量化就给数:写「改了四稿」「带了 12 个学生」,不写「多次」「大量」。',
|
|
312
|
+
'- 不写自我评价,不写「学习能力强」「能吃苦」「热爱这一行」。招聘的人不看这些。',
|
|
313
|
+
'- 没有经历的部分不要硬凑,空着比编一段强。',
|
|
314
|
+
'',
|
|
315
|
+
'输出直接用 Markdown,按「教育背景」「课程与成绩」「做过的事」「会用的工具」「想去的方向」分段。',
|
|
316
|
+
'哪一段没有内容就整段不写,不要写「暂无」。',
|
|
317
|
+
'不要解释你是怎么写的,不要问我问题,直接给简历。',
|
|
318
|
+
].join('\n')
|
|
319
|
+
|
|
320
|
+
const user = [
|
|
321
|
+
'下面是他自己填的材料:',
|
|
322
|
+
'',
|
|
323
|
+
profileBlock(profile),
|
|
324
|
+
'',
|
|
325
|
+
'【这份简历投给谁】',
|
|
326
|
+
want === '' ? '(他没填目标。按上面「想去哪」那一段自己判断。)' : want,
|
|
327
|
+
'',
|
|
328
|
+
'【要你做什么】',
|
|
329
|
+
'照上面的目标写一份能直接投出去的简历。他真有的东西写清楚,没有的不要编。',
|
|
330
|
+
].join('\n')
|
|
331
|
+
|
|
332
|
+
return { system, user }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 做长期规划的那两段话。每一步都要能动手,也要能判断做完没做完。 */
|
|
336
|
+
export function buildPathPrompt({ profile, goal, horizon } = {}) {
|
|
337
|
+
const aim = String(goal ?? '').trim()
|
|
338
|
+
const span = String(horizon ?? '').trim()
|
|
339
|
+
const system = [
|
|
340
|
+
'你在帮一个人规划接下来一段时间怎么走。要的不是愿景,是能照着做的事。',
|
|
341
|
+
'',
|
|
342
|
+
'要求:',
|
|
343
|
+
'- 每一步都要落到「下个月做什么」这个程度。不要写「多读书」「积累经验」「多了解这个行业」这种没法动手的话。',
|
|
344
|
+
'- 每一步都必须写清楚:做完没做完怎么判断。判断标准要看得见(考了多少分、写完几篇、拿到谁的回复、投出去几份),不能是「有提升」。',
|
|
345
|
+
'- 不许写鸡汤和大词。「提升自我」「赋能」「沉淀」「破圈」「找到热爱」「成为更好的自己」这类一概不许出现。',
|
|
346
|
+
'- 如果他的目标和材料明显对不上(比如目标是进地质类院所的技术岗,但学过的课、做过的事全不沾边),要直说这个矛盾,放在最前面,不要绕过去,也不要替他圆场。',
|
|
347
|
+
'- 步骤之间要有先后:前一步没做完,后一步不该开始,就在后一步里写清楚前置条件。',
|
|
348
|
+
'- 时间要具体到月份或者「第几个月」,不要写「尽快」「适当时候」。',
|
|
349
|
+
'',
|
|
350
|
+
'输出用 Markdown,分四段:',
|
|
351
|
+
'## 终点长什么样',
|
|
352
|
+
'## 分几步走',
|
|
353
|
+
'## 现在这一步',
|
|
354
|
+
'## 可能会卡在哪',
|
|
355
|
+
'「分几步走」每行一条,格式是:- 这一步做什么|大概什么时候|怎么算做完了。',
|
|
356
|
+
'不要客套,不要鼓励,直接给规划。',
|
|
357
|
+
].join('\n')
|
|
358
|
+
|
|
359
|
+
const user = [
|
|
360
|
+
'【他现在有什么】',
|
|
361
|
+
'',
|
|
362
|
+
profileBlock(profile),
|
|
363
|
+
'',
|
|
364
|
+
'【他想到哪儿】',
|
|
365
|
+
aim === '' ? '(他没写清楚要去哪。按上面「想去哪」那一段判断。)' : aim,
|
|
366
|
+
'',
|
|
367
|
+
'【这段规划管多久】',
|
|
368
|
+
span === '' ? '(他没填。按一年算。)' : span,
|
|
369
|
+
'',
|
|
370
|
+
'【要你做什么】',
|
|
371
|
+
'写一份从今天到那个终点的规划,落到下个月做什么。',
|
|
372
|
+
'如果目标和上面的材料对不上,第一段就把这个矛盾说出来。',
|
|
373
|
+
].join('\n')
|
|
374
|
+
|
|
375
|
+
return { system, user }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 差距分析的那两段话。缺的排前面,每条要具体。 */
|
|
379
|
+
export function buildGapPrompt({ profile, target } = {}) {
|
|
380
|
+
const want = String(target ?? '').trim()
|
|
381
|
+
const system = [
|
|
382
|
+
'你在做一次差距分析:他要去的地方要求什么,他现在有什么,中间差什么。',
|
|
383
|
+
'',
|
|
384
|
+
'要求:',
|
|
385
|
+
'- 把「目标要求的」和「他现在有的」并排列出来,一行一条,能对上的写在同一行。',
|
|
386
|
+
'- 缺的东西写在最前面,不要藏在后面。',
|
|
387
|
+
'- 每条必须具体到能看见:写「六级还没过」,不写「需要提升英语」;写「没写过学术论文」,不写「学术能力不足」。',
|
|
388
|
+
'- 不要写「建议多实习」这种谁都能说的话。要说清楚是哪一类单位、做什么事、大概什么时候开始。',
|
|
389
|
+
'- 材料里看不出来的地方,就写「看不出来」,不要猜。',
|
|
390
|
+
'- 只谈事实和差距,不评价他这个人。',
|
|
391
|
+
'',
|
|
392
|
+
'输出用 Markdown,三段:',
|
|
393
|
+
'## 差什么',
|
|
394
|
+
'## 目标要什么、现在有什么',
|
|
395
|
+
'## 先补哪一条',
|
|
396
|
+
'「差什么」按要紧程度排,第一条要是最难绕过去的那一条。',
|
|
397
|
+
'不要鼓励,不要总结,直接说差距。',
|
|
398
|
+
].join('\n')
|
|
399
|
+
|
|
400
|
+
const user = [
|
|
401
|
+
'【他现在有什么】',
|
|
402
|
+
'',
|
|
403
|
+
profileBlock(profile),
|
|
404
|
+
'',
|
|
405
|
+
'【目标】',
|
|
406
|
+
want === '' ? '(他没填目标。按上面「想去哪」那一段判断。)' : want,
|
|
407
|
+
'',
|
|
408
|
+
'【要你做什么】',
|
|
409
|
+
'把目标要求的和他现在有的并排列出来,缺的写在最前面。',
|
|
410
|
+
].join('\n')
|
|
411
|
+
|
|
412
|
+
return { system, user }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── 从模型输出里扒步骤 ────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/** 按 `|` 或 `|` 切三段,第三段之后的内容原样留在第三段里。 */
|
|
418
|
+
function splitPipes(text) {
|
|
419
|
+
const parts = []
|
|
420
|
+
let cur = ''
|
|
421
|
+
for (const ch of String(text ?? '')) {
|
|
422
|
+
if (parts.length < 2 && (ch === '|' || ch === '|')) {
|
|
423
|
+
parts.push(cur)
|
|
424
|
+
cur = ''
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
cur += ch
|
|
428
|
+
}
|
|
429
|
+
parts.push(cur)
|
|
430
|
+
return parts.map((part) => part.trim())
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 从一段模型输出里抽出步骤。
|
|
435
|
+
*
|
|
436
|
+
* 认两种行:
|
|
437
|
+
* - 这一步做什么|大概什么时候|怎么算做完了
|
|
438
|
+
* 1. 这一步做什么
|
|
439
|
+
* 认不出来的行直接跳过,不报错——模型常常在前后写几句解释。
|
|
440
|
+
* @returns {Array<{text: string, when: string, done: string}>}
|
|
441
|
+
*/
|
|
442
|
+
export function parseSteps(text) {
|
|
443
|
+
const out = []
|
|
444
|
+
for (const raw of String(text ?? '').split(/\r?\n/)) {
|
|
445
|
+
const line = raw.trim()
|
|
446
|
+
if (line === '') continue
|
|
447
|
+
if (/^#{1,6}\s/.test(line)) continue
|
|
448
|
+
if (/^[||]/.test(line)) continue
|
|
449
|
+
|
|
450
|
+
let body = ''
|
|
451
|
+
const bullet = /^[-*+]\s+(.+)$/.exec(line)
|
|
452
|
+
if (bullet !== null) {
|
|
453
|
+
body = bullet[1].trim()
|
|
454
|
+
} else {
|
|
455
|
+
const numbered = /^\d+\s*[.、).]\s*(.+)$/.exec(line)
|
|
456
|
+
if (numbered !== null) {
|
|
457
|
+
body = numbered[1].trim()
|
|
458
|
+
} else {
|
|
459
|
+
const chinese = /^第\s*[\d一二三四五六七八九十]+\s*步\s*[::、..]?\s*(.+)$/.exec(line)
|
|
460
|
+
if (chinese === null) continue
|
|
461
|
+
body = chinese[1].trim()
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (body === '') continue
|
|
465
|
+
const [first = '', second = '', third = ''] = splitPipes(body)
|
|
466
|
+
if (first === '' && second === '' && third === '') continue
|
|
467
|
+
out.push({ text: first, when: second, done: third })
|
|
468
|
+
}
|
|
469
|
+
return out
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export default {
|
|
473
|
+
CAREER_DIR,
|
|
474
|
+
PROFILE_REL,
|
|
475
|
+
PURPOSE_REL,
|
|
476
|
+
renderProfileMarkdown,
|
|
477
|
+
parseProfileText,
|
|
478
|
+
renderPlanMarkdown,
|
|
479
|
+
parsePlanText,
|
|
480
|
+
readProfile,
|
|
481
|
+
writeProfile,
|
|
482
|
+
readPlan,
|
|
483
|
+
writePlan,
|
|
484
|
+
buildResumePrompt,
|
|
485
|
+
buildPathPrompt,
|
|
486
|
+
buildGapPrompt,
|
|
487
|
+
parseSteps,
|
|
488
|
+
}
|