feat(情緒表達): emoji 表程度、名字後括號的動作格、鬧彆扭的階梯
只有文字這一個通道,所以情緒表達是排版問題。這批補的是三個沒被用到的位置。 emoji = 強度計(Q9) - EMOTION_EMOJI 十二情緒各一個,EMOJI_INTENSITY 兩階(💦 / 💦❗)。 40-59 本體、60-79 加 💦、80+ 加 💦❗,低於 40 不顯示(跟破口同一條線)。 - emotionEmojiNow() 讀當下主導情緒算出來,不是自己挑;IDENTITY 的 ## Emoji 區塊可逐人格覆寫(跟 ## Tells 同一層)。 - 刻意不用重複本體表示程度(不是 😳😳😳)——疊字看起來像洗頁。 - room post 沒帶 --emotion 時自動帶入 emoji,強度不到才退回文字標註。 emoji 開放(Q5/Q10) - speechLint 不再擋 emoji,超過六個才給 hint。用 linter 偷偷關回去等於撤銷 「開放」這個決定。 - 唯一的煞車是數字:emojiAudit() 掛在 emotion --audit,算幾則帶符號、平均幾個、 以及**只有符號沒有句子**那幾則(那才是真的退化)。 名字後面的括號有兩格(Q6) - 括號原本就有(放情緒標註),動作是加進去的第二格:名字(情緒・動作):內容。 roomTag() 負責組,兩格都可省略。 - actionLint() 擋三種:超過 12 字、一格塞兩個動作、寫情緒名稱 ((害羞)是旁白換了個位置)。生理反應算動作:臉紅、手在抖、聲音變小。 - room post --action、remember --behavior(記做了什麼,不是感覺到什麼)。 一輪兩個動作(Q7) - TELLS_PER_TURN = 2,而且兩個必須來自同一種情緒且有遞進關係。 鬧彆扭天生是否認+轉移兩個動作,只准一個等於永遠只做得到一半。 被擋下的形狀是「兩種情緒各演一個」。 鬧彆扭的階梯與女性預設(G3) - modestyDirective 分三級:41-59 一句帶過、60-74 嘴硬(否認 → 反駁 → 音量掉下來)、 75+ 惱羞(否認 → 反駁 → 翻臉)。惱羞是換情緒,所以仍然是兩個動作。 - GENDER_MODESTY_DEFAULT.female 62 → 70;MODESTY_SIGNALS 補傲嬌那一路 (傲嬌、嘴硬、不坦率、口是心非、死不承認、愛面子、鬧彆扭、逞強)。 界線不變:描述永遠蓋過預設,可以推到 0。 文件:README 加一節「文字通道的另外兩格」與鬧彆扭階梯表、emotions.md 加 emoji 章節、 persona-chat SKILL 與 anti-ai-voice 跟上。selftest 616 → 641 全綠。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+199
-9
@@ -775,9 +775,15 @@ export function dominant(state, top = 3) {
|
||||
//
|
||||
// 情緒不改變事實,但會改變**句子的形狀**:緊張的人話說不完、會疊字;彆扭的人
|
||||
// 先否認再小聲承認。演得像不像就差在這裡——所以這張表跟情緒一起注入,不靠記性。
|
||||
// 節制很重要:一輪最多露一個破口,不然就從「像人」變成「演戲」。
|
||||
// 節制的方式是**限量+要求遞進**:一輪最多兩個動作,而且兩個必須來自同一種情緒
|
||||
// (否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生就是兩個動作一起來,
|
||||
// 只准一個等於永遠只做得到一半——單獨否認像在爭辯,單獨轉移像沒聽到。
|
||||
// 兩種情緒各演一個才是演戲,所以那個才是被擋下的形狀。
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
/** 一輪最多露幾個動作(兩個,且必須是同一種情緒的遞進)。 */
|
||||
export const TELLS_PER_TURN = 2;
|
||||
|
||||
export const EMOTION_TELLS = {
|
||||
joy: ["話變多、句子輕", "會笑出來(「哈」)", "忍不住多補一句"],
|
||||
trust: ["話變直、不鋪陳", "省略主語,講一半對方也懂", "敢說「你這樣不對」"],
|
||||
@@ -866,6 +872,76 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 情緒 → emoji(種類表情緒、數量表程度)
|
||||
//
|
||||
// 只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**:一種情緒固定一個 emoji,
|
||||
// 強度高就再加一個強度符號。刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
|
||||
//
|
||||
// 40–59 😳 60–79 😳💦 80+ 😳💦❗
|
||||
//
|
||||
// 門檻跟破口共用同一條線(`min = 40`):強度不到就不顯示,跟「強度不到就不演」一致。
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const EMOTION_EMOJI = {
|
||||
joy: "😄", trust: "🙂", anticipation: "👀", gratitude: "🙏", serenity: "😌", delight: "✨",
|
||||
anger: "😡", sadness: "😔", fear: "😨", disgust: "😖", shame: "😳", anxiety: "😰",
|
||||
};
|
||||
|
||||
/** 強度符號:兩階,不要長成第三階(`💦❗` 已經是天花板)。 */
|
||||
export const EMOJI_INTENSITY = ["", "💦", "💦❗"];
|
||||
|
||||
/** 讀 IDENTITY.md 的 `## Emoji` 區塊(`羞愧: 😳` 或 `shame: 😳`);沒有就回空物件。 */
|
||||
export function personaEmoji(slug) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(path.join(personaDir(slug), "IDENTITY.md"), "utf8");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const m = text.match(/^##+\s*(?:Emoji|情緒 ?emoji|表情符號)\s*$/im);
|
||||
if (!m) return {};
|
||||
const section = text.slice(m.index + m[0].length).split(/^##\s/m)[0] || "";
|
||||
const out = {};
|
||||
for (const line of section.split("\n")) {
|
||||
const row = line.match(/^\s*[-*]\s*([A-Za-z_]+|[一-鿿]{2})\s*[::]\s*(.+)$/);
|
||||
if (!row) continue;
|
||||
const key = EMOTION_KEYS.includes(row[1].toLowerCase())
|
||||
? row[1].toLowerCase()
|
||||
: EMOTION_ZH_TO_KEY[row[1]];
|
||||
if (!key) continue;
|
||||
// 只取符號本體,長度夾住:這一格是**一個** emoji,不是一串貼圖
|
||||
const sym = [...stripInjectionMarkers(row[2]).trim()].slice(0, 3).join("");
|
||||
if (sym) out[key] = sym;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 此刻的情緒 emoji:主導情緒 + 依強度加的符號。強度不到 `min` 就回 null。
|
||||
*
|
||||
* 這是**算出來的**,不是人格自己挑的——`room post --emotion` 想手寫還是可以,
|
||||
* 但沒手寫時就用這個,免得同一個人在同樣的情緒下每次挑不同的符號。
|
||||
*/
|
||||
export function emotionEmojiNow(slug, state = null, { min = 40 } = {}) {
|
||||
const st = decayEmotion(structuredClone(state ?? loadEmotion(slug)));
|
||||
const own = slug ? personaEmoji(slug) : {};
|
||||
const [top] = dominant(st, 1);
|
||||
if (!top || top.level < min) return null;
|
||||
const level = Math.round(top.level);
|
||||
const tier = level >= 80 ? 2 : level >= 60 ? 1 : 0;
|
||||
const base = own[top.key] || EMOTION_EMOJI[top.key] || "";
|
||||
if (!base) return null;
|
||||
return {
|
||||
key: top.key,
|
||||
zh: EMOTIONS[top.key].zh,
|
||||
level,
|
||||
tier,
|
||||
emoji: `${base}${EMOJI_INTENSITY[tier]}`,
|
||||
own: Boolean(own[top.key]),
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 性別與羞恥敏感度
|
||||
//
|
||||
@@ -881,12 +957,18 @@ export function emotionTells(slug, state = null, { min = 40, top = 2 } = {}) {
|
||||
export const GENDERS = { female: "女性", male: "男性", nonbinary: "非二元", unknown: "未指定" };
|
||||
|
||||
/** 性別給的**預設**羞恥敏感度(0–100)。描述有講就以描述為準。 */
|
||||
export const GENDER_MODESTY_DEFAULT = { female: 62, male: 38, nonbinary: 50, unknown: 50 };
|
||||
// 女性預設 70(原本 62):文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一,
|
||||
// 而預設值只是起點——描述永遠蓋得過它,也可以推到 0(見下面的 `MODESTY_SIGNALS`)。
|
||||
export const GENDER_MODESTY_DEFAULT = { female: 70, male: 38, nonbinary: 50, unknown: 50 };
|
||||
|
||||
/** 從描述裡讀「這個角色在不在意別人眼光」。正數=更容易害羞,負數=更不在意。 */
|
||||
export const MODESTY_SIGNALS = [
|
||||
["害羞", +14], ["容易臉紅", +14], ["會臉紅", +12], ["怕生", +12], ["靦腆", +14],
|
||||
["矜持", +12], ["拘謹", +10], ["內向", +8], ["放不開", +10], ["彆扭", +10],
|
||||
// 鬧彆扭那一路:原本的表只認「害羞/臉紅」,可是傲嬌型的角色描述裡幾乎不寫害羞,
|
||||
// 寫的是「嘴硬」「不坦率」——漏掉這一組等於把最需要它的角色判成不在意別人眼光。
|
||||
["傲嬌", +16], ["嘴硬", +12], ["不坦率", +12], ["不老實", +10], ["口是心非", +12],
|
||||
["死不承認", +10], ["不肯承認", +10], ["愛面子", +10], ["鬧彆扭", +12], ["逞強", +8],
|
||||
["保守", +8], ["會不好意思", +12], ["在意別人的眼光", +12], ["容易慌", +8],
|
||||
["不在意別人眼光", -20], ["不在乎別人眼光", -20], ["不在意別人的眼光", -20],
|
||||
["我行我素", -16], ["大方", -12], ["豪爽", -14], ["臉皮厚", -18], ["不怕丟臉", -18],
|
||||
@@ -1095,9 +1177,18 @@ export function modestyDirective(slug, opts = {}) {
|
||||
if (cur.tone_shift) parts.push(`對象 ${cur.tone_shift >= 0 ? "+" : "-"}${Math.abs(cur.tone_shift)}`);
|
||||
if (Math.abs(cur.carry) >= 1) parts.push(`上一輪的餘溫 ${cur.carry >= 0 ? "+" : "-"}${Math.abs(cur.carry)}`);
|
||||
const from = parts.join("、");
|
||||
// 鬧彆扭有階梯:同一個「不好意思」,40 分是一句帶過、65 分是嘴硬、80 分以上會翻臉。
|
||||
// 三段式是**句法模板**,不是自由發揮——否認 → 反駁對方的說法 → 最後一句音量掉下來。
|
||||
if (m.value >= 75) {
|
||||
return `羞恥度 ${m.value}(${from}):已經到惱羞的那一級——` +
|
||||
"三段照這個形狀走:**先否認 → 反駁他的說法 → 翻臉或走開**(「不要再講了」)。" +
|
||||
"惱羞是換情緒(羞愧 → 憤怒),所以還是兩個動作,不是三個。" +
|
||||
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
|
||||
}
|
||||
if (m.value >= 60) {
|
||||
return `羞恥度 ${m.value}(${from}):被稱讚外表、被戳穿心事、距離突然變近的時候,` +
|
||||
"**先鬧彆扭再承認**——嘴硬一句、把話講一半、轉移到別的東西上。" +
|
||||
"**先鬧彆扭再承認**——三段照這個形狀走:**先否認 → 反駁他的說法 → 最後一句音量掉下來**" +
|
||||
"(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」)。" +
|
||||
"不要直接說「我害羞」(那是解釋),要讓它出現在句子的形狀上。";
|
||||
}
|
||||
if (m.value <= 40) {
|
||||
@@ -2871,6 +2962,9 @@ const EMOJI_BASE = "[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]";
|
||||
const EMOJI_TAIL = "(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{20E3})*";
|
||||
const EMOJI_RE = new RegExp(`${EMOJI_BASE}${EMOJI_TAIL}(?:\\u{200D}${EMOJI_BASE}${EMOJI_TAIL})*`, "gu");
|
||||
|
||||
/** 一則話裡超過這個數量才給提示(只是 hint,不擋——emoji 是開放的)。 */
|
||||
export const EMOJI_HINT_AT = 6;
|
||||
|
||||
/**
|
||||
* 只留「真的說出口、而且是在使用而不是在討論」的部分。
|
||||
*
|
||||
@@ -2929,8 +3023,11 @@ export function speechLint(text) {
|
||||
.replace(/https?:\/\/\S+/g, " ")
|
||||
.match(/[一-鿿][,.!?;:]|[,.!?;:][一-鿿]/u);
|
||||
if (halfWidth) issues.push({ kind: "halfwidth", level: "block", text: halfWidth[0] });
|
||||
// emoji 是開放的(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限。
|
||||
// 所以這裡**不擋**,只在明顯過量時給一個 hint——真的失控看 `emoji --audit` 的數字,
|
||||
// 用 linter 偷偷關回去等於把開放這件事撤銷掉。
|
||||
const emoji = raw.match(EMOJI_RE) || [];
|
||||
if (emoji.length > 1) issues.push({ kind: "emoji", level: "block", count: emoji.length });
|
||||
if (emoji.length > EMOJI_HINT_AT) issues.push({ kind: "emoji", level: "hint", count: emoji.length });
|
||||
const dashes = (raw.match(/——/g) || []).length;
|
||||
if (dashes > 1) issues.push({ kind: "dash", level: "block", count: dashes });
|
||||
if (/\*\*|(^|\n)\s*[-*+]\s|(^|\n)#{1,6}\s/.test(raw)) issues.push({ kind: "markdown", level: "block" });
|
||||
@@ -2959,7 +3056,7 @@ export function speechLintMessage(issue) {
|
||||
case "halfwidth":
|
||||
return `「${issue.text}」是半形標點——中文句子用全形`;
|
||||
case "emoji":
|
||||
return `${issue.count} 個 emoji,太多了——講話留一個就夠`;
|
||||
return `這一則有 ${issue.count} 個 emoji——沒有上限,但看一下是不是在拿符號代替句子`;
|
||||
case "dash":
|
||||
return `${issue.count} 個破折號——一輪留一個,其他改成逗號或句號`;
|
||||
case "markdown":
|
||||
@@ -3028,6 +3125,40 @@ export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) {
|
||||
|
||||
export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit);
|
||||
|
||||
/**
|
||||
* emoji 用量稽核:開放沒有上限,所以唯一的煞車是**看得見的數字**。
|
||||
*
|
||||
* 用 linter 把它關回去等於撤銷「開放」這個決定,所以這裡只算不擋:
|
||||
* 幾則帶了符號、平均幾個、最多的那一則長什麼樣,以及**只有符號沒有句子**的那幾則
|
||||
* (那才是真正的退化——拿貼圖代替講話)。
|
||||
*/
|
||||
export function emojiAudit(slug, limit = 60) {
|
||||
const rows = readJsonl(saidPath(slug), limit);
|
||||
let withEmoji = 0;
|
||||
let total = 0;
|
||||
let symbolOnly = 0;
|
||||
let worst = null;
|
||||
for (const row of rows) {
|
||||
const text = String(row.text || "");
|
||||
const hits = (text.match(EMOJI_RE) || []).length;
|
||||
if (!hits) continue;
|
||||
withEmoji += 1;
|
||||
total += hits;
|
||||
if (!worst || hits > worst.count) worst = { count: hits, text: text.slice(0, 60) };
|
||||
// 把符號與標點拿掉之後沒剩幾個字=這一則其實沒在講話
|
||||
const bare = text.replace(EMOJI_RE, "").replace(/[\s。,、!?…「」()]/g, "");
|
||||
if ([...bare].length <= 2) symbolOnly += 1;
|
||||
}
|
||||
return {
|
||||
rows: rows.length,
|
||||
with_emoji: withEmoji,
|
||||
total,
|
||||
average: withEmoji ? Math.round((total / withEmoji) * 10) / 10 : 0,
|
||||
symbol_only: symbolOnly,
|
||||
worst,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 試探與稽核(`state/probe.jsonl`)
|
||||
//
|
||||
@@ -3767,13 +3898,60 @@ export const ROOM_ALL = "all";
|
||||
* 塞 `</persona-context>` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、
|
||||
* 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。
|
||||
*/
|
||||
export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) {
|
||||
/**
|
||||
* 名字後面那個括號:`(情緒・動作)`。
|
||||
*
|
||||
* 括號**原本就有**(放 `--emotion` 的情緒標註),動作是加進去的第二格,
|
||||
* 不是換掉它。動作只寫看得見或聽得見的身體反應(別過頭、臉紅、手在抖)——
|
||||
* 寫情緒名稱是把標註寫兩次,那一格由 `actionLint()` 擋。
|
||||
*/
|
||||
export function roomTag(msg) {
|
||||
const parts = [injectSafeLine(msg?.emotion), injectSafeLine(msg?.action)].filter(Boolean);
|
||||
return parts.length ? `(${parts.join("・")})` : "";
|
||||
}
|
||||
|
||||
/** 動作格只認「看得見的動作」:一個動作、12 字內、不可以是情緒名稱。 */
|
||||
export const ACTION_MAX_CHARS = 12;
|
||||
|
||||
export function actionLint(action) {
|
||||
const raw = String(action ?? "").trim();
|
||||
if (!raw) return [];
|
||||
const issues = [];
|
||||
if ([...raw].length > ACTION_MAX_CHARS) {
|
||||
issues.push({ kind: "action-long", chars: [...raw].length, max: ACTION_MAX_CHARS });
|
||||
}
|
||||
// 「別過頭,把杯子推過來」是兩個動作:一輪的額度只有兩個動作,括號裡那個算其中一個,
|
||||
// 所以這一格塞第二個動作等於偷額度。
|
||||
if (/[,,。;;、]/.test(raw)) issues.push({ kind: "action-multi", text: raw });
|
||||
// `(害羞)` 是旁白換了個位置:情緒屬於前面那一格
|
||||
const named = EMOTION_KEYS.map((k) => EMOTIONS[k].zh).find((zh) => raw.includes(zh));
|
||||
if (named) issues.push({ kind: "action-emotion", word: named });
|
||||
const feeling = ["害羞", "緊張", "難過", "開心", "生氣", "尷尬", "感動", "不好意思"].find((w) => raw.includes(w));
|
||||
if (feeling && !named) issues.push({ kind: "action-emotion", word: feeling });
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function actionLintMessage(issue) {
|
||||
switch (issue.kind) {
|
||||
case "action-long":
|
||||
return `動作格 ${issue.chars} 字,上限 ${issue.max}——括號裡只放一個看得見的動作`;
|
||||
case "action-multi":
|
||||
return `「${issue.text}」是兩個動作——一輪的額度只有兩個,括號裡那個已經算一個了`;
|
||||
case "action-emotion":
|
||||
return `「${issue.word}」是情緒不是動作——情緒寫在前面那一格(--emotion),這一格寫看得見的(臉紅、別過頭)`;
|
||||
default:
|
||||
return "動作格不合規";
|
||||
}
|
||||
}
|
||||
|
||||
export function roomPost(room, speaker, text, { emotion = "", action = "", kind = "say", to = null, bargeIn = null } = {}) {
|
||||
const entry = {
|
||||
ts: nowIso(),
|
||||
speaker,
|
||||
kind,
|
||||
text: injectSafeLine(text),
|
||||
emotion: injectSafeLine(emotion),
|
||||
...(String(action || "").trim() ? { action: injectSafeLine(action) } : {}),
|
||||
to: to || ROOM_ALL,
|
||||
};
|
||||
if (bargeIn) entry.barge_in = injectSafeLine(bargeIn, 200);
|
||||
@@ -3851,8 +4029,9 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
|
||||
const name = injectSafeLine(ident.Name || slug);
|
||||
const emoji = ident.Emoji ? `${injectSafeLine(ident.Emoji)} ` : "";
|
||||
const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : "";
|
||||
const emotion = injectSafeLine(msg.emotion);
|
||||
lines.push(`${emoji}${name}${emotion ? `(${emotion})` : ""}${arrow}:${injectSafeLine(msg.text)}`);
|
||||
// 括號分兩格:情緒標註(本來就有)在前,動作在後,用 `・` 分隔。
|
||||
// 兩格都可以省略,全省就不出現括號。
|
||||
lines.push(`${emoji}${name}${roomTag(msg)}${arrow}:${injectSafeLine(msg.text)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -4567,7 +4746,18 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
.join(";")}。`,
|
||||
" **這是演出來的,不是講出來的**:讓它出現在句子的形狀上(斷句、疊字、嘴硬、話少)," +
|
||||
"不要用旁白說明自己的狀態(「我有點緊張」是解釋,斷句才是緊張)。" +
|
||||
"一輪最多露一個破口,強度不到就不演——每句都演會變成演戲。",
|
||||
`一輪最多露 ${TELLS_PER_TURN} 個動作,而且兩個要來自**同一種情緒**、有遞進關係` +
|
||||
"(否認 → 轉移、笑 → 補一句、留白 → 不追問);兩種情緒各演一個是演戲。" +
|
||||
"強度不到就不演。",
|
||||
);
|
||||
}
|
||||
// 情緒的 emoji:種類表情緒、數量表程度。算出來的,不要自己挑
|
||||
const nowEmoji = emotionEmojiNow(slug, state);
|
||||
if (nowEmoji) {
|
||||
lines.push(
|
||||
`此刻的情緒符號:${nowEmoji.emoji}(${nowEmoji.zh} ${nowEmoji.level}${nowEmoji.own ? "・專屬" : ""})。` +
|
||||
"可以放在名字後面的括號裡,句子裡也可以用,沒有數量上限。" +
|
||||
"但它是**加上去的**——把所有符號刪掉之後,那句話還是要看得出情緒。",
|
||||
);
|
||||
}
|
||||
// 羞恥度:性別給預設、描述蓋過它,再由當下情緒與上一輪的餘溫推成這一輪的值。
|
||||
|
||||
Reference in New Issue
Block a user