1129 lines
29 KiB
TypeScript
1129 lines
29 KiB
TypeScript
type CustomWorldCreatorInputMode = 'freeform' | 'card';
|
||
|
||
export interface CreatorFactionSeedRecord {
|
||
id: string;
|
||
name: string;
|
||
publicGoal: string;
|
||
tension: string;
|
||
notes: string;
|
||
locked?: boolean;
|
||
}
|
||
|
||
export interface CreatorCharacterSeedRecord {
|
||
id: string;
|
||
name: string;
|
||
role: string;
|
||
publicMask: string;
|
||
hiddenHook: string;
|
||
relationToPlayer: string;
|
||
notes: string;
|
||
locked?: boolean;
|
||
}
|
||
|
||
export interface CreatorLandmarkSeedRecord {
|
||
id: string;
|
||
name: string;
|
||
purpose: string;
|
||
mood: string;
|
||
secret: string;
|
||
locked?: boolean;
|
||
}
|
||
|
||
export interface CustomWorldCreatorIntentRecord {
|
||
sourceMode: CustomWorldCreatorInputMode;
|
||
rawSettingText: string;
|
||
worldHook: string;
|
||
themeKeywords: string[];
|
||
toneDirectives: string[];
|
||
playerPremise: string;
|
||
openingSituation: string;
|
||
coreConflicts: string[];
|
||
keyFactions: CreatorFactionSeedRecord[];
|
||
keyCharacters: CreatorCharacterSeedRecord[];
|
||
keyLandmarks: CreatorLandmarkSeedRecord[];
|
||
iconicElements: string[];
|
||
forbiddenDirectives: string[];
|
||
}
|
||
|
||
export type ExtractedCreatorIntentPatch = Partial<
|
||
Pick<
|
||
CustomWorldCreatorIntentRecord,
|
||
| 'rawSettingText'
|
||
| 'worldHook'
|
||
| 'themeKeywords'
|
||
| 'toneDirectives'
|
||
| 'playerPremise'
|
||
| 'openingSituation'
|
||
| 'coreConflicts'
|
||
| 'keyFactions'
|
||
| 'keyCharacters'
|
||
| 'keyLandmarks'
|
||
| 'iconicElements'
|
||
| 'forbiddenDirectives'
|
||
>
|
||
> & {
|
||
replaceFields?: Array<
|
||
| 'rawSettingText'
|
||
| 'worldHook'
|
||
| 'themeKeywords'
|
||
| 'toneDirectives'
|
||
| 'playerPremise'
|
||
| 'openingSituation'
|
||
| 'coreConflicts'
|
||
| 'keyFactions'
|
||
| 'keyCharacters'
|
||
| 'keyLandmarks'
|
||
| 'iconicElements'
|
||
| 'forbiddenDirectives'
|
||
>;
|
||
};
|
||
|
||
const THEME_LEXICON = [
|
||
'武侠',
|
||
'修仙',
|
||
'仙侠',
|
||
'赛博',
|
||
'蒸汽',
|
||
'废土',
|
||
'悬疑',
|
||
'宫廷',
|
||
'海岛',
|
||
'边境',
|
||
'宗教',
|
||
'朝堂',
|
||
'奇谭',
|
||
'妖异',
|
||
'科幻',
|
||
'神秘',
|
||
'冒险',
|
||
'克苏鲁',
|
||
'侦探',
|
||
];
|
||
|
||
const TONE_LEXICON = [
|
||
'冷峻',
|
||
'克制',
|
||
'压抑',
|
||
'浪漫',
|
||
'潮湿',
|
||
'荒凉',
|
||
'悬疑',
|
||
'紧张',
|
||
'明快',
|
||
'史诗',
|
||
'残酷',
|
||
'诡异',
|
||
'黑暗',
|
||
'肃杀',
|
||
'温柔',
|
||
'宏大',
|
||
'宿命',
|
||
'神秘',
|
||
];
|
||
|
||
const RELATIONSHIP_TERMS = [
|
||
'宿敌',
|
||
'盟友',
|
||
'导师',
|
||
'师父',
|
||
'搭档',
|
||
'同伴',
|
||
'恋人',
|
||
'家人',
|
||
'兄弟',
|
||
'姐妹',
|
||
'父亲',
|
||
'母亲',
|
||
'向导',
|
||
'引路人',
|
||
'守望者',
|
||
'巡夜人',
|
||
];
|
||
|
||
const META_MESSAGE_PATTERN =
|
||
/^(请)?(总结|梳理|归纳|收一下|概括)|继续补充锚点|继续收集锚点/u;
|
||
|
||
function toText(value: unknown) {
|
||
return typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '';
|
||
}
|
||
|
||
function toStringArray(value: unknown, maxCount = 8) {
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||
0,
|
||
maxCount,
|
||
);
|
||
}
|
||
|
||
function slugify(value: string) {
|
||
const normalized = value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
|
||
.replace(/^-+|-+$/gu, '');
|
||
|
||
return normalized || 'entry';
|
||
}
|
||
|
||
function createSeedId(prefix: string, label: string, index: number) {
|
||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||
}
|
||
|
||
function clampText(value: string, maxLength: number) {
|
||
const normalized = value.trim().replace(/\s+/gu, ' ');
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
if (normalized.length <= maxLength) {
|
||
return normalized;
|
||
}
|
||
|
||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||
}
|
||
|
||
function splitSentences(text: string) {
|
||
return text
|
||
.split(/[。!?;\n]/u)
|
||
.map((sentence) => sentence.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function splitList(text: string, maxCount = 8) {
|
||
const normalized = text
|
||
.replace(/[“”"'`]/gu, '')
|
||
.replace(/^(改成|改为|换成|重设|重新设定|覆盖为|更新为)/u, '')
|
||
.replace(/^(包括|比如|例如|像是|例如说)/u, '')
|
||
.replace(/^(是|为|有|偏|走|要|想要)/u, '')
|
||
.trim();
|
||
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
|
||
return [
|
||
...new Set(
|
||
normalized
|
||
.split(/[、,,\/|;;]/u)
|
||
.map((item) => item.trim())
|
||
.filter((item) => item.length >= 2 && item.length <= 24),
|
||
),
|
||
].slice(0, maxCount);
|
||
}
|
||
|
||
function mergeStringArray(
|
||
base: string[],
|
||
patch: string[] | undefined,
|
||
maxCount: number,
|
||
) {
|
||
if (!patch || patch.length === 0) {
|
||
return [...base];
|
||
}
|
||
|
||
return [
|
||
...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]),
|
||
].slice(0, maxCount);
|
||
}
|
||
|
||
function mergeNarrativeText(base: string, patch: string | undefined) {
|
||
const nextText = toText(patch);
|
||
if (!nextText) {
|
||
return base;
|
||
}
|
||
if (!base) {
|
||
return nextText;
|
||
}
|
||
if (base.includes(nextText)) {
|
||
return base;
|
||
}
|
||
|
||
return `${base}\n${nextText}`.trim();
|
||
}
|
||
|
||
function normalizeCreatorFactionSeed(
|
||
value: unknown,
|
||
index: number,
|
||
): CreatorFactionSeedRecord | null {
|
||
if (!value || typeof value !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = value as Record<string, unknown>;
|
||
const name = toText(item.name);
|
||
const publicGoal = toText(item.publicGoal);
|
||
const tension = toText(item.tension);
|
||
const notes = toText(item.notes);
|
||
|
||
if (!name && !publicGoal && !tension && !notes) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id:
|
||
toText(item.id) ||
|
||
createSeedId('creator-faction', name || publicGoal, index),
|
||
name,
|
||
publicGoal,
|
||
tension,
|
||
notes,
|
||
locked: Boolean(item.locked),
|
||
};
|
||
}
|
||
|
||
function normalizeCreatorCharacterSeed(
|
||
value: unknown,
|
||
index: number,
|
||
): CreatorCharacterSeedRecord | null {
|
||
if (!value || typeof value !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = value as Record<string, unknown>;
|
||
const name = toText(item.name);
|
||
const role = toText(item.role);
|
||
const publicMask = toText(item.publicMask);
|
||
const hiddenHook = toText(item.hiddenHook);
|
||
const relationToPlayer = toText(item.relationToPlayer);
|
||
const notes = toText(item.notes);
|
||
|
||
if (
|
||
!name &&
|
||
!role &&
|
||
!publicMask &&
|
||
!hiddenHook &&
|
||
!relationToPlayer &&
|
||
!notes
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id:
|
||
toText(item.id) ||
|
||
createSeedId('creator-character', name || role || publicMask, index),
|
||
name,
|
||
role,
|
||
publicMask,
|
||
hiddenHook,
|
||
relationToPlayer,
|
||
notes,
|
||
locked: Boolean(item.locked),
|
||
};
|
||
}
|
||
|
||
function normalizeCreatorLandmarkSeed(
|
||
value: unknown,
|
||
index: number,
|
||
): CreatorLandmarkSeedRecord | null {
|
||
if (!value || typeof value !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = value as Record<string, unknown>;
|
||
const name = toText(item.name);
|
||
const purpose = toText(item.purpose);
|
||
const mood = toText(item.mood);
|
||
const secret = toText(item.secret);
|
||
|
||
if (!name && !purpose && !mood && !secret) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id:
|
||
toText(item.id) ||
|
||
createSeedId('creator-landmark', name || purpose || mood, index),
|
||
name,
|
||
purpose,
|
||
mood,
|
||
secret,
|
||
locked: Boolean(item.locked),
|
||
};
|
||
}
|
||
|
||
function normalizeAnchorArray<T>(
|
||
value: unknown,
|
||
normalizer: (value: unknown, index: number) => T | null,
|
||
maxCount: number,
|
||
) {
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
return value
|
||
.map((item, index) => normalizer(item, index))
|
||
.filter((item): item is T => Boolean(item))
|
||
.slice(0, maxCount);
|
||
}
|
||
|
||
function mergeSeedArray<T extends { id: string; name?: string }>(
|
||
base: T[],
|
||
patch: T[] | undefined,
|
||
maxCount: number,
|
||
mergeEntry: (current: T, next: T) => T,
|
||
) {
|
||
if (!patch || patch.length === 0) {
|
||
return [...base];
|
||
}
|
||
|
||
const nextItems = [...base];
|
||
|
||
patch.forEach((entry) => {
|
||
const normalizedName = toText(entry.name);
|
||
const existingIndex = nextItems.findIndex(
|
||
(item) =>
|
||
item.id === entry.id ||
|
||
(normalizedName &&
|
||
toText(item.name).toLowerCase() === normalizedName.toLowerCase()),
|
||
);
|
||
|
||
if (existingIndex >= 0) {
|
||
nextItems[existingIndex] = mergeEntry(nextItems[existingIndex], entry);
|
||
return;
|
||
}
|
||
|
||
nextItems.push(entry);
|
||
});
|
||
|
||
return nextItems.slice(0, maxCount);
|
||
}
|
||
|
||
function mergeCharacterSeed(
|
||
current: CreatorCharacterSeedRecord,
|
||
next: CreatorCharacterSeedRecord,
|
||
): CreatorCharacterSeedRecord {
|
||
return {
|
||
...current,
|
||
...next,
|
||
id: next.id || current.id,
|
||
name: toText(next.name) || current.name,
|
||
role: toText(next.role) || current.role,
|
||
publicMask: toText(next.publicMask) || current.publicMask,
|
||
hiddenHook: toText(next.hiddenHook) || current.hiddenHook,
|
||
relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer,
|
||
notes: toText(next.notes) || current.notes,
|
||
locked:
|
||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||
};
|
||
}
|
||
|
||
function mergeFactionSeed(
|
||
current: CreatorFactionSeedRecord,
|
||
next: CreatorFactionSeedRecord,
|
||
): CreatorFactionSeedRecord {
|
||
return {
|
||
...current,
|
||
...next,
|
||
id: next.id || current.id,
|
||
name: toText(next.name) || current.name,
|
||
publicGoal: toText(next.publicGoal) || current.publicGoal,
|
||
tension: toText(next.tension) || current.tension,
|
||
notes: toText(next.notes) || current.notes,
|
||
locked:
|
||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||
};
|
||
}
|
||
|
||
function mergeLandmarkSeed(
|
||
current: CreatorLandmarkSeedRecord,
|
||
next: CreatorLandmarkSeedRecord,
|
||
): CreatorLandmarkSeedRecord {
|
||
return {
|
||
...current,
|
||
...next,
|
||
id: next.id || current.id,
|
||
name: toText(next.name) || current.name,
|
||
purpose: toText(next.purpose) || current.purpose,
|
||
mood: toText(next.mood) || current.mood,
|
||
secret: toText(next.secret) || current.secret,
|
||
locked:
|
||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||
};
|
||
}
|
||
|
||
export function createEmptyCreatorIntentRecord(
|
||
sourceMode: CustomWorldCreatorInputMode = 'freeform',
|
||
): CustomWorldCreatorIntentRecord {
|
||
return {
|
||
sourceMode,
|
||
rawSettingText: '',
|
||
worldHook: '',
|
||
themeKeywords: [],
|
||
toneDirectives: [],
|
||
playerPremise: '',
|
||
openingSituation: '',
|
||
coreConflicts: [],
|
||
keyFactions: [],
|
||
keyCharacters: [],
|
||
keyLandmarks: [],
|
||
iconicElements: [],
|
||
forbiddenDirectives: [],
|
||
};
|
||
}
|
||
|
||
export function normalizeCreatorIntentRecord(
|
||
value: unknown,
|
||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||
): CustomWorldCreatorIntentRecord | null {
|
||
if (!value || typeof value !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const item = value as Record<string, unknown>;
|
||
const sourceMode =
|
||
item.sourceMode === 'card' || item.sourceMode === 'freeform'
|
||
? item.sourceMode
|
||
: fallbackMode;
|
||
const rawSettingText = toText(item.rawSettingText);
|
||
const worldHook = toText(item.worldHook);
|
||
const playerPremise = toText(item.playerPremise);
|
||
const openingSituation = toText(item.openingSituation);
|
||
const themeKeywords = toStringArray(item.themeKeywords, 8);
|
||
const toneDirectives = toStringArray(item.toneDirectives, 8);
|
||
const coreConflicts = toStringArray(item.coreConflicts, 6);
|
||
const iconicElements = toStringArray(item.iconicElements, 8);
|
||
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
|
||
const keyFactions = normalizeAnchorArray(
|
||
item.keyFactions,
|
||
normalizeCreatorFactionSeed,
|
||
6,
|
||
);
|
||
const keyCharacters = normalizeAnchorArray(
|
||
item.keyCharacters,
|
||
normalizeCreatorCharacterSeed,
|
||
8,
|
||
);
|
||
const keyLandmarks = normalizeAnchorArray(
|
||
item.keyLandmarks,
|
||
normalizeCreatorLandmarkSeed,
|
||
8,
|
||
);
|
||
|
||
if (
|
||
!rawSettingText &&
|
||
!worldHook &&
|
||
themeKeywords.length === 0 &&
|
||
toneDirectives.length === 0 &&
|
||
!playerPremise &&
|
||
!openingSituation &&
|
||
coreConflicts.length === 0 &&
|
||
keyFactions.length === 0 &&
|
||
keyCharacters.length === 0 &&
|
||
keyLandmarks.length === 0 &&
|
||
iconicElements.length === 0 &&
|
||
forbiddenDirectives.length === 0
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
sourceMode,
|
||
rawSettingText,
|
||
worldHook,
|
||
themeKeywords,
|
||
toneDirectives,
|
||
playerPremise,
|
||
openingSituation,
|
||
coreConflicts,
|
||
keyFactions,
|
||
keyCharacters,
|
||
keyLandmarks,
|
||
iconicElements,
|
||
forbiddenDirectives,
|
||
};
|
||
}
|
||
|
||
export function mergeCreatorIntentRecord(
|
||
current: CustomWorldCreatorIntentRecord | null | undefined,
|
||
patch: ExtractedCreatorIntentPatch | null | undefined,
|
||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||
) {
|
||
if (!patch) {
|
||
return (
|
||
normalizeCreatorIntentRecord(current, fallbackMode) ??
|
||
createEmptyCreatorIntentRecord(fallbackMode)
|
||
);
|
||
}
|
||
|
||
const base =
|
||
normalizeCreatorIntentRecord(current, fallbackMode) ??
|
||
createEmptyCreatorIntentRecord(fallbackMode);
|
||
const replaceFields = new Set(patch.replaceFields ?? []);
|
||
const patchIntent =
|
||
normalizeCreatorIntentRecord(
|
||
{
|
||
sourceMode: base.sourceMode,
|
||
...patch,
|
||
},
|
||
base.sourceMode,
|
||
) ?? createEmptyCreatorIntentRecord(base.sourceMode);
|
||
|
||
return {
|
||
...base,
|
||
rawSettingText: replaceFields.has('rawSettingText')
|
||
? toText(patchIntent.rawSettingText) || base.rawSettingText
|
||
: mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText),
|
||
worldHook: toText(patchIntent.worldHook) || base.worldHook,
|
||
themeKeywords: replaceFields.has('themeKeywords')
|
||
? [...patchIntent.themeKeywords]
|
||
: mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8),
|
||
toneDirectives: replaceFields.has('toneDirectives')
|
||
? [...patchIntent.toneDirectives]
|
||
: mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8),
|
||
playerPremise: toText(patchIntent.playerPremise) || base.playerPremise,
|
||
openingSituation:
|
||
toText(patchIntent.openingSituation) || base.openingSituation,
|
||
coreConflicts: replaceFields.has('coreConflicts')
|
||
? [...patchIntent.coreConflicts]
|
||
: mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6),
|
||
keyFactions: replaceFields.has('keyFactions')
|
||
? [...patchIntent.keyFactions]
|
||
: mergeSeedArray(
|
||
base.keyFactions,
|
||
patchIntent.keyFactions,
|
||
6,
|
||
mergeFactionSeed,
|
||
),
|
||
keyCharacters: replaceFields.has('keyCharacters')
|
||
? [...patchIntent.keyCharacters]
|
||
: mergeSeedArray(
|
||
base.keyCharacters,
|
||
patchIntent.keyCharacters,
|
||
8,
|
||
mergeCharacterSeed,
|
||
),
|
||
keyLandmarks: replaceFields.has('keyLandmarks')
|
||
? [...patchIntent.keyLandmarks]
|
||
: mergeSeedArray(
|
||
base.keyLandmarks,
|
||
patchIntent.keyLandmarks,
|
||
8,
|
||
mergeLandmarkSeed,
|
||
),
|
||
iconicElements: replaceFields.has('iconicElements')
|
||
? [...patchIntent.iconicElements]
|
||
: mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8),
|
||
forbiddenDirectives: replaceFields.has('forbiddenDirectives')
|
||
? [...patchIntent.forbiddenDirectives]
|
||
: mergeStringArray(
|
||
base.forbiddenDirectives,
|
||
patchIntent.forbiddenDirectives,
|
||
8,
|
||
),
|
||
} satisfies CustomWorldCreatorIntentRecord;
|
||
}
|
||
|
||
export function hasMeaningfulCreatorIntentRecord(
|
||
intent: CustomWorldCreatorIntentRecord | null | undefined,
|
||
) {
|
||
return Boolean(
|
||
intent &&
|
||
(intent.rawSettingText ||
|
||
intent.worldHook ||
|
||
intent.themeKeywords.length > 0 ||
|
||
intent.toneDirectives.length > 0 ||
|
||
intent.playerPremise ||
|
||
intent.openingSituation ||
|
||
intent.coreConflicts.length > 0 ||
|
||
intent.keyFactions.length > 0 ||
|
||
intent.keyCharacters.length > 0 ||
|
||
intent.keyLandmarks.length > 0 ||
|
||
intent.iconicElements.length > 0 ||
|
||
intent.forbiddenDirectives.length > 0),
|
||
);
|
||
}
|
||
|
||
function buildAnchorLine(label: string, content: string) {
|
||
return content ? `${label}:${content}` : '';
|
||
}
|
||
|
||
export function buildCreatorIntentDisplayText(
|
||
intent: CustomWorldCreatorIntentRecord | null | undefined,
|
||
) {
|
||
if (!hasMeaningfulCreatorIntentRecord(intent)) {
|
||
return '';
|
||
}
|
||
|
||
const lines = [
|
||
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
|
||
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
|
||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||
buildAnchorLine(
|
||
'主题气质',
|
||
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
|
||
.filter(Boolean)
|
||
.join('、'),
|
||
),
|
||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||
].filter(Boolean);
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export function buildDraftTitleFromIntent(
|
||
intent: CustomWorldCreatorIntentRecord | null | undefined,
|
||
) {
|
||
return (
|
||
clampText(intent?.worldHook || '', 24) ||
|
||
clampText(intent?.rawSettingText || '', 24) ||
|
||
'未命名草稿'
|
||
);
|
||
}
|
||
|
||
export function buildDraftSummaryFromIntent(
|
||
intent: CustomWorldCreatorIntentRecord | null | undefined,
|
||
) {
|
||
const summary = buildCreatorIntentDisplayText(intent);
|
||
if (summary) {
|
||
return clampText(summary.replace(/\n+/gu, ' · '), 180);
|
||
}
|
||
|
||
return (
|
||
clampText(intent?.rawSettingText || '', 180) || '还在收集你的世界锚点。'
|
||
);
|
||
}
|
||
|
||
export function buildAnchorPackFromIntent(
|
||
intent: CustomWorldCreatorIntentRecord | null | undefined,
|
||
options: {
|
||
completedKeys?: string[];
|
||
missingKeys?: string[];
|
||
} = {},
|
||
) {
|
||
return {
|
||
worldSummary: clampText(
|
||
intent?.worldHook || intent?.rawSettingText || '',
|
||
96,
|
||
),
|
||
creatorIntentSummary: clampText(buildDraftSummaryFromIntent(intent), 180),
|
||
completedKeys: [...(options.completedKeys ?? [])],
|
||
missingKeys: [...(options.missingKeys ?? [])],
|
||
keyCharacterAnchors:
|
||
intent?.keyCharacters.map((entry) => ({
|
||
id: entry.id,
|
||
name: entry.name || '未命名关键人物',
|
||
summary: clampText(
|
||
[entry.role, entry.relationToPlayer, entry.hiddenHook]
|
||
.filter(Boolean)
|
||
.join(';'),
|
||
60,
|
||
),
|
||
})) ?? [],
|
||
motifDirectives: [
|
||
...(intent?.themeKeywords ?? []),
|
||
...(intent?.toneDirectives ?? []),
|
||
...(intent?.iconicElements ?? []),
|
||
].slice(0, 12),
|
||
};
|
||
}
|
||
|
||
function findSentenceByPattern(text: string, pattern: RegExp) {
|
||
return splitSentences(text).find((sentence) => pattern.test(sentence)) ?? '';
|
||
}
|
||
|
||
function extractAfterCue(text: string, cues: string[]) {
|
||
const sentences = splitSentences(text);
|
||
|
||
for (const sentence of sentences) {
|
||
for (const cue of cues) {
|
||
const index = sentence.indexOf(cue);
|
||
if (index < 0) {
|
||
continue;
|
||
}
|
||
|
||
const candidate = sentence
|
||
.slice(index + cue.length)
|
||
.replace(/^[::,,是为偏走要想]+/u, '')
|
||
.trim();
|
||
if (candidate) {
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function escapeRegExp(value: string) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||
}
|
||
|
||
function isExplicitRewrite(text: string, cues: string[]) {
|
||
const rewritePattern = /(改成|改为|换成|重设|重新设定|覆盖为|更新为)/u;
|
||
|
||
return cues.some((cue) => {
|
||
const escapedCue = escapeRegExp(cue);
|
||
return (
|
||
new RegExp(
|
||
`${escapedCue}[^。!?;\\n]{0,28}${rewritePattern.source}`,
|
||
'u',
|
||
).test(text) ||
|
||
new RegExp(
|
||
`${rewritePattern.source}[^。!?;\\n]{0,28}${escapedCue}`,
|
||
'u',
|
||
).test(text)
|
||
);
|
||
});
|
||
}
|
||
|
||
function extractWorldHook(text: string, contextText: string) {
|
||
const explicit =
|
||
extractAfterCue(text, ['世界一句话', '核心幻想', '一句话概括', '一句话']) ||
|
||
extractAfterCue(text, ['这个世界', '整体设定', '世界设定']);
|
||
if (explicit) {
|
||
return clampText(explicit, 72);
|
||
}
|
||
|
||
const firstSentence = splitSentences(contextText)[0] ?? '';
|
||
if (firstSentence.length >= 8 && !META_MESSAGE_PATTERN.test(firstSentence)) {
|
||
return clampText(firstSentence, 72);
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function extractThemeKeywords(text: string) {
|
||
const explicitSource = extractAfterCue(text, [
|
||
'主题关键词',
|
||
'关键词',
|
||
'主题',
|
||
'题材',
|
||
]).replace(
|
||
/(?:,|,).*(气质|风格|基调|氛围|核心冲突|冲突|玩家|开局|不要|避免).*/u,
|
||
'',
|
||
);
|
||
const explicit = splitList(explicitSource);
|
||
const inferred = THEME_LEXICON.filter((entry) => text.includes(entry));
|
||
return [...new Set([...explicit, ...inferred])].slice(0, 8);
|
||
}
|
||
|
||
function extractToneDirectives(text: string) {
|
||
const explicit = splitList(
|
||
extractAfterCue(text, ['气质', '风格', '基调', '氛围', '风味']),
|
||
);
|
||
const inferred = TONE_LEXICON.filter((entry) => text.includes(entry));
|
||
return [...new Set([...explicit, ...inferred])].slice(0, 8);
|
||
}
|
||
|
||
function extractPlayerPremise(text: string) {
|
||
const explicit =
|
||
extractAfterCue(text, [
|
||
'玩家是',
|
||
'玩家身份是',
|
||
'主角是',
|
||
'你扮演',
|
||
'玩家身份',
|
||
]) || findSentenceByPattern(text, /(玩家|主角|你扮演|身份|视角)/u);
|
||
|
||
return clampText(explicit, 96);
|
||
}
|
||
|
||
function extractOpeningSituation(text: string) {
|
||
const explicit =
|
||
extractAfterCue(text, [
|
||
'开局是',
|
||
'开局',
|
||
'故事开场',
|
||
'开场',
|
||
'一开始',
|
||
'起始',
|
||
]) || findSentenceByPattern(text, /(开局|开场|一开始|故事开始|起始|初始)/u);
|
||
|
||
return clampText(explicit, 96);
|
||
}
|
||
|
||
function extractCoreConflicts(text: string) {
|
||
const explicit = splitList(
|
||
extractAfterCue(text, ['核心冲突', '冲突', '危机', '主要矛盾']),
|
||
6,
|
||
);
|
||
const inferred = splitSentences(text)
|
||
.filter((sentence) =>
|
||
/(冲突|危机|争夺|战争|对抗|灾变|失衡|威胁|追杀|背叛|悬念)/u.test(
|
||
sentence,
|
||
),
|
||
)
|
||
.map((sentence) => clampText(sentence, 72));
|
||
|
||
return [...new Set([...explicit, ...inferred])].slice(0, 6);
|
||
}
|
||
|
||
function extractForbiddenDirectives(text: string) {
|
||
return splitSentences(text)
|
||
.filter((sentence) => /(不要|避免|禁止|不能|别出现)/u.test(sentence))
|
||
.map((sentence) =>
|
||
clampText(
|
||
sentence.replace(/^(不要|避免|禁止|不能|别出现)/u, '').trim() ||
|
||
sentence,
|
||
48,
|
||
),
|
||
)
|
||
.filter(Boolean)
|
||
.slice(0, 8);
|
||
}
|
||
|
||
function extractIconicElements(text: string) {
|
||
const explicit = splitList(
|
||
extractAfterCue(text, [
|
||
'标志性元素',
|
||
'标志性要素',
|
||
'标志元素',
|
||
'视觉符号',
|
||
'核心意象',
|
||
'一眼能认出来的设定',
|
||
]),
|
||
);
|
||
|
||
return explicit.slice(0, 8);
|
||
}
|
||
|
||
function extractCharacterName(sentence: string) {
|
||
const matchers = [
|
||
/叫([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})/u,
|
||
/名为([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})/u,
|
||
/([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})(?:是|作为|担任)/u,
|
||
];
|
||
|
||
for (const matcher of matchers) {
|
||
const matched = sentence.match(matcher);
|
||
const candidate = toText(matched?.[1]);
|
||
if (
|
||
candidate &&
|
||
!['玩家', '主角', '世界', '故事', '开局', '气质'].includes(candidate)
|
||
) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function extractRelationToPlayer(sentence: string) {
|
||
const explicit = sentence.match(
|
||
/(与玩家[^,。;]+|和玩家[^,。;]+|对玩家[^,。;]+|主角的[^,。;]+)/u,
|
||
);
|
||
if (explicit?.[1]) {
|
||
return clampText(explicit[1], 48);
|
||
}
|
||
|
||
const relationKeyword = RELATIONSHIP_TERMS.find((entry) =>
|
||
sentence.includes(entry),
|
||
);
|
||
return relationKeyword ?? '';
|
||
}
|
||
|
||
function extractHiddenHook(sentence: string) {
|
||
const explicit = sentence.match(
|
||
/(其实[^,。;]+|暗地里[^,。;]+|暗线是[^,。;]+|秘密是[^,。;]+|真实身份[^,。;]+|真正目的[^,。;]+)/u,
|
||
);
|
||
|
||
return clampText(toText(explicit?.[1]), 64);
|
||
}
|
||
|
||
function extractRole(sentence: string, name: string) {
|
||
if (!name) {
|
||
return '';
|
||
}
|
||
|
||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||
const matcher = new RegExp(`${escapedName}(?:是|作为|担任)([^,。;]+)`, 'u');
|
||
const matched = sentence.match(matcher);
|
||
return clampText(toText(matched?.[1]), 48);
|
||
}
|
||
|
||
function extractCharacterSeeds(text: string) {
|
||
const candidateSentences = splitSentences(text).filter((sentence) =>
|
||
/(关键人物|关键角色|人物|角色|宿敌|盟友|导师|搭档|同伴|恋人|家人|与玩家|对玩家)/u.test(
|
||
sentence,
|
||
),
|
||
);
|
||
|
||
return candidateSentences
|
||
.map((sentence, index) => {
|
||
const name = extractCharacterName(sentence);
|
||
const relationToPlayer = extractRelationToPlayer(sentence);
|
||
const hiddenHook = extractHiddenHook(sentence);
|
||
const role = extractRole(sentence, name);
|
||
|
||
if (!name && !role && !relationToPlayer && !hiddenHook) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: createSeedId(
|
||
'creator-character',
|
||
name || role || hiddenHook,
|
||
index,
|
||
),
|
||
name,
|
||
role,
|
||
publicMask: '',
|
||
hiddenHook,
|
||
relationToPlayer,
|
||
notes: '',
|
||
} satisfies CreatorCharacterSeedRecord;
|
||
})
|
||
.filter((entry): entry is CreatorCharacterSeedRecord => Boolean(entry))
|
||
.slice(0, 3);
|
||
}
|
||
|
||
function shouldAppendRawSettingText(text: string) {
|
||
return text.length >= 8 && !META_MESSAGE_PATTERN.test(text);
|
||
}
|
||
|
||
export function extractCreatorIntentPatch(params: {
|
||
currentIntent: CustomWorldCreatorIntentRecord | null | undefined;
|
||
latestUserMessage: string;
|
||
recentMessages?: string[];
|
||
}) {
|
||
const currentIntent =
|
||
normalizeCreatorIntentRecord(params.currentIntent) ??
|
||
createEmptyCreatorIntentRecord('freeform');
|
||
const latestUserMessage = toText(params.latestUserMessage);
|
||
const recentMessages = (params.recentMessages ?? [])
|
||
.map((entry) => toText(entry))
|
||
.filter(Boolean)
|
||
.slice(-10);
|
||
const contextText = [...recentMessages, latestUserMessage].join('\n');
|
||
|
||
if (!latestUserMessage) {
|
||
return {} satisfies ExtractedCreatorIntentPatch;
|
||
}
|
||
|
||
const patch: ExtractedCreatorIntentPatch = {};
|
||
const markReplace = (
|
||
field: NonNullable<ExtractedCreatorIntentPatch['replaceFields']>[number],
|
||
) => {
|
||
patch.replaceFields = [...new Set([...(patch.replaceFields ?? []), field])];
|
||
};
|
||
|
||
if (shouldAppendRawSettingText(latestUserMessage)) {
|
||
patch.rawSettingText = latestUserMessage;
|
||
}
|
||
|
||
const worldHook = extractWorldHook(
|
||
latestUserMessage,
|
||
currentIntent.worldHook ? latestUserMessage : contextText,
|
||
);
|
||
if (worldHook) {
|
||
patch.worldHook = worldHook;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, [
|
||
'世界一句话',
|
||
'核心幻想',
|
||
'一句话概括',
|
||
'这个世界',
|
||
'世界设定',
|
||
])
|
||
) {
|
||
markReplace('worldHook');
|
||
}
|
||
}
|
||
|
||
const themeKeywords = extractThemeKeywords(latestUserMessage);
|
||
if (themeKeywords.length > 0) {
|
||
patch.themeKeywords = themeKeywords;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, [
|
||
'主题关键词',
|
||
'关键词',
|
||
'主题',
|
||
'题材',
|
||
])
|
||
) {
|
||
markReplace('themeKeywords');
|
||
}
|
||
}
|
||
|
||
const toneDirectives = extractToneDirectives(latestUserMessage);
|
||
if (toneDirectives.length > 0) {
|
||
patch.toneDirectives = toneDirectives;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, ['气质', '风格', '基调', '氛围'])
|
||
) {
|
||
markReplace('toneDirectives');
|
||
}
|
||
}
|
||
|
||
const playerPremise = extractPlayerPremise(latestUserMessage);
|
||
if (playerPremise) {
|
||
patch.playerPremise = playerPremise;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, ['玩家', '玩家身份', '主角', '身份'])
|
||
) {
|
||
markReplace('playerPremise');
|
||
}
|
||
}
|
||
|
||
const openingSituation = extractOpeningSituation(latestUserMessage);
|
||
if (openingSituation) {
|
||
patch.openingSituation = openingSituation;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, ['开局', '故事开场', '开场', '起始'])
|
||
) {
|
||
markReplace('openingSituation');
|
||
}
|
||
}
|
||
|
||
const coreConflicts = extractCoreConflicts(latestUserMessage);
|
||
if (coreConflicts.length > 0) {
|
||
patch.coreConflicts = coreConflicts;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, [
|
||
'核心冲突',
|
||
'冲突',
|
||
'危机',
|
||
'主要矛盾',
|
||
])
|
||
) {
|
||
markReplace('coreConflicts');
|
||
}
|
||
}
|
||
|
||
const keyCharacters = extractCharacterSeeds(latestUserMessage);
|
||
if (keyCharacters.length > 0) {
|
||
patch.keyCharacters = keyCharacters;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, [
|
||
'关键人物',
|
||
'关键角色',
|
||
'人物',
|
||
'角色',
|
||
])
|
||
) {
|
||
markReplace('keyCharacters');
|
||
}
|
||
}
|
||
|
||
const iconicElements = extractIconicElements(latestUserMessage);
|
||
if (iconicElements.length > 0) {
|
||
patch.iconicElements = iconicElements;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, [
|
||
'标志性元素',
|
||
'标志性要素',
|
||
'标志元素',
|
||
'视觉符号',
|
||
'核心意象',
|
||
])
|
||
) {
|
||
markReplace('iconicElements');
|
||
}
|
||
}
|
||
|
||
const forbiddenDirectives = extractForbiddenDirectives(latestUserMessage);
|
||
if (forbiddenDirectives.length > 0) {
|
||
patch.forbiddenDirectives = forbiddenDirectives;
|
||
if (
|
||
isExplicitRewrite(latestUserMessage, ['禁忌', '禁止事项', '不要', '避免'])
|
||
) {
|
||
markReplace('forbiddenDirectives');
|
||
}
|
||
}
|
||
|
||
return patch;
|
||
}
|