Skip to content

Commit 5c07bf5

Browse files
prplxprplx
authored andcommitted
feat: add world info and streaming markdown support
1 parent 3e970bb commit 5c07bf5

14 files changed

Lines changed: 477 additions & 260 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vellium",
33
"private": true,
4-
"version": "0.3.6",
4+
"version": "0.4.2",
55
"type": "module",
66
"main": "dist-electron/main.cjs",
77
"scripts": {

server/domain/lorebooks.ts

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ export interface LoreBookEntryData {
44
id: string;
55
name: string;
66
keys: string[];
7+
secondaryKeys: string[];
78
content: string;
89
enabled: boolean;
910
constant: boolean;
11+
selective: boolean;
12+
selectiveLogic: "and" | "or";
1013
position: string;
1114
insertionOrder: number;
1215
}
@@ -38,7 +41,29 @@ function normalizeKeyList(input: unknown): string[] {
3841
return out;
3942
}
4043

44+
function normalizeSecondaryKeys(row: Record<string, unknown>): string[] {
45+
return normalizeKeyList(
46+
row.secondaryKeys
47+
?? row.secondary_keys
48+
?? row.keysecondary
49+
?? []
50+
);
51+
}
52+
4153
function normalizePosition(input: unknown): string {
54+
if (typeof input === "number" && Number.isFinite(input)) {
55+
switch (Math.floor(input)) {
56+
case 0: return "before_char";
57+
case 1: return "after_char";
58+
case 2: return "before_scene";
59+
case 3: return "after_scene";
60+
case 4: return "before_author_note";
61+
case 5: return "after_author_note";
62+
case 6: return "before_history";
63+
case 7: return "after_history";
64+
default: return "after_char";
65+
}
66+
}
4267
const raw = String(input || "").trim().toLowerCase();
4368
if (!raw) return "after_char";
4469
if (raw === "before_character") return "before_char";
@@ -52,26 +77,50 @@ function toInsertionOrder(input: unknown, fallback: number): number {
5277
return Math.floor(parsed);
5378
}
5479

80+
function normalizeSelectiveLogic(input: unknown): "and" | "or" {
81+
if (typeof input === "string") {
82+
const raw = input.trim().toLowerCase();
83+
if (raw === "or") return "or";
84+
return "and";
85+
}
86+
const numeric = Number(input);
87+
if (!Number.isFinite(numeric)) return "and";
88+
return numeric === 1 ? "or" : "and";
89+
}
90+
91+
function normalizeEntriesInput(input: unknown): Record<string, unknown>[] {
92+
if (Array.isArray(input)) {
93+
return input.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
94+
}
95+
if (input && typeof input === "object") {
96+
return Object.values(input as Record<string, unknown>)
97+
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
98+
}
99+
return [];
100+
}
101+
55102
export function normalizeLoreBookEntries(input: unknown): LoreBookEntryData[] {
56-
if (!Array.isArray(input)) return [];
103+
const rows = normalizeEntriesInput(input);
104+
if (rows.length === 0) return [];
57105
const out: LoreBookEntryData[] = [];
58106

59-
for (let index = 0; index < input.length; index += 1) {
60-
const raw = input[index];
61-
if (!raw || typeof raw !== "object") continue;
62-
const row = raw as Record<string, unknown>;
107+
for (let index = 0; index < rows.length; index += 1) {
108+
const row = rows[index];
63109
const content = String(row.content || "").trim();
64110
if (!content) continue;
65111
const id = String(row.id || row.uid || "").trim() || `entry-${index + 1}`;
66112
out.push({
67113
id,
68-
name: String(row.name || "").trim(),
69-
keys: normalizeKeyList(row.keys),
114+
name: String(row.name || row.comment || "").trim(),
115+
keys: normalizeKeyList(row.keys ?? row.key),
116+
secondaryKeys: normalizeSecondaryKeys(row),
70117
content,
71-
enabled: row.enabled !== false,
118+
enabled: row.enabled !== false && row.disable !== true,
72119
constant: row.constant === true,
120+
selective: row.selective === true,
121+
selectiveLogic: normalizeSelectiveLogic(row.selectiveLogic ?? row.selective_logic),
73122
position: normalizePosition(row.position),
74-
insertionOrder: toInsertionOrder(row.insertion_order ?? row.insertionOrder, (index + 1) * 100)
123+
insertionOrder: toInsertionOrder(row.insertion_order ?? row.insertionOrder ?? row.order ?? row.priority, (index + 1) * 100)
75124
});
76125
}
77126

@@ -91,14 +140,35 @@ export function parseCharacterLoreBook(rawData: unknown): { name: string; descri
91140
return { name, description, entries };
92141
}
93142

143+
export function parseSillyTavernWorldInfo(rawData: unknown): { name: string; description: string; entries: LoreBookEntryData[] } | null {
144+
if (!rawData || typeof rawData !== "object" || Array.isArray(rawData)) return null;
145+
const data = rawData as Record<string, unknown>;
146+
const entries = normalizeLoreBookEntries(data.entries);
147+
if (entries.length === 0) return null;
148+
const name = String(data.name || "").trim() || "Imported World Info";
149+
const description = String(data.description || "").trim();
150+
return { name, description, entries };
151+
}
152+
153+
function matchesKeyGroup(haystack: string, keys: string[], logic: "and" | "or"): boolean {
154+
if (keys.length === 0) return true;
155+
if (logic === "or") {
156+
return keys.some((key) => matchesLoreKey(haystack, key));
157+
}
158+
return keys.every((key) => matchesLoreKey(haystack, key));
159+
}
160+
94161
export function getTriggeredLoreEntries(entries: LoreBookEntryData[], timelineTexts: string[]): LoreBookEntryData[] {
95162
const haystack = timelineTexts.join("\n").toLowerCase();
96163
return entries
97164
.filter((entry) => entry.enabled && entry.content.trim())
98165
.filter((entry) => {
99166
if (entry.constant) return true;
100167
if (entry.keys.length === 0) return false;
101-
return entry.keys.some((key) => matchesLoreKey(haystack, key));
168+
const primaryMatched = entry.keys.some((key) => matchesLoreKey(haystack, key));
169+
if (!primaryMatched) return false;
170+
if (!entry.selective || entry.secondaryKeys.length === 0) return true;
171+
return matchesKeyGroup(haystack, entry.secondaryKeys, entry.selectiveLogic);
102172
})
103173
.sort((a, b) => a.insertionOrder - b.insertionOrder);
104174
}

server/modules/chat/reasoning.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const THINK_OPEN = "<think>";
2+
const THINK_CLOSE = "</think>";
3+
const THINK_TAGS = [THINK_OPEN, THINK_CLOSE];
4+
5+
export interface ThinkSplitResult {
6+
content: string;
7+
reasoning: string;
8+
}
9+
10+
export interface ThinkStreamState {
11+
pending: string;
12+
inThink: boolean;
13+
}
14+
15+
function trailingTagPrefix(input: string): string {
16+
for (let size = Math.min(input.length, THINK_CLOSE.length); size > 0; size -= 1) {
17+
const suffix = input.slice(-size);
18+
if (THINK_TAGS.some((tag) => tag.startsWith(suffix))) {
19+
return suffix;
20+
}
21+
}
22+
return "";
23+
}
24+
25+
function appendChunk(target: ThinkSplitResult, inThink: boolean, chunk: string) {
26+
if (!chunk) return;
27+
if (inThink) {
28+
target.reasoning += chunk;
29+
} else {
30+
target.content += chunk;
31+
}
32+
}
33+
34+
function processText(state: ThinkStreamState, text: string): ThinkSplitResult {
35+
const result: ThinkSplitResult = { content: "", reasoning: "" };
36+
let index = 0;
37+
38+
while (index < text.length) {
39+
const nextTag = state.inThink
40+
? text.indexOf(THINK_CLOSE, index)
41+
: text.indexOf(THINK_OPEN, index);
42+
43+
if (nextTag === -1) {
44+
break;
45+
}
46+
47+
appendChunk(result, state.inThink, text.slice(index, nextTag));
48+
index = nextTag + (state.inThink ? THINK_CLOSE.length : THINK_OPEN.length);
49+
state.inThink = !state.inThink;
50+
}
51+
52+
const remainder = text.slice(index);
53+
const carry = trailingTagPrefix(remainder);
54+
const safe = carry ? remainder.slice(0, -carry.length) : remainder;
55+
appendChunk(result, state.inThink, safe);
56+
state.pending = carry;
57+
58+
return result;
59+
}
60+
61+
export function createThinkStreamState(): ThinkStreamState {
62+
return {
63+
pending: "",
64+
inThink: false
65+
};
66+
}
67+
68+
export function consumeThinkChunk(state: ThinkStreamState, chunk: string): ThinkSplitResult {
69+
const source = `${state.pending}${String(chunk || "")}`;
70+
state.pending = "";
71+
return processText(state, source);
72+
}
73+
74+
export function flushThinkState(state: ThinkStreamState): ThinkSplitResult {
75+
const result: ThinkSplitResult = { content: "", reasoning: "" };
76+
if (state.pending) {
77+
appendChunk(result, state.inThink, state.pending);
78+
state.pending = "";
79+
}
80+
return result;
81+
}
82+
83+
export function splitThinkContent(text: string): ThinkSplitResult {
84+
const state = createThinkStreamState();
85+
const first = consumeThinkChunk(state, text);
86+
const tail = flushThinkState(state);
87+
return {
88+
content: `${first.content}${tail.content}`,
89+
reasoning: `${first.reasoning}${tail.reasoning}`
90+
};
91+
}

0 commit comments

Comments
 (0)