@musnows/scriverse 0.6.2 → 0.6.4
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/dist/ai-protocol.js +22 -9
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +913 -195
- package/dist/ai.js.map +1 -1
- package/dist/app.js +245 -46
- package/dist/app.js.map +1 -1
- package/dist/cli-core.js +23 -13
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +251 -2
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +89 -0
- package/dist/docx-export.js.map +1 -0
- package/dist/google-vertex-auth.js +155 -0
- package/dist/google-vertex-auth.js.map +1 -0
- package/dist/image-captcha.js +8 -5
- package/dist/image-captcha.js.map +1 -1
- package/dist/public/ai-context-meter.js +4 -0
- package/dist/public/ai-mentions.js +10 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +1004 -131
- package/dist/public/display-labels.js +2 -1
- package/dist/public/index.html +75 -16
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +155 -19
- package/dist/public/system-status.d.ts +12 -0
- package/dist/public/system-status.js +16 -0
- package/dist/public/theme-init.js +2 -2
- package/dist/security.js +101 -9
- package/dist/security.js.map +1 -1
- package/dist/store.js +290 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +62 -24
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +23 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DRAFT_SETTING_MODULES } from "./domain.js";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
4
|
+
import { exportWorkDocx } from "./docx-export.js";
|
|
4
5
|
import { AppError, notFound } from "./errors.js";
|
|
5
6
|
import { accountReference, logger } from "./logger.js";
|
|
6
7
|
import { paginated, paginationSql } from "./pagination.js";
|
|
@@ -9,6 +10,31 @@ import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModu
|
|
|
9
10
|
import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
|
|
10
11
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
11
12
|
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
|
|
13
|
+
export const WORK_AGENT_TOOL_IDS = [
|
|
14
|
+
"story_index",
|
|
15
|
+
"read_chapters",
|
|
16
|
+
"grep",
|
|
17
|
+
"search_story_entities",
|
|
18
|
+
"read_character_sections",
|
|
19
|
+
"search_drafts"
|
|
20
|
+
];
|
|
21
|
+
const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
|
|
22
|
+
export function normalizeWorkAgentTools(value) {
|
|
23
|
+
const source = Array.isArray(value)
|
|
24
|
+
? value
|
|
25
|
+
: typeof value === "string"
|
|
26
|
+
? json(value, DEFAULT_WORK_AGENT_TOOLS)
|
|
27
|
+
: DEFAULT_WORK_AGENT_TOOLS;
|
|
28
|
+
const enabled = new Set();
|
|
29
|
+
for (const item of source) {
|
|
30
|
+
if (typeof item !== "string")
|
|
31
|
+
continue;
|
|
32
|
+
const toolId = item === "query_story_knowledge" ? "search_story_entities" : item;
|
|
33
|
+
if (WORK_AGENT_TOOL_IDS.includes(toolId))
|
|
34
|
+
enabled.add(toolId);
|
|
35
|
+
}
|
|
36
|
+
return WORK_AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId));
|
|
37
|
+
}
|
|
12
38
|
const defaultPlatformPageSizes = {
|
|
13
39
|
drafts: 30,
|
|
14
40
|
settings: 30,
|
|
@@ -127,6 +153,7 @@ export const versionedEntityTypes = [
|
|
|
127
153
|
"chapter-outline",
|
|
128
154
|
"foreshadow"
|
|
129
155
|
];
|
|
156
|
+
export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
|
|
130
157
|
export function defaultAiConversationTitle(prompt) {
|
|
131
158
|
const normalized = prompt.replace(/\s+/gu, " ").trim();
|
|
132
159
|
return Array.from(normalized).slice(0, 15).join("") || "新对话";
|
|
@@ -205,6 +232,29 @@ function booleanValue(row, key) {
|
|
|
205
232
|
export function normalizeCharacterName(value) {
|
|
206
233
|
return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
|
|
207
234
|
}
|
|
235
|
+
const EMPTY_AI_INJECTED_ENTITIES = {
|
|
236
|
+
characters: [],
|
|
237
|
+
races: [],
|
|
238
|
+
organizations: []
|
|
239
|
+
};
|
|
240
|
+
function parseAiInjectedEntities(value) {
|
|
241
|
+
const parsed = typeof value === "string" ? json(value, {}) : isRecord(value) ? value : {};
|
|
242
|
+
const uniqueIds = (items) => [...new Set((Array.isArray(items) ? items : [])
|
|
243
|
+
.filter((item) => typeof item === "string" && item.trim().length > 0)
|
|
244
|
+
.map((item) => item.trim()))];
|
|
245
|
+
return {
|
|
246
|
+
characters: uniqueIds(parsed.characters),
|
|
247
|
+
races: uniqueIds(parsed.races),
|
|
248
|
+
organizations: uniqueIds(parsed.organizations)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function mergeAiInjectedEntities(base, extra) {
|
|
252
|
+
return {
|
|
253
|
+
characters: [...new Set([...base.characters, ...(extra.characters ?? [])])],
|
|
254
|
+
races: [...new Set([...base.races, ...(extra.races ?? [])])],
|
|
255
|
+
organizations: [...new Set([...base.organizations, ...(extra.organizations ?? [])])]
|
|
256
|
+
};
|
|
257
|
+
}
|
|
208
258
|
export class Store {
|
|
209
259
|
db;
|
|
210
260
|
constructor(db) {
|
|
@@ -685,6 +735,9 @@ export class Store {
|
|
|
685
735
|
return {
|
|
686
736
|
workId,
|
|
687
737
|
systemPrompt: String(row?.system_prompt ?? ""),
|
|
738
|
+
dailyTokenQuota: row?.daily_token_quota === null || row?.daily_token_quota === undefined
|
|
739
|
+
? null
|
|
740
|
+
: Math.max(10_000, Number(row.daily_token_quota)),
|
|
688
741
|
autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
|
|
689
742
|
autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
|
|
690
743
|
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
@@ -696,9 +749,9 @@ export class Store {
|
|
|
696
749
|
autoRunConsecutiveFailures: Math.max(0, Number(row?.auto_run_consecutive_failures ?? 0) || 0),
|
|
697
750
|
bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
|
|
698
751
|
contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
752
|
+
agentToolCallLimit: Math.min(48, Math.max(5, Number(row?.agent_tool_call_limit ?? 12) || 12)),
|
|
753
|
+
agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, Number(row?.agent_tool_call_global_multiplier ?? 3) || 3)),
|
|
754
|
+
agentTools: normalizeWorkAgentTools(row?.agent_tools_json),
|
|
702
755
|
titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
|
|
703
756
|
? null
|
|
704
757
|
: String(row.title_generation_model_id),
|
|
@@ -710,6 +763,9 @@ export class Store {
|
|
|
710
763
|
const current = this.getWorkAiSettings(workId);
|
|
711
764
|
const timestamp = now();
|
|
712
765
|
const nextPrompt = input.systemPrompt ?? String(current.systemPrompt);
|
|
766
|
+
const nextDailyTokenQuota = input.dailyTokenQuota === undefined
|
|
767
|
+
? (current.dailyTokenQuota === null ? null : Number(current.dailyTokenQuota))
|
|
768
|
+
: input.dailyTokenQuota;
|
|
713
769
|
const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
|
|
714
770
|
const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
|
|
715
771
|
const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
|
|
@@ -717,18 +773,22 @@ export class Store {
|
|
|
717
773
|
const nextFailureThreshold = input.autoRunFailureThreshold ?? Number(current.autoRunFailureThreshold);
|
|
718
774
|
const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
|
|
719
775
|
const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
|
|
720
|
-
const
|
|
776
|
+
const nextAgentToolCallLimit = input.agentToolCallLimit ?? Number(current.agentToolCallLimit);
|
|
777
|
+
const nextAgentToolCallGlobalMultiplier = input.agentToolCallGlobalMultiplier ?? Number(current.agentToolCallGlobalMultiplier);
|
|
778
|
+
const nextAgentTools = normalizeWorkAgentTools(input.agentTools ?? current.agentTools);
|
|
721
779
|
const nextTitleGenerationModelId = input.titleGenerationModelId === undefined
|
|
722
780
|
? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
|
|
723
781
|
: input.titleGenerationModelId?.trim() || null;
|
|
724
782
|
this.db.run(`INSERT INTO work_ai_settings (
|
|
725
|
-
work_id, system_prompt, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
783
|
+
work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
726
784
|
auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
|
|
727
785
|
auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
|
|
728
|
-
context_compact_threshold,
|
|
729
|
-
|
|
786
|
+
context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
|
|
787
|
+
agent_tools_json, title_generation_model_id, updated_at
|
|
788
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
730
789
|
ON CONFLICT(work_id) DO UPDATE SET
|
|
731
790
|
system_prompt = excluded.system_prompt,
|
|
791
|
+
daily_token_quota = excluded.daily_token_quota,
|
|
732
792
|
auto_run_enabled = excluded.auto_run_enabled,
|
|
733
793
|
auto_run_concurrency = excluded.auto_run_concurrency,
|
|
734
794
|
auto_run_batch_limit = excluded.auto_run_batch_limit,
|
|
@@ -740,11 +800,14 @@ export class Store {
|
|
|
740
800
|
auto_run_consecutive_failures = excluded.auto_run_consecutive_failures,
|
|
741
801
|
book_summary_context_percent = excluded.book_summary_context_percent,
|
|
742
802
|
context_compact_threshold = excluded.context_compact_threshold,
|
|
803
|
+
agent_tool_call_limit = excluded.agent_tool_call_limit,
|
|
804
|
+
agent_tool_call_global_multiplier = excluded.agent_tool_call_global_multiplier,
|
|
743
805
|
agent_tools_json = excluded.agent_tools_json,
|
|
744
806
|
title_generation_model_id = excluded.title_generation_model_id,
|
|
745
|
-
updated_at = excluded.updated_at`, workId, nextPrompt, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
|
|
807
|
+
updated_at = excluded.updated_at`, workId, nextPrompt, nextDailyTokenQuota, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), Math.min(48, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
|
|
746
808
|
this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
|
|
747
809
|
systemPromptChanged: input.systemPrompt !== undefined,
|
|
810
|
+
dailyTokenQuota: nextDailyTokenQuota,
|
|
748
811
|
autoRunEnabled: nextEnabled,
|
|
749
812
|
autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
|
|
750
813
|
autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
|
|
@@ -752,6 +815,8 @@ export class Store {
|
|
|
752
815
|
autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
|
|
753
816
|
bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
|
|
754
817
|
contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
|
|
818
|
+
agentToolCallLimit: Math.min(48, Math.max(5, nextAgentToolCallLimit)),
|
|
819
|
+
agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)),
|
|
755
820
|
agentTools: nextAgentTools,
|
|
756
821
|
titleGenerationModelId: nextTitleGenerationModelId
|
|
757
822
|
});
|
|
@@ -856,12 +921,22 @@ export class Store {
|
|
|
856
921
|
return this.getWork(workId);
|
|
857
922
|
}
|
|
858
923
|
getWorkCover(workId) {
|
|
924
|
+
const cover = this.findWorkCover(workId);
|
|
925
|
+
if (!cover)
|
|
926
|
+
throw notFound("作品封面");
|
|
927
|
+
return cover;
|
|
928
|
+
}
|
|
929
|
+
findWorkCover(workId) {
|
|
859
930
|
this.getWork(workId);
|
|
860
931
|
const row = this.db.get("SELECT * FROM work_covers WHERE work_id = ?", workId);
|
|
861
932
|
if (!row)
|
|
862
|
-
|
|
933
|
+
return null;
|
|
934
|
+
const mimeType = requiredString(row, "mime_type");
|
|
935
|
+
if (mimeType !== "image/jpeg" && mimeType !== "image/png" && mimeType !== "image/webp") {
|
|
936
|
+
throw new AppError(500, "INVALID_COVER_MIME", "作品封面类型无效");
|
|
937
|
+
}
|
|
863
938
|
return {
|
|
864
|
-
mimeType
|
|
939
|
+
mimeType,
|
|
865
940
|
content: Buffer.from(row.content),
|
|
866
941
|
byteLength: numberValue(row, "byte_length"),
|
|
867
942
|
sha256: requiredString(row, "sha256"),
|
|
@@ -1185,14 +1260,16 @@ export class Store {
|
|
|
1185
1260
|
});
|
|
1186
1261
|
}
|
|
1187
1262
|
createChapter(workId, input) {
|
|
1188
|
-
this.
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1263
|
+
return this.db.transaction(() => {
|
|
1264
|
+
this.getWork(workId);
|
|
1265
|
+
const volume = this.getVolume(input.volumeId);
|
|
1266
|
+
if (volume.workId !== workId)
|
|
1267
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
|
|
1268
|
+
const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId);
|
|
1269
|
+
const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
|
|
1270
|
+
this.audit(workId, "chapter.created", "chapter", chapterId);
|
|
1271
|
+
return this.getChapter(chapterId);
|
|
1272
|
+
});
|
|
1196
1273
|
}
|
|
1197
1274
|
getChapter(chapterId) {
|
|
1198
1275
|
const row = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
|
|
@@ -4283,11 +4360,12 @@ export class Store {
|
|
|
4283
4360
|
const row = this.db.get("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC LIMIT 1", suggestionId);
|
|
4284
4361
|
return row ? this.mapContinuationGuard(row) : null;
|
|
4285
4362
|
}
|
|
4286
|
-
createAiConversation(workId, title = "新对话") {
|
|
4363
|
+
createAiConversation(workId, title = "新对话", taskType = null) {
|
|
4287
4364
|
this.getWork(workId);
|
|
4288
4365
|
const conversationId = id("conversation");
|
|
4289
4366
|
const timestamp = now();
|
|
4290
|
-
|
|
4367
|
+
const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
|
|
4368
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, task_type, title, agent_tools_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", conversationId, workId, taskType, title.trim() || "新对话", JSON.stringify(agentTools), timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
4291
4369
|
return this.getAiConversation(conversationId);
|
|
4292
4370
|
}
|
|
4293
4371
|
listAiConversations(workId) {
|
|
@@ -4354,10 +4432,12 @@ export class Store {
|
|
|
4354
4432
|
const compactedMessageCount = Math.min(rows.length, Math.max(0, numberValue(conversation, "compacted_message_count")));
|
|
4355
4433
|
return {
|
|
4356
4434
|
workId,
|
|
4435
|
+
roleplayCharacterId: optionalString(conversation, "roleplay_character_id"),
|
|
4357
4436
|
summary: requiredString(conversation, "compacted_summary"),
|
|
4358
4437
|
compactedMessageCount,
|
|
4359
4438
|
totalMessageCount: rows.length,
|
|
4360
4439
|
warningPending: Boolean(optionalString(conversation, "context_warning_at")),
|
|
4440
|
+
injectedEntities: parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES),
|
|
4361
4441
|
messages: rows.slice(compactedMessageCount)
|
|
4362
4442
|
.filter((message) => requiredString(message, "id") !== excludeMessageId)
|
|
4363
4443
|
.map((message) => ({
|
|
@@ -4368,6 +4448,53 @@ export class Store {
|
|
|
4368
4448
|
}))
|
|
4369
4449
|
};
|
|
4370
4450
|
}
|
|
4451
|
+
getAiConversationInjectedEntities(conversationId, workId) {
|
|
4452
|
+
const conversation = this.db.get("SELECT work_id, injected_entities_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
4453
|
+
if (!conversation)
|
|
4454
|
+
throw notFound("AI 对话");
|
|
4455
|
+
if (requiredString(conversation, "work_id") !== workId)
|
|
4456
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
4457
|
+
return parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES);
|
|
4458
|
+
}
|
|
4459
|
+
mergeAiConversationInjectedEntities(conversationId, workId, extra) {
|
|
4460
|
+
const conversation = this.db.get("SELECT work_id, injected_entities_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
4461
|
+
if (!conversation)
|
|
4462
|
+
throw notFound("AI 对话");
|
|
4463
|
+
if (requiredString(conversation, "work_id") !== workId)
|
|
4464
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
4465
|
+
const merged = mergeAiInjectedEntities(parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES), extra);
|
|
4466
|
+
this.db.run("UPDATE ai_conversations SET injected_entities_json = ?, updated_at = ? WHERE id = ?", JSON.stringify(merged), now(), conversationId);
|
|
4467
|
+
return merged;
|
|
4468
|
+
}
|
|
4469
|
+
/** 对话首轮写入 system 时钟文案;已有值则原样返回,禁止后续覆盖。 */
|
|
4470
|
+
ensureAiConversationSystemClock(conversationId, workId, candidate) {
|
|
4471
|
+
const conversation = this.db.get("SELECT work_id, system_clock_text FROM ai_conversations WHERE id = ?", conversationId);
|
|
4472
|
+
if (!conversation)
|
|
4473
|
+
throw notFound("AI 对话");
|
|
4474
|
+
if (requiredString(conversation, "work_id") !== workId)
|
|
4475
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
4476
|
+
const existing = (optionalString(conversation, "system_clock_text") ?? "").trim();
|
|
4477
|
+
if (existing)
|
|
4478
|
+
return existing;
|
|
4479
|
+
const clock = candidate.trim();
|
|
4480
|
+
if (!clock)
|
|
4481
|
+
return "";
|
|
4482
|
+
this.db.run("UPDATE ai_conversations SET system_clock_text = ? WHERE id = ? AND TRIM(system_clock_text) = ''", clock, conversationId);
|
|
4483
|
+
const refreshed = this.db.get("SELECT system_clock_text FROM ai_conversations WHERE id = ?", conversationId);
|
|
4484
|
+
return (optionalString(refreshed ?? {}, "system_clock_text") ?? clock).trim() || clock;
|
|
4485
|
+
}
|
|
4486
|
+
listCharacterNameEntries(workId) {
|
|
4487
|
+
this.getWork(workId);
|
|
4488
|
+
return this.db.all(`SELECT character_id, normalized_name, display_name, kind FROM character_names
|
|
4489
|
+
WHERE work_id = ?
|
|
4490
|
+
AND character_id NOT IN (SELECT id FROM characters WHERE work_id = ? AND merged_into_character_id IS NOT NULL)
|
|
4491
|
+
ORDER BY LENGTH(normalized_name) DESC, sort_order ASC`, workId, workId).map((row) => ({
|
|
4492
|
+
characterId: requiredString(row, "character_id"),
|
|
4493
|
+
normalizedName: requiredString(row, "normalized_name"),
|
|
4494
|
+
displayName: requiredString(row, "display_name"),
|
|
4495
|
+
kind: requiredString(row, "kind") === "alias" ? "alias" : "primary"
|
|
4496
|
+
}));
|
|
4497
|
+
}
|
|
4371
4498
|
getAiConversationTitleContext(conversationId, workId) {
|
|
4372
4499
|
const conversation = this.db.get("SELECT title, work_id FROM ai_conversations WHERE id = ?", conversationId);
|
|
4373
4500
|
if (!conversation)
|
|
@@ -4401,6 +4528,94 @@ export class Store {
|
|
|
4401
4528
|
this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", normalizedTitle, now(), conversationId);
|
|
4402
4529
|
return this.getAiConversation(conversationId);
|
|
4403
4530
|
}
|
|
4531
|
+
setAiConversationRoleplayCharacter(conversationId, characterId) {
|
|
4532
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
4533
|
+
if (!conversation)
|
|
4534
|
+
throw notFound("AI 对话");
|
|
4535
|
+
const workId = requiredString(conversation, "work_id");
|
|
4536
|
+
const previousCharacterId = optionalString(conversation, "roleplay_character_id");
|
|
4537
|
+
if (previousCharacterId === characterId)
|
|
4538
|
+
return this.getAiConversationSummary(conversationId);
|
|
4539
|
+
const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
|
|
4540
|
+
if (messageCount > 0) {
|
|
4541
|
+
throw new AppError(409, previousCharacterId ? "ROLEPLAY_CHARACTER_LOCKED" : "ROLEPLAY_CONVERSATION_STARTED", previousCharacterId ? "角色扮演对话开始后不能退出模式或更换角色卡" : "当前对话已经开始,不能中途切换为角色扮演");
|
|
4542
|
+
}
|
|
4543
|
+
if (characterId) {
|
|
4544
|
+
const character = this.getCharacter(characterId);
|
|
4545
|
+
if (String(character.workId) !== workId) {
|
|
4546
|
+
throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色卡不属于当前作品");
|
|
4547
|
+
}
|
|
4548
|
+
if (character.mergedIntoCharacterId) {
|
|
4549
|
+
throw new AppError(409, "ROLEPLAY_CHARACTER_MERGED", "已合并角色不能用于角色扮演");
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
this.db.transaction(() => {
|
|
4553
|
+
this.db.run("UPDATE ai_conversations SET roleplay_character_id = ?, task_type = CASE WHEN ? IS NOT NULL THEN 'roleplay' ELSE task_type END, updated_at = ? WHERE id = ?", characterId, characterId, now(), conversationId);
|
|
4554
|
+
this.audit(workId, "ai-conversation.roleplay-updated", "ai-conversation", conversationId, {
|
|
4555
|
+
previousCharacterId,
|
|
4556
|
+
characterId
|
|
4557
|
+
});
|
|
4558
|
+
});
|
|
4559
|
+
return this.getAiConversationSummary(conversationId);
|
|
4560
|
+
}
|
|
4561
|
+
setAiConversationTaskType(conversationId, taskType) {
|
|
4562
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
4563
|
+
if (!conversation)
|
|
4564
|
+
throw notFound("AI 对话");
|
|
4565
|
+
const workId = requiredString(conversation, "work_id");
|
|
4566
|
+
const previousCharacterId = optionalString(conversation, "roleplay_character_id");
|
|
4567
|
+
const previousTaskType = optionalString(conversation, "task_type") ?? (previousCharacterId ? "roleplay" : "chat");
|
|
4568
|
+
if (previousTaskType === taskType)
|
|
4569
|
+
return this.getAiConversationSummary(conversationId);
|
|
4570
|
+
const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
|
|
4571
|
+
if (messageCount > 0) {
|
|
4572
|
+
throw new AppError(409, "AI_CONVERSATION_TASK_LOCKED", "对话开始后不能切换任务类型");
|
|
4573
|
+
}
|
|
4574
|
+
this.db.transaction(() => {
|
|
4575
|
+
this.db.run("UPDATE ai_conversations SET task_type = ?, roleplay_character_id = CASE WHEN ? = 'roleplay' THEN roleplay_character_id ELSE NULL END, updated_at = ? WHERE id = ?", taskType, taskType, now(), conversationId);
|
|
4576
|
+
this.audit(workId, "ai-conversation.task-type-updated", "ai-conversation", conversationId, {
|
|
4577
|
+
previousTaskType,
|
|
4578
|
+
taskType
|
|
4579
|
+
});
|
|
4580
|
+
});
|
|
4581
|
+
return this.getAiConversationSummary(conversationId);
|
|
4582
|
+
}
|
|
4583
|
+
setAiConversationContextScope(conversationId, scope) {
|
|
4584
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
4585
|
+
if (!conversation)
|
|
4586
|
+
throw notFound("AI 对话");
|
|
4587
|
+
const workId = requiredString(conversation, "work_id");
|
|
4588
|
+
const assertWork = (record, code, label) => {
|
|
4589
|
+
if (String(record.workId) !== workId)
|
|
4590
|
+
throw new AppError(400, code, `${label}不属于当前作品`);
|
|
4591
|
+
};
|
|
4592
|
+
if (scope.chapterId)
|
|
4593
|
+
assertWork(this.getChapter(scope.chapterId), "CHAPTER_WORK_MISMATCH", "章节");
|
|
4594
|
+
if (scope.volumeId)
|
|
4595
|
+
assertWork(this.getVolume(scope.volumeId), "VOLUME_WORK_MISMATCH", "卷");
|
|
4596
|
+
for (const chapterId of scope.chapterIds ?? [])
|
|
4597
|
+
assertWork(this.getChapter(chapterId), "CHAPTER_WORK_MISMATCH", "章节");
|
|
4598
|
+
for (const characterId of scope.characterIds ?? [])
|
|
4599
|
+
assertWork(this.getCharacter(characterId), "CHARACTER_WORK_MISMATCH", "角色");
|
|
4600
|
+
for (const settingId of scope.settingIds ?? [])
|
|
4601
|
+
assertWork(this.getSetting(settingId), "SETTING_WORK_MISMATCH", "设定");
|
|
4602
|
+
const previousScope = json(optionalString(conversation, "context_scope_json") ?? "", { type: "none" });
|
|
4603
|
+
const serializedScope = JSON.stringify(scope);
|
|
4604
|
+
if (JSON.stringify(previousScope) === serializedScope)
|
|
4605
|
+
return this.getAiConversationSummary(conversationId);
|
|
4606
|
+
const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
|
|
4607
|
+
if (messageCount > 0) {
|
|
4608
|
+
throw new AppError(409, "AI_CONVERSATION_CONTEXT_LOCKED", "对话开始后不能切换上下文引用");
|
|
4609
|
+
}
|
|
4610
|
+
this.db.transaction(() => {
|
|
4611
|
+
this.db.run("UPDATE ai_conversations SET context_scope_json = ?, updated_at = ? WHERE id = ?", serializedScope, now(), conversationId);
|
|
4612
|
+
this.audit(workId, "ai-conversation.context-scope-updated", "ai-conversation", conversationId, {
|
|
4613
|
+
previousScope,
|
|
4614
|
+
scope
|
|
4615
|
+
});
|
|
4616
|
+
});
|
|
4617
|
+
return this.getAiConversationSummary(conversationId);
|
|
4618
|
+
}
|
|
4404
4619
|
addAiConversationMessage(conversationId, input) {
|
|
4405
4620
|
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
4406
4621
|
if (!conversation)
|
|
@@ -4444,8 +4659,13 @@ export class Store {
|
|
|
4444
4659
|
const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
|
|
4445
4660
|
const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
|
|
4446
4661
|
const forkSummary = forkCompactedCount ? requiredString(conversation, "compacted_summary") : "";
|
|
4662
|
+
const injectedEntitiesJson = optionalString(conversation, "injected_entities_json")
|
|
4663
|
+
?? JSON.stringify(EMPTY_AI_INJECTED_ENTITIES);
|
|
4664
|
+
const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
|
|
4447
4665
|
this.db.transaction(() => {
|
|
4448
|
-
this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount,
|
|
4666
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
|
|
4667
|
+
? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(requiredString(conversation, "work_id")).agentTools))
|
|
4668
|
+
: String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
4449
4669
|
for (const message of messages.slice(0, targetIndex + 1)) {
|
|
4450
4670
|
this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, request_id, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id("message"), forkId, requiredString(message, "role"), requiredString(message, "content"), requiredString(message, "citations_json"), requiredString(message, "metadata_json"), optionalString(message, "request_id"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
|
|
4451
4671
|
}
|
|
@@ -4453,6 +4673,10 @@ export class Store {
|
|
|
4453
4673
|
return this.getAiConversation(forkId);
|
|
4454
4674
|
}
|
|
4455
4675
|
mapAiConversation(row) {
|
|
4676
|
+
const roleplayCharacterId = optionalString(row, "roleplay_character_id");
|
|
4677
|
+
const roleplayCharacter = roleplayCharacterId
|
|
4678
|
+
? this.db.get("SELECT id, name, code FROM characters WHERE id = ? AND work_id = ?", roleplayCharacterId, requiredString(row, "work_id"))
|
|
4679
|
+
: undefined;
|
|
4456
4680
|
return {
|
|
4457
4681
|
id: requiredString(row, "id"),
|
|
4458
4682
|
workId: requiredString(row, "work_id"),
|
|
@@ -4462,10 +4686,36 @@ export class Store {
|
|
|
4462
4686
|
compactedMessageCount: numberValue(row, "compacted_message_count"),
|
|
4463
4687
|
hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
|
|
4464
4688
|
contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
|
|
4689
|
+
taskType: optionalString(row, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat"),
|
|
4690
|
+
contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
|
|
4691
|
+
roleplayCharacter: roleplayCharacter ? {
|
|
4692
|
+
id: requiredString(roleplayCharacter, "id"),
|
|
4693
|
+
name: requiredString(roleplayCharacter, "name"),
|
|
4694
|
+
code: requiredString(roleplayCharacter, "code")
|
|
4695
|
+
} : null,
|
|
4696
|
+
agentTools: row.agent_tools_json == null || row.agent_tools_json === undefined
|
|
4697
|
+
? null
|
|
4698
|
+
: normalizeWorkAgentTools(row.agent_tools_json),
|
|
4465
4699
|
createdAt: requiredString(row, "created_at"),
|
|
4466
4700
|
updatedAt: requiredString(row, "updated_at")
|
|
4467
4701
|
};
|
|
4468
4702
|
}
|
|
4703
|
+
/** 锁定本对话可用工具集;已锁定则保持不变,避免中途改作品设置破坏 prompt cache。 */
|
|
4704
|
+
ensureAiConversationAgentTools(conversationId, workId) {
|
|
4705
|
+
const conversation = this.db.get("SELECT id, work_id, agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
4706
|
+
if (!conversation)
|
|
4707
|
+
throw notFound("AI 对话");
|
|
4708
|
+
if (requiredString(conversation, "work_id") !== workId) {
|
|
4709
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
4710
|
+
}
|
|
4711
|
+
if (conversation.agent_tools_json != null && conversation.agent_tools_json !== undefined) {
|
|
4712
|
+
return normalizeWorkAgentTools(conversation.agent_tools_json);
|
|
4713
|
+
}
|
|
4714
|
+
const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
|
|
4715
|
+
this.db.run("UPDATE ai_conversations SET agent_tools_json = ?, updated_at = ? WHERE id = ? AND agent_tools_json IS NULL", JSON.stringify(agentTools), now(), conversationId);
|
|
4716
|
+
const locked = this.db.get("SELECT agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
4717
|
+
return normalizeWorkAgentTools(locked?.agent_tools_json ?? agentTools);
|
|
4718
|
+
}
|
|
4469
4719
|
mapAiConversationMessage(row) {
|
|
4470
4720
|
return {
|
|
4471
4721
|
id: requiredString(row, "id"),
|
|
@@ -5757,6 +6007,9 @@ export class Store {
|
|
|
5757
6007
|
if (scope.type === "settings") {
|
|
5758
6008
|
return [{ type: "settings", title: "仅设定集" }];
|
|
5759
6009
|
}
|
|
6010
|
+
if (scope.type === "settings-catalog") {
|
|
6011
|
+
return [{ type: "settings-catalog", title: "设定库" }];
|
|
6012
|
+
}
|
|
5760
6013
|
if (scope.type === "selection" && typeof scope.selection === "string") {
|
|
5761
6014
|
return [{ type: "selection", selection: scope.selection }];
|
|
5762
6015
|
}
|
|
@@ -5869,6 +6122,22 @@ export class Store {
|
|
|
5869
6122
|
}
|
|
5870
6123
|
return lines.join("\n").trimEnd() + "\n";
|
|
5871
6124
|
}
|
|
6125
|
+
async exportDocx(workId) {
|
|
6126
|
+
const tree = this.getWorkTree(workId);
|
|
6127
|
+
const cover = this.findWorkCover(workId);
|
|
6128
|
+
const volumes = tree.volumes.map((volume) => ({
|
|
6129
|
+
title: String(volume.title),
|
|
6130
|
+
chapters: volume.chapters.map((chapter) => ({
|
|
6131
|
+
title: String(chapter.title),
|
|
6132
|
+
content: String(chapter.content ?? "")
|
|
6133
|
+
}))
|
|
6134
|
+
}));
|
|
6135
|
+
return exportWorkDocx({
|
|
6136
|
+
title: String(tree.title),
|
|
6137
|
+
volumes,
|
|
6138
|
+
cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
|
|
6139
|
+
});
|
|
6140
|
+
}
|
|
5872
6141
|
listAuditLogs(workId) {
|
|
5873
6142
|
this.getWork(workId);
|
|
5874
6143
|
return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username
|