@musnows/scriverse 0.3.0 → 0.3.1
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.en.md +1 -1
- package/README.md +1 -1
- package/dist/ai.js +182 -8
- package/dist/ai.js.map +1 -1
- package/dist/app.js +41 -8
- package/dist/app.js.map +1 -1
- package/dist/database.js +108 -10
- package/dist/database.js.map +1 -1
- package/dist/http-logging.js +76 -0
- package/dist/http-logging.js.map +1 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/public/app.js +211 -31
- package/dist/public/index.html +4 -4
- package/dist/public/styles.css +44 -2
- package/dist/request-context.js +10 -3
- package/dist/request-context.js.map +1 -1
- package/dist/security.js +12 -0
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +22 -9
- package/dist/server-runtime.js.map +1 -1
- package/dist/server.js +12 -1
- package/dist/server.js.map +1 -1
- package/dist/store.js +80 -4
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +33 -7
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
package/README.md
CHANGED
package/dist/ai.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
2
2
|
import { AppError, notFound } from "./errors.js";
|
|
3
|
+
import { logger, sanitizeError } from "./logger.js";
|
|
3
4
|
import { currentRequestActor } from "./request-context.js";
|
|
4
5
|
import { fetchSafeAiEndpoint } from "./security.js";
|
|
5
6
|
import { clamp, id, json, maskSecret, normalizeBaseUrl, now } from "./utils.js";
|
|
6
7
|
import { z } from "zod";
|
|
8
|
+
export function aiErrorForLog(error) {
|
|
9
|
+
const sanitized = sanitizeError(error);
|
|
10
|
+
const message = typeof sanitized.message === "string" ? sanitized.message : "AI operation failed";
|
|
11
|
+
const httpStatus = message.match(/^HTTP (\d{3}):/u)?.[1];
|
|
12
|
+
if (httpStatus)
|
|
13
|
+
return { name: sanitized.name ?? "Error", message: `Provider returned HTTP ${httpStatus}` };
|
|
14
|
+
if (message.includes("returned invalid JSON"))
|
|
15
|
+
return { name: sanitized.name ?? "Error", message: "Provider returned invalid JSON" };
|
|
16
|
+
return sanitized;
|
|
17
|
+
}
|
|
7
18
|
const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
|
|
8
19
|
const DEFAULT_MAX_TOKENS = 32_000;
|
|
9
20
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
10
|
-
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "query_story_knowledge"];
|
|
21
|
+
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "query_story_knowledge"];
|
|
11
22
|
const MAX_AGENT_TOOL_ROUNDS = 6;
|
|
12
23
|
const MAX_AGENT_TOOL_CALLS = 12;
|
|
13
24
|
const storyIndexArguments = z.object({
|
|
@@ -18,6 +29,10 @@ const readChaptersArguments = z.object({
|
|
|
18
29
|
chapterIds: z.array(z.string().min(1).max(200)).min(1).max(3),
|
|
19
30
|
include: z.enum(["summary", "content", "both"]).default("both")
|
|
20
31
|
}).strict();
|
|
32
|
+
const grepArguments = z.object({
|
|
33
|
+
keyword: z.string().trim().min(1).max(200),
|
|
34
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
35
|
+
}).strict();
|
|
21
36
|
const queryStoryKnowledgeArguments = z.object({
|
|
22
37
|
query: z.string().trim().min(1).max(200),
|
|
23
38
|
categories: z.array(z.enum(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"])).max(8).default([])
|
|
@@ -39,6 +54,14 @@ const AGENT_TOOL_DEFINITIONS = {
|
|
|
39
54
|
parameters: { type: "object", properties: { chapterIds: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3 }, include: { type: "string", enum: ["summary", "content", "both"] } }, required: ["chapterIds"], additionalProperties: false }
|
|
40
55
|
}
|
|
41
56
|
},
|
|
57
|
+
grep: {
|
|
58
|
+
type: "function",
|
|
59
|
+
function: {
|
|
60
|
+
name: "grep",
|
|
61
|
+
description: "在当前作品的章节正文索引中查询关键字,返回关键字所在的完整段落及章节标题和 ID。默认返回前 20 条,可按需调整 limit。",
|
|
62
|
+
parameters: { type: "object", properties: { keyword: { type: "string", minLength: 1, maxLength: 200 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 } }, required: ["keyword"], additionalProperties: false }
|
|
63
|
+
}
|
|
64
|
+
},
|
|
42
65
|
query_story_knowledge: {
|
|
43
66
|
type: "function",
|
|
44
67
|
function: {
|
|
@@ -642,6 +665,7 @@ export class AiManager {
|
|
|
642
665
|
this.validateOutboundUrl = validateOutboundUrl;
|
|
643
666
|
this.contextBuilder = new ContextBuilder(store);
|
|
644
667
|
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
668
|
+
logger.info("ai.manager.ready");
|
|
645
669
|
}
|
|
646
670
|
resetAutoRunBatch(workId) {
|
|
647
671
|
this.autoRunBatches.set(workId, { claimed: 0, starting: new Set() });
|
|
@@ -662,6 +686,7 @@ export class AiManager {
|
|
|
662
686
|
void this.drainAutoRun(workId);
|
|
663
687
|
}, 0);
|
|
664
688
|
this.autoRunTimers.set(workId, timer);
|
|
689
|
+
logger.debug("ai.auto_run.scheduled", { workId });
|
|
665
690
|
}
|
|
666
691
|
startAutoRunBatch(workId) {
|
|
667
692
|
this.store.getWork(workId);
|
|
@@ -671,6 +696,11 @@ export class AiManager {
|
|
|
671
696
|
}
|
|
672
697
|
this.resetAutoRunBatch(workId);
|
|
673
698
|
this.scheduleAutoRun(workId);
|
|
699
|
+
logger.info("ai.auto_run.batch_started", {
|
|
700
|
+
workId,
|
|
701
|
+
concurrency: settings.autoRunConcurrency,
|
|
702
|
+
batchLimit: settings.autoRunBatchLimit
|
|
703
|
+
});
|
|
674
704
|
return {
|
|
675
705
|
workId,
|
|
676
706
|
autoRunEnabled: true,
|
|
@@ -681,11 +711,13 @@ export class AiManager {
|
|
|
681
711
|
};
|
|
682
712
|
}
|
|
683
713
|
dispose() {
|
|
714
|
+
logger.info("ai.manager.disposing", { scheduledWorks: this.autoRunTimers.size, activeTasks: this.taskControllers.size });
|
|
684
715
|
for (const timer of this.autoRunTimers.values())
|
|
685
716
|
clearTimeout(timer);
|
|
686
717
|
this.autoRunTimers.clear();
|
|
687
718
|
this.autoRunBatches.clear();
|
|
688
719
|
this.store.setAnalysisTaskQueuedHandler(null);
|
|
720
|
+
logger.info("ai.manager.disposed");
|
|
689
721
|
}
|
|
690
722
|
getAutoRunBatch(workId) {
|
|
691
723
|
const existing = this.autoRunBatches.get(workId);
|
|
@@ -697,6 +729,7 @@ export class AiManager {
|
|
|
697
729
|
}
|
|
698
730
|
async drainAutoRun(workId) {
|
|
699
731
|
try {
|
|
732
|
+
logger.debug("ai.auto_run.drain_started", { workId });
|
|
700
733
|
const settings = this.store.getWorkAiSettings(workId);
|
|
701
734
|
if (!settings.autoRunEnabled)
|
|
702
735
|
return;
|
|
@@ -726,7 +759,8 @@ export class AiManager {
|
|
|
726
759
|
});
|
|
727
760
|
}
|
|
728
761
|
}
|
|
729
|
-
catch {
|
|
762
|
+
catch (error) {
|
|
763
|
+
logger.warn("ai.auto_run.drain_failed", { workId, error: aiErrorForLog(error) });
|
|
730
764
|
// 数据库已关闭或作品不存在时忽略自动调度
|
|
731
765
|
}
|
|
732
766
|
}
|
|
@@ -795,6 +829,8 @@ export class AiManager {
|
|
|
795
829
|
const apiKey = this.decryptKey(row);
|
|
796
830
|
const controller = new AbortController();
|
|
797
831
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
832
|
+
const startedAt = process.hrtime.bigint();
|
|
833
|
+
logger.info("ai.provider_test.started", { providerId });
|
|
798
834
|
try {
|
|
799
835
|
const endpoint = `${normalizeBaseUrl(stringValue(row, "base_url"))}/models`;
|
|
800
836
|
const response = await this.outboundFetch(endpoint, {
|
|
@@ -809,11 +845,23 @@ export class AiManager {
|
|
|
809
845
|
const availableModels = Array.isArray(payload.data) ? payload.data.map((item) => item.id).filter(Boolean) : [];
|
|
810
846
|
const timestamp = now();
|
|
811
847
|
this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", timestamp, timestamp, providerId);
|
|
848
|
+
logger.info("ai.provider_test.completed", {
|
|
849
|
+
providerId,
|
|
850
|
+
ok: true,
|
|
851
|
+
availableModelCount: availableModels.length,
|
|
852
|
+
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
853
|
+
});
|
|
812
854
|
return { ok: true, availableModels, provider: this.getProvider(providerId) };
|
|
813
855
|
}
|
|
814
856
|
catch (error) {
|
|
815
857
|
const message = error instanceof Error ? error.message : "连接失败";
|
|
816
858
|
this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, now(), providerId);
|
|
859
|
+
logger.warn("ai.provider_test.completed", {
|
|
860
|
+
providerId,
|
|
861
|
+
ok: false,
|
|
862
|
+
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
863
|
+
error: aiErrorForLog(error)
|
|
864
|
+
});
|
|
817
865
|
return { ok: false, error: message, provider: this.getProvider(providerId) };
|
|
818
866
|
}
|
|
819
867
|
finally {
|
|
@@ -1061,12 +1109,15 @@ export class AiManager {
|
|
|
1061
1109
|
const task = this.store.getTask(taskId);
|
|
1062
1110
|
const workId = String(task.workId);
|
|
1063
1111
|
const batch = this.getAutoRunBatch(workId);
|
|
1112
|
+
const startedAt = process.hrtime.bigint();
|
|
1113
|
+
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: modelId ?? null });
|
|
1064
1114
|
if (task.status !== "pending")
|
|
1065
1115
|
throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
|
|
1066
1116
|
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
1067
1117
|
const expired = this.store.updateTask(taskId, { status: "expired" });
|
|
1068
1118
|
batch.starting.delete(taskId);
|
|
1069
1119
|
this.scheduleAutoRun(workId);
|
|
1120
|
+
logger.warn("ai.task.expired", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1070
1121
|
return expired;
|
|
1071
1122
|
}
|
|
1072
1123
|
const settings = this.store.getWorkAiSettings(workId);
|
|
@@ -1113,15 +1164,20 @@ export class AiManager {
|
|
|
1113
1164
|
});
|
|
1114
1165
|
result = { content: generated.content, callId: generated.callId };
|
|
1115
1166
|
}
|
|
1116
|
-
if (!this.taskCanCommit(taskId))
|
|
1167
|
+
if (!this.taskCanCommit(taskId)) {
|
|
1168
|
+
logger.warn("ai.task.result_discarded", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1117
1169
|
return this.store.getTask(taskId);
|
|
1118
|
-
|
|
1170
|
+
}
|
|
1171
|
+
const completed = this.store.updateTask(taskId, { status: "review", progress: 100, result });
|
|
1172
|
+
logger.info("ai.task.completed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1173
|
+
return completed;
|
|
1119
1174
|
}
|
|
1120
1175
|
catch (error) {
|
|
1121
1176
|
if (this.store.getTask(taskId).status !== "running")
|
|
1122
1177
|
return this.store.getTask(taskId);
|
|
1123
1178
|
const message = error instanceof Error ? error.message : "分析失败";
|
|
1124
1179
|
this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [{ message }] });
|
|
1180
|
+
logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
|
|
1125
1181
|
throw error;
|
|
1126
1182
|
}
|
|
1127
1183
|
finally {
|
|
@@ -1135,6 +1191,7 @@ export class AiManager {
|
|
|
1135
1191
|
this.taskControllers.get(taskId)?.abort(new Error("分析任务已取消"));
|
|
1136
1192
|
this.taskControllers.delete(taskId);
|
|
1137
1193
|
this.scheduleAutoRun(String(task.workId));
|
|
1194
|
+
logger.warn("ai.task.cancelled", { taskId, workId: task.workId });
|
|
1138
1195
|
return task;
|
|
1139
1196
|
}
|
|
1140
1197
|
contextBudget(input, model) {
|
|
@@ -1288,7 +1345,7 @@ export class AiManager {
|
|
|
1288
1345
|
? [
|
|
1289
1346
|
`当前可用作品查询工具:${enabledToolIds.join("、")}。`,
|
|
1290
1347
|
"当作者询问当前作品、项目、章节、情节、人物、关系、世界观或设定,而预加载上下文为空或不足时,必须先调用工具主动查询;不得直接声称没有上下文,也不得先要求作者补充本系统已经能够查询的信息。",
|
|
1291
|
-
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index
|
|
1348
|
+
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查询设定、人物、组织、时间线、关系、大纲或伏笔时调用 query_story_knowledge。",
|
|
1292
1349
|
"根据问题选择最少且必要的工具。工具结果仍不足时才说明未知,并明确已经查询过什么;不要重复无效调用。"
|
|
1293
1350
|
].join("\n")
|
|
1294
1351
|
: "";
|
|
@@ -1370,8 +1427,9 @@ export class AiManager {
|
|
|
1370
1427
|
: null;
|
|
1371
1428
|
const schema = name === "story_index" ? storyIndexArguments
|
|
1372
1429
|
: name === "read_chapters" ? readChaptersArguments
|
|
1373
|
-
: name === "
|
|
1374
|
-
:
|
|
1430
|
+
: name === "grep" ? grepArguments
|
|
1431
|
+
: name === "query_story_knowledge" ? queryStoryKnowledgeArguments
|
|
1432
|
+
: null;
|
|
1375
1433
|
if (!schema) {
|
|
1376
1434
|
return {
|
|
1377
1435
|
id: toolCall.id,
|
|
@@ -1450,6 +1508,18 @@ export class AiManager {
|
|
|
1450
1508
|
});
|
|
1451
1509
|
return { id: toolCall.id, name, calledAt, arguments: { chapterIds, include }, status: "completed", result: { ok: true, data: { chapters, contentLimitChars: 36_000 } } };
|
|
1452
1510
|
}
|
|
1511
|
+
if (name === "grep") {
|
|
1512
|
+
const { keyword, limit } = args;
|
|
1513
|
+
const matches = this.store.searchChapterParagraphs(workId, keyword, limit);
|
|
1514
|
+
return {
|
|
1515
|
+
id: toolCall.id,
|
|
1516
|
+
name,
|
|
1517
|
+
calledAt,
|
|
1518
|
+
arguments: { keyword, limit },
|
|
1519
|
+
status: "completed",
|
|
1520
|
+
result: { ok: true, data: { keyword, limit, matches } }
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1453
1523
|
if (name === "query_story_knowledge") {
|
|
1454
1524
|
const { query, categories: categoryList } = args;
|
|
1455
1525
|
const categories = new Set(categoryList);
|
|
@@ -1500,6 +1570,18 @@ export class AiManager {
|
|
|
1500
1570
|
const timestamp = now();
|
|
1501
1571
|
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1502
1572
|
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
|
|
1573
|
+
const callStartedAt = process.hrtime.bigint();
|
|
1574
|
+
logger.info("ai.call.started", {
|
|
1575
|
+
callId,
|
|
1576
|
+
workId: input.workId,
|
|
1577
|
+
taskType: input.taskType,
|
|
1578
|
+
providerId: stringValue(provider, "id"),
|
|
1579
|
+
modelId: stringValue(model, "id"),
|
|
1580
|
+
streaming: false,
|
|
1581
|
+
contextChars: context.length,
|
|
1582
|
+
instructionChars: input.instruction.length,
|
|
1583
|
+
toolCount: tools.length
|
|
1584
|
+
});
|
|
1503
1585
|
try {
|
|
1504
1586
|
const apiKey = this.decryptKey(provider);
|
|
1505
1587
|
const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
|
|
@@ -1509,6 +1591,8 @@ export class AiManager {
|
|
|
1509
1591
|
let lastFailure = null;
|
|
1510
1592
|
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
1511
1593
|
let retryable = true;
|
|
1594
|
+
const attemptStartedAt = process.hrtime.bigint();
|
|
1595
|
+
logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, toolChoice });
|
|
1512
1596
|
try {
|
|
1513
1597
|
const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
|
|
1514
1598
|
const controller = new AbortController();
|
|
@@ -1532,6 +1616,13 @@ export class AiManager {
|
|
|
1532
1616
|
input.signal?.removeEventListener("abort", forwardAbort);
|
|
1533
1617
|
}
|
|
1534
1618
|
});
|
|
1619
|
+
logger.info("ai.call.attempt_completed", {
|
|
1620
|
+
callId,
|
|
1621
|
+
attempt,
|
|
1622
|
+
status: candidate.status,
|
|
1623
|
+
ok: candidate.ok,
|
|
1624
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000
|
|
1625
|
+
});
|
|
1535
1626
|
if (candidate.ok) {
|
|
1536
1627
|
try {
|
|
1537
1628
|
return JSON.parse(candidate.body);
|
|
@@ -1548,6 +1639,13 @@ export class AiManager {
|
|
|
1548
1639
|
}
|
|
1549
1640
|
catch (error) {
|
|
1550
1641
|
lastFailure = error;
|
|
1642
|
+
logger.warn("ai.call.attempt_failed", {
|
|
1643
|
+
callId,
|
|
1644
|
+
attempt,
|
|
1645
|
+
retryable: retryable && attempt < maximumAttempts && !input.signal?.aborted,
|
|
1646
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
1647
|
+
error: aiErrorForLog(error)
|
|
1648
|
+
});
|
|
1551
1649
|
if (input.signal?.aborted)
|
|
1552
1650
|
throw error;
|
|
1553
1651
|
if (!retryable || attempt >= maximumAttempts)
|
|
@@ -1599,6 +1697,7 @@ export class AiManager {
|
|
|
1599
1697
|
});
|
|
1600
1698
|
for (const toolCall of toolCalls) {
|
|
1601
1699
|
const execution = this.executeAgentTool(input.workId, toolCall);
|
|
1700
|
+
logger.info("ai.tool_call.completed", { callId, toolName: execution.name, status: execution.status, round });
|
|
1602
1701
|
executedToolCalls.push(execution);
|
|
1603
1702
|
processSteps.push({ id: id("process"), type: "tool", round, toolCall: execution, createdAt: execution.calledAt });
|
|
1604
1703
|
input.onToolCall?.(execution, round);
|
|
@@ -1622,11 +1721,30 @@ export class AiManager {
|
|
|
1622
1721
|
throw new Error(`Chat Completions 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
|
|
1623
1722
|
}
|
|
1624
1723
|
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
1625
|
-
|
|
1724
|
+
const outputTokens = resolveOutputTokens(payload.usage, content);
|
|
1725
|
+
logger.info("ai.call.completed", {
|
|
1726
|
+
callId,
|
|
1727
|
+
workId: input.workId,
|
|
1728
|
+
taskType: input.taskType,
|
|
1729
|
+
streaming: false,
|
|
1730
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1731
|
+
outputChars: content.length,
|
|
1732
|
+
outputTokens,
|
|
1733
|
+
toolCallCount: executedToolCalls.length
|
|
1734
|
+
});
|
|
1735
|
+
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
|
|
1626
1736
|
}
|
|
1627
1737
|
catch (error) {
|
|
1628
1738
|
const message = error instanceof Error ? error.message : "AI 调用失败";
|
|
1629
1739
|
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
1740
|
+
logger.error("ai.call.failed", {
|
|
1741
|
+
callId,
|
|
1742
|
+
workId: input.workId,
|
|
1743
|
+
taskType: input.taskType,
|
|
1744
|
+
streaming: false,
|
|
1745
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1746
|
+
error: aiErrorForLog(error)
|
|
1747
|
+
});
|
|
1630
1748
|
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
|
|
1631
1749
|
}
|
|
1632
1750
|
}
|
|
@@ -1642,6 +1760,17 @@ export class AiManager {
|
|
|
1642
1760
|
const callId = id("call");
|
|
1643
1761
|
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1644
1762
|
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, now(), currentRequestActor()?.userId ?? null);
|
|
1763
|
+
const callStartedAt = process.hrtime.bigint();
|
|
1764
|
+
logger.info("ai.call.started", {
|
|
1765
|
+
callId,
|
|
1766
|
+
workId: input.workId,
|
|
1767
|
+
taskType: input.taskType,
|
|
1768
|
+
providerId: stringValue(provider, "id"),
|
|
1769
|
+
modelId: stringValue(model, "id"),
|
|
1770
|
+
streaming: true,
|
|
1771
|
+
contextChars: context.length,
|
|
1772
|
+
instructionChars: input.instruction.length
|
|
1773
|
+
});
|
|
1645
1774
|
try {
|
|
1646
1775
|
const apiKey = this.decryptKey(provider);
|
|
1647
1776
|
const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
|
|
@@ -1652,6 +1781,8 @@ export class AiManager {
|
|
|
1652
1781
|
const thinkingStepId = id("process");
|
|
1653
1782
|
const thinkingCreatedAt = now();
|
|
1654
1783
|
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
1784
|
+
const attemptStartedAt = process.hrtime.bigint();
|
|
1785
|
+
logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, streaming: true });
|
|
1655
1786
|
try {
|
|
1656
1787
|
const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
|
|
1657
1788
|
const controller = new AbortController();
|
|
@@ -1684,6 +1815,14 @@ export class AiManager {
|
|
|
1684
1815
|
input.signal?.removeEventListener("abort", forwardAbort);
|
|
1685
1816
|
}
|
|
1686
1817
|
});
|
|
1818
|
+
logger.info("ai.call.attempt_completed", {
|
|
1819
|
+
callId,
|
|
1820
|
+
attempt,
|
|
1821
|
+
status: candidate.status,
|
|
1822
|
+
ok: candidate.ok,
|
|
1823
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
1824
|
+
streaming: true
|
|
1825
|
+
});
|
|
1687
1826
|
if (candidate.ok) {
|
|
1688
1827
|
streamedResult = candidate.result;
|
|
1689
1828
|
break;
|
|
@@ -1694,6 +1833,14 @@ export class AiManager {
|
|
|
1694
1833
|
}
|
|
1695
1834
|
catch (error) {
|
|
1696
1835
|
lastFailure = error;
|
|
1836
|
+
logger.warn("ai.call.attempt_failed", {
|
|
1837
|
+
callId,
|
|
1838
|
+
attempt,
|
|
1839
|
+
retryable: !input.signal?.aborted && !emitted && attempt < maximumAttempts,
|
|
1840
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
1841
|
+
streaming: true,
|
|
1842
|
+
error: aiErrorForLog(error)
|
|
1843
|
+
});
|
|
1697
1844
|
if (input.signal?.aborted || emitted || attempt >= maximumAttempts)
|
|
1698
1845
|
throw error;
|
|
1699
1846
|
}
|
|
@@ -1707,11 +1854,28 @@ export class AiManager {
|
|
|
1707
1854
|
? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
|
|
1708
1855
|
: [];
|
|
1709
1856
|
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
1857
|
+
logger.info("ai.call.completed", {
|
|
1858
|
+
callId,
|
|
1859
|
+
workId: input.workId,
|
|
1860
|
+
taskType: input.taskType,
|
|
1861
|
+
streaming: true,
|
|
1862
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1863
|
+
outputChars: content.length,
|
|
1864
|
+
outputTokens
|
|
1865
|
+
});
|
|
1710
1866
|
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
1711
1867
|
}
|
|
1712
1868
|
catch (error) {
|
|
1713
1869
|
const message = error instanceof Error ? error.message : "AI 流式调用失败";
|
|
1714
1870
|
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
1871
|
+
logger.error("ai.call.failed", {
|
|
1872
|
+
callId,
|
|
1873
|
+
workId: input.workId,
|
|
1874
|
+
taskType: input.taskType,
|
|
1875
|
+
streaming: true,
|
|
1876
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1877
|
+
error: aiErrorForLog(error)
|
|
1878
|
+
});
|
|
1715
1879
|
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
|
|
1716
1880
|
}
|
|
1717
1881
|
}
|
|
@@ -3206,6 +3370,13 @@ export class AiManager {
|
|
|
3206
3370
|
};
|
|
3207
3371
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3208
3372
|
schedule.queue.push(entry);
|
|
3373
|
+
logger.debug("ai.provider_queue.enqueued", {
|
|
3374
|
+
providerId,
|
|
3375
|
+
active: schedule.active,
|
|
3376
|
+
queued: schedule.queue.length,
|
|
3377
|
+
concurrencyLimit: schedule.concurrencyLimit,
|
|
3378
|
+
rpmLimit: schedule.rpmLimit
|
|
3379
|
+
});
|
|
3209
3380
|
this.pumpProviderSchedule(providerId);
|
|
3210
3381
|
});
|
|
3211
3382
|
}
|
|
@@ -3230,16 +3401,19 @@ export class AiManager {
|
|
|
3230
3401
|
}
|
|
3231
3402
|
schedule.active += 1;
|
|
3232
3403
|
schedule.starts.push(Date.now());
|
|
3404
|
+
logger.debug("ai.provider_queue.dispatched", { providerId, active: schedule.active, queued: schedule.queue.length });
|
|
3233
3405
|
void Promise.resolve()
|
|
3234
3406
|
.then(entry.run)
|
|
3235
3407
|
.then(entry.resolve, entry.reject)
|
|
3236
3408
|
.finally(() => {
|
|
3237
3409
|
schedule.active -= 1;
|
|
3410
|
+
logger.debug("ai.provider_queue.finished", { providerId, active: schedule.active, queued: schedule.queue.length });
|
|
3238
3411
|
this.pumpProviderSchedule(providerId);
|
|
3239
3412
|
});
|
|
3240
3413
|
}
|
|
3241
3414
|
if (schedule.queue.length > 0 && schedule.active < schedule.concurrencyLimit && schedule.starts.length >= schedule.rpmLimit) {
|
|
3242
3415
|
const delay = Math.max(1, (schedule.starts[0] ?? Date.now()) + 60_000 - Date.now() + 1);
|
|
3416
|
+
logger.info("ai.provider_queue.rate_limited", { providerId, queued: schedule.queue.length, retryInMs: delay });
|
|
3243
3417
|
schedule.timer = setTimeout(() => this.pumpProviderSchedule(providerId), delay);
|
|
3244
3418
|
schedule.timer.unref?.();
|
|
3245
3419
|
}
|