@musnows/scriverse 0.8.8 → 0.9.0

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.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ANALYSIS_TASK_TYPES, HISTORICAL_ANALYSIS_TASK_TYPES } from "./domain.js";
2
2
  import { buildCompletionRequestBody, AI_THINKING_TYPES, isAiProviderProtocol, normalizeProviderBaseUrl, parseCompletionPayload, parseProviderModelListPage, providerCompletionEndpoint, providerModelListPageEndpoint, providerModelEndpoints, providerProtocolLabelText, providerRequestHeaders } from "./ai-protocol.js";
3
3
  import { estimateLiteLlmUsageCost } from "./ai-model-pricing.js";
4
+ import { DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS, isLongRunningAiAnalysisTaskType, normalizeAiAnalysisTimeoutSeconds } from "./ai-analysis-timeout.js";
4
5
  import { AGENT_TOOL_RESULT_MAX_CHARS, DEFAULT_AGENT_TOOL_CALL_GLOBAL_MULTIPLIER, MIN_AGENT_TOOL_CALL_LIMIT, agentToolCallGlobalLimit, agentToolCallQuotaNoticeBudgetChars, agentToolCallQuotaUsedAfterCompact, agentToolCallSoftWarningThreshold, clampAgentToolCallGlobalMultiplier, paginateToolResultRecords, resolveMaxAgentToolCallLimit, shouldRejectAgentToolCalls, shouldRejectGlobalToolCalls, structuralToolResultRecords, withAgentToolCallQuotaNotice } from "./ai-tool-results.js";
5
6
  import { AiConnectivityTestGate, hashAiConnectivityConfiguration } from "./ai-connectivity-test.js";
6
7
  import { aiHttpRetryCount, aiHttpRetryDelayMs, normalizeAiRetryPolicy } from "./ai-retry.js";
@@ -13,7 +14,7 @@ import { assertOfficialGoogleVertexBaseUrl, fetchGoogleOAuthAccessToken, GoogleV
13
14
  import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, buildHybridSearchSnippet, documentParagraphLineRangesFromLines, fuseHybridSearchChannels, normalizeWorkSearchQuery } from "./hybrid-search.js";
14
15
  import { logger, sanitizeError } from "./logger.js";
15
16
  import { paginated, paginationSql } from "./pagination.js";
16
- import { currentRequestActor } from "./request-context.js";
17
+ import { currentRequestActor, runWithRequestActor } from "./request-context.js";
17
18
  import { aiEndpointUsesPrivateNetwork, fetchSafeAiEndpoint } from "./security.js";
18
19
  import { defaultAiConversationTitle, normalizeCharacterName } from "./store.js";
19
20
  import { composeRoleplayCurrentUserTurn, formatRoleplayScenePinText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
@@ -53,7 +54,6 @@ function connectivityTestErrorForLog(error) {
53
54
  const AUTO_RUN_MAX_ATTEMPTS = 3;
54
55
  const AUTO_RUN_RETRY_DELAYS_MS = [5_000, 30_000];
55
56
  const AI_INTERACTIVE_TIMEOUT_MS = 60_000;
56
- const AI_LONG_RUNNING_TIMEOUT_MS = 300_000;
57
57
  const FORCE_CONVERSATION_COMPACTION_USAGE_PERCENT = 95;
58
58
  const MIN_OUTPUT_RESERVE_TOKENS = 1_024;
59
59
  const MIN_CONTEXT_REMAINING_TOKENS = 5_000;
@@ -204,6 +204,9 @@ export function autoRunFailureDisposition(error, attemptCount) {
204
204
  pauseImmediately
205
205
  };
206
206
  }
207
+ const DESKTOP_LOCAL_AI_RUN_LIMIT = 20;
208
+ const DESKTOP_LOCAL_AI_RUN_RETENTION_MS = 10 * 60_000;
209
+ const DESKTOP_LOCAL_AI_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
207
210
  const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
208
211
  const DEFAULT_MAX_TOKENS = 32_000;
209
212
  const MAX_MODEL_OUTPUT_TOKENS = 2_000_000;
@@ -255,6 +258,9 @@ function providerThinkingType(provider) {
255
258
  const value = stringValue(provider, "thinking_type");
256
259
  return AI_THINKING_TYPES.includes(value) ? value : "enabled";
257
260
  }
261
+ function providerAnalysisTimeoutSeconds(provider) {
262
+ return normalizeAiAnalysisTimeoutSeconds(numberValue(provider, "analysis_timeout_seconds") || DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS);
263
+ }
258
264
  function supportsMultimodalProviderProtocol(provider) {
259
265
  return ["openai-chat-completions", "openai-responses", "anthropic-messages", "google-vertex"].includes(providerProtocol(provider));
260
266
  }
@@ -1891,6 +1897,7 @@ export class AiManager {
1891
1897
  relationshipIndexTimer = null;
1892
1898
  relationshipIndexDisposed = false;
1893
1899
  providerSchedules = new Map();
1900
+ desktopLocalAiRuns = new Map();
1894
1901
  vertexTokenCache = new GoogleVertexTokenCache();
1895
1902
  connectivityTestGate;
1896
1903
  allowPrivateAiEndpoints;
@@ -2644,6 +2651,12 @@ export class AiManager {
2644
2651
  }
2645
2652
  dispose() {
2646
2653
  logger.info("ai.manager.disposing", { scheduledWorks: this.autoRunTimers.size, activeTasks: this.taskControllers.size });
2654
+ for (const run of this.desktopLocalAiRuns.values()) {
2655
+ run.pending?.dispose();
2656
+ run.pending?.reject(new Error("AI manager disposed"));
2657
+ run.controller.abort(new Error("AI manager disposed"));
2658
+ }
2659
+ this.desktopLocalAiRuns.clear();
2647
2660
  if (this.autoRunStartupTimer)
2648
2661
  clearTimeout(this.autoRunStartupTimer);
2649
2662
  this.autoRunStartupTimer = null;
@@ -2916,9 +2929,17 @@ export class AiManager {
2916
2929
  if (protocol === "google-vertex")
2917
2930
  assertOfficialGoogleVertexBaseUrl(baseUrl);
2918
2931
  this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, protocol, encrypted_key, key_iv, key_tag, key_hint, status,
2919
- connection_status, concurrency_limit, rpm_limit, daily_token_quota, monthly_token_quota, max_tokens_parameter, thinking_type, note, created_at, updated_at)
2920
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name, baseUrl, protocol, encrypted.encrypted, encrypted.iv, encrypted.tag, providerCredentialHint(protocol, input.apiKey), input.status ?? "disabled", input.concurrencyLimit ?? 10, input.rpmLimit ?? 10, input.dailyTokenQuota ?? null, input.monthlyTokenQuota ?? null, maxTokensParameter, input.thinkingType ?? "enabled", input.note ?? "", timestamp, timestamp);
2921
- this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl, protocol, maxTokensParameter, thinkingType: input.thinkingType ?? "enabled" });
2932
+ connection_status, concurrency_limit, rpm_limit, analysis_timeout_seconds, daily_token_quota, monthly_token_quota,
2933
+ max_tokens_parameter, thinking_type, note, created_at, updated_at)
2934
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name, baseUrl, protocol, encrypted.encrypted, encrypted.iv, encrypted.tag, providerCredentialHint(protocol, input.apiKey), input.status ?? "disabled", input.concurrencyLimit ?? 10, input.rpmLimit ?? 10, input.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS, input.dailyTokenQuota ?? null, input.monthlyTokenQuota ?? null, maxTokensParameter, input.thinkingType ?? "enabled", input.note ?? "", timestamp, timestamp);
2935
+ this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, {
2936
+ name: input.name,
2937
+ baseUrl,
2938
+ protocol,
2939
+ maxTokensParameter,
2940
+ thinkingType: input.thinkingType ?? "enabled",
2941
+ analysisTimeoutSeconds: input.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS
2942
+ });
2922
2943
  return this.getProvider(providerId);
2923
2944
  }
2924
2945
  listProviders() {
@@ -2978,8 +2999,9 @@ export class AiManager {
2978
2999
  ? nullableNumberValue(row, "monthly_token_quota")
2979
3000
  : input.monthlyTokenQuota;
2980
3001
  this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, protocol = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
2981
- status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, daily_token_quota = ?, monthly_token_quota = ?,
2982
- max_tokens_parameter = ?, thinking_type = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), nextBaseUrl, nextProtocol, encryptedKey, keyIv, keyTag, keyHint, input.status ?? stringValue(row, "status"), connectionStatus, input.concurrencyLimit ?? numberValue(row, "concurrency_limit"), input.rpmLimit ?? numberValue(row, "rpm_limit"), nextDailyTokenQuota, nextMonthlyTokenQuota, nextMaxTokensParameter, nextThinkingType, input.note ?? stringValue(row, "note"), now(), providerId);
3002
+ status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, analysis_timeout_seconds = ?,
3003
+ daily_token_quota = ?, monthly_token_quota = ?,
3004
+ max_tokens_parameter = ?, thinking_type = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), nextBaseUrl, nextProtocol, encryptedKey, keyIv, keyTag, keyHint, input.status ?? stringValue(row, "status"), connectionStatus, input.concurrencyLimit ?? numberValue(row, "concurrency_limit"), input.rpmLimit ?? numberValue(row, "rpm_limit"), input.analysisTimeoutSeconds ?? providerAnalysisTimeoutSeconds(row), nextDailyTokenQuota, nextMonthlyTokenQuota, nextMaxTokensParameter, nextThinkingType, input.note ?? stringValue(row, "note"), now(), providerId);
2983
3005
  this.store.audit(PLATFORM_AI_WORK_ID, "provider.updated", "provider", providerId, {
2984
3006
  fields: Object.keys(input).filter((key) => key !== "apiKey"),
2985
3007
  keyReplaced: Boolean(input.apiKey)
@@ -4228,7 +4250,7 @@ export class AiManager {
4228
4250
  this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
4229
4251
  source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.taskType, input.instruction, effectiveInput.scope.selection ?? "", generated.content, action, now(), currentRequestActor()?.userId ?? null);
4230
4252
  if (input.taskType === "continue")
4231
- await this.runSuggestionGuard(suggestionId);
4253
+ await this.runSuggestionGuardWithRuntime(suggestionId, undefined, effectiveInput.runtime);
4232
4254
  return {
4233
4255
  ...this.getSuggestion(suggestionId),
4234
4256
  outputTokens: generated.outputTokens,
@@ -4356,6 +4378,9 @@ export class AiManager {
4356
4378
  }
4357
4379
  }
4358
4380
  async runSuggestionGuard(suggestionId, candidateContent) {
4381
+ return this.runSuggestionGuardWithRuntime(suggestionId, candidateContent);
4382
+ }
4383
+ async runSuggestionGuardWithRuntime(suggestionId, candidateContent, runtime) {
4359
4384
  const suggestion = this.getSuggestion(suggestionId);
4360
4385
  if (suggestion.taskType !== "continue" || !suggestion.chapterId) {
4361
4386
  throw new AppError(409, "GUARD_NOT_APPLICABLE", "只有续写建议可以运行一致性守卫");
@@ -4387,7 +4412,8 @@ export class AiManager {
4387
4412
  "续写候选:",
4388
4413
  content
4389
4414
  ].join("\n\n"),
4390
- extraSystemPrompt: "你是续写一致性守卫。必须逐项对照人物状态、地点、时间、世界观硬约束、章节大纲和未回收伏笔。"
4415
+ extraSystemPrompt: "你是续写一致性守卫。必须逐项对照人物状态、地点、时间、世界观硬约束、章节大纲和未回收伏笔。",
4416
+ ...(runtime ? { runtime } : {})
4391
4417
  });
4392
4418
  const issues = parseGuardIssues(generated.content);
4393
4419
  return this.store.createContinuationGuard({
@@ -4501,43 +4527,14 @@ export class AiManager {
4501
4527
  }
4502
4528
  listCalls(workId) {
4503
4529
  this.store.getWork(workId);
4504
- return this.store.db.all("SELECT * FROM ai_calls WHERE work_id = ? ORDER BY created_at DESC LIMIT 200", workId).map((row) => ({
4505
- id: stringValue(row, "id"),
4506
- workId: stringValue(row, "work_id"),
4507
- taskId: row.task_id === null ? null : stringValue(row, "task_id"),
4508
- taskType: stringValue(row, "task_type"),
4509
- provider: this.getProvider(stringValue(row, "provider_id")),
4510
- model: this.getModel(stringValue(row, "model_id")),
4511
- contextScope: json(stringValue(row, "context_scope_json"), {}),
4512
- parameters: json(stringValue(row, "parameters_json"), {}),
4513
- status: stringValue(row, "status"),
4514
- failure: row.failure === null ? null : stringValue(row, "failure"),
4515
- inputChars: numberValue(row, "input_chars"),
4516
- outputChars: numberValue(row, "output_chars"),
4517
- createdAt: stringValue(row, "created_at"),
4518
- completedAt: row.completed_at === null ? null : stringValue(row, "completed_at")
4519
- }));
4530
+ return this.store.db.all("SELECT * FROM ai_calls WHERE work_id = ? ORDER BY created_at DESC LIMIT 200", workId)
4531
+ .map((row) => this.mapCall(row));
4520
4532
  }
4521
4533
  listCallsPage(workId, pagination) {
4522
4534
  this.store.getWork(workId);
4523
4535
  const page = paginationSql(pagination);
4524
4536
  const rows = this.store.db.all(`SELECT * FROM ai_calls WHERE work_id = ? ORDER BY created_at DESC${page.sql}`, workId, ...page.params);
4525
- return paginated(rows.map((row) => ({
4526
- id: stringValue(row, "id"),
4527
- workId: stringValue(row, "work_id"),
4528
- taskId: row.task_id === null ? null : stringValue(row, "task_id"),
4529
- taskType: stringValue(row, "task_type"),
4530
- provider: this.getProvider(stringValue(row, "provider_id")),
4531
- model: this.getModel(stringValue(row, "model_id")),
4532
- contextScope: json(stringValue(row, "context_scope_json"), {}),
4533
- parameters: json(stringValue(row, "parameters_json"), {}),
4534
- status: stringValue(row, "status"),
4535
- failure: row.failure === null ? null : stringValue(row, "failure"),
4536
- inputChars: numberValue(row, "input_chars"),
4537
- outputChars: numberValue(row, "output_chars"),
4538
- createdAt: stringValue(row, "created_at"),
4539
- completedAt: row.completed_at === null ? null : stringValue(row, "completed_at")
4540
- })), pagination);
4537
+ return paginated(rows.map((row) => this.mapCall(row)), pagination);
4541
4538
  }
4542
4539
  getTaskTrace(taskId) {
4543
4540
  this.store.getTaskWorkId(taskId);
@@ -4782,6 +4779,9 @@ export class AiManager {
4782
4779
  }
4783
4780
  getContextUsage(input) {
4784
4781
  const { model } = this.resolveModel(input.workId, input.taskType, input.modelId);
4782
+ return this.contextUsageForModel(input, model);
4783
+ }
4784
+ contextUsageForModel(input, model) {
4785
4785
  const budget = this.contextBudget(input, model);
4786
4786
  const conversation = budget.conversation;
4787
4787
  const contextPlan = this.buildContextPlan(input, model, budget);
@@ -4835,8 +4835,235 @@ export class AiManager {
4835
4835
  degradedContextBlocks: contextPlan.degradedBlockIds.length
4836
4836
  };
4837
4837
  }
4838
+ async startDesktopLocalAiRun(input, actorScope, actor, permissions) {
4839
+ this.pruneDesktopLocalAiRuns();
4840
+ if (this.desktopLocalAiRuns.size >= DESKTOP_LOCAL_AI_RUN_LIMIT) {
4841
+ throw new AppError(429, "DESKTOP_LOCAL_AI_RUN_LIMIT", "当前正在处理的 Desktop 本地 AI 请求过多,请稍后再试");
4842
+ }
4843
+ const { provider, model } = this.desktopLocalAiRuntimeRows(input.runtimeModel);
4844
+ const imageAttachments = await this.prepareChatImageAttachmentsForModel(input.workId, model, provider, input.imageAttachmentIds ?? [], permissions);
4845
+ const runId = id("desktop-local-ai-run");
4846
+ const timestamp = Date.now();
4847
+ const run = {
4848
+ id: runId,
4849
+ workId: input.workId,
4850
+ actorScope,
4851
+ actor,
4852
+ status: "running",
4853
+ createdAt: timestamp,
4854
+ updatedAt: timestamp,
4855
+ controller: new AbortController(),
4856
+ contextUsage: null,
4857
+ pending: null,
4858
+ result: null,
4859
+ error: null
4860
+ };
4861
+ const runtime = {
4862
+ provider,
4863
+ model,
4864
+ localModelId: input.runtimeModel.id,
4865
+ completionTransport: (request) => this.awaitDesktopLocalAiCompletion(run, request)
4866
+ };
4867
+ this.desktopLocalAiRuns.set(runId, run);
4868
+ const updateContextUsage = (contextUsage) => {
4869
+ run.contextUsage = contextUsage;
4870
+ run.updatedAt = Date.now();
4871
+ };
4872
+ void Promise.resolve().then(() => runWithRequestActor(actor, () => this.createSuggestion({
4873
+ workId: input.workId,
4874
+ taskType: input.taskType,
4875
+ instruction: input.instruction,
4876
+ scope: input.scope,
4877
+ modelId: input.runtimeModel.id,
4878
+ signal: run.controller.signal,
4879
+ runtime,
4880
+ onPrepared: updateContextUsage,
4881
+ onContextCompacted: (event) => updateContextUsage(event.contextUsage),
4882
+ ...(input.conversationId ? { conversationId: input.conversationId } : {}),
4883
+ ...(input.excludeConversationMessageId ? { excludeConversationMessageId: input.excludeConversationMessageId } : {}),
4884
+ ...(imageAttachments.length > 0 ? { imageAttachments } : {}),
4885
+ ...(input.sceneDirection ? { sceneDirection: input.sceneDirection } : {})
4886
+ }))).then((result) => {
4887
+ if (run.status === "cancelled")
4888
+ return;
4889
+ run.status = "completed";
4890
+ run.result = result;
4891
+ run.contextUsage = result.contextUsage && typeof result.contextUsage === "object" && !Array.isArray(result.contextUsage)
4892
+ ? result.contextUsage
4893
+ : run.contextUsage;
4894
+ run.updatedAt = Date.now();
4895
+ }).catch((error) => {
4896
+ if (run.status === "cancelled")
4897
+ return;
4898
+ const appError = error instanceof AppError ? error : null;
4899
+ run.status = "failed";
4900
+ run.error = {
4901
+ status: appError?.status ?? 502,
4902
+ code: appError?.code ?? "AI_CALL_FAILED",
4903
+ message: appError?.message ?? "AI 调用失败"
4904
+ };
4905
+ run.updatedAt = Date.now();
4906
+ });
4907
+ return this.desktopLocalAiRunStatus(runId, input.workId, actorScope);
4908
+ }
4909
+ desktopLocalAiRunStatus(runId, workId, actorScope) {
4910
+ this.pruneDesktopLocalAiRuns();
4911
+ const run = this.desktopLocalAiRun(runId, workId, actorScope);
4912
+ return {
4913
+ id: run.id,
4914
+ status: run.status,
4915
+ ...(run.contextUsage ? { contextUsage: run.contextUsage } : {}),
4916
+ ...(run.pending ? { completion: run.pending.request } : {}),
4917
+ ...(run.result ? { result: run.result } : {}),
4918
+ ...(run.error ? { error: run.error } : {})
4919
+ };
4920
+ }
4921
+ submitDesktopLocalAiCompletion(runId, workId, actorScope, input) {
4922
+ const run = this.desktopLocalAiRun(runId, workId, actorScope);
4923
+ const pending = run.pending;
4924
+ if (!pending || run.status !== "awaiting-completion") {
4925
+ throw new AppError(409, "DESKTOP_LOCAL_AI_NOT_AWAITING", "当前 Desktop 本地 AI 请求不等待模型响应");
4926
+ }
4927
+ if (pending.request.requestId !== input.requestId) {
4928
+ throw new AppError(409, "DESKTOP_LOCAL_AI_REQUEST_MISMATCH", "Desktop 本地 AI 响应与当前请求不匹配");
4929
+ }
4930
+ if (Buffer.byteLength(input.body, "utf8") > DESKTOP_LOCAL_AI_RESPONSE_MAX_BYTES) {
4931
+ throw new AppError(413, "DESKTOP_LOCAL_AI_RESPONSE_TOO_LARGE", "Desktop 本地 AI 响应过大");
4932
+ }
4933
+ run.pending = null;
4934
+ run.status = "running";
4935
+ run.updatedAt = Date.now();
4936
+ pending.dispose();
4937
+ pending.resolve({
4938
+ status: input.status,
4939
+ body: input.body,
4940
+ retryAfter: input.retryAfter ?? null
4941
+ });
4942
+ return this.desktopLocalAiRunStatus(runId, workId, actorScope);
4943
+ }
4944
+ cancelDesktopLocalAiRun(runId, workId, actorScope) {
4945
+ const run = this.desktopLocalAiRun(runId, workId, actorScope);
4946
+ if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
4947
+ return this.desktopLocalAiRunStatus(runId, workId, actorScope);
4948
+ }
4949
+ run.status = "cancelled";
4950
+ run.updatedAt = Date.now();
4951
+ run.pending?.dispose();
4952
+ run.pending?.reject(new Error("Desktop local AI run cancelled"));
4953
+ run.pending = null;
4954
+ run.controller.abort(new Error("Desktop local AI run cancelled"));
4955
+ return this.desktopLocalAiRunStatus(runId, workId, actorScope);
4956
+ }
4957
+ desktopLocalAiRuntimeRows(input) {
4958
+ const timestamp = now();
4959
+ return {
4960
+ provider: {
4961
+ id: input.providerId,
4962
+ work_id: PLATFORM_AI_WORK_ID,
4963
+ name: input.providerName,
4964
+ base_url: "",
4965
+ protocol: input.protocol,
4966
+ encrypted_key: "",
4967
+ key_iv: "",
4968
+ key_tag: "",
4969
+ key_hint: "",
4970
+ status: "enabled",
4971
+ connection_status: "success",
4972
+ max_tokens_parameter: input.maxTokensParameter,
4973
+ thinking_type: input.thinkingType,
4974
+ concurrency_limit: input.concurrencyLimit,
4975
+ rpm_limit: input.rpmLimit,
4976
+ analysis_timeout_seconds: input.analysisTimeoutSeconds,
4977
+ daily_token_quota: null,
4978
+ monthly_token_quota: null,
4979
+ default_model_id: input.id,
4980
+ note: input.note,
4981
+ last_error: null,
4982
+ last_success_at: timestamp,
4983
+ created_at: timestamp,
4984
+ updated_at: timestamp,
4985
+ desktop_local: 1
4986
+ },
4987
+ model: {
4988
+ id: input.id,
4989
+ provider_id: input.providerId,
4990
+ display_name: input.displayName,
4991
+ model_id: input.modelId,
4992
+ enabled: 1,
4993
+ purposes_json: JSON.stringify(input.purposes),
4994
+ context_note: input.contextNote,
4995
+ context_window: input.contextWindow,
4996
+ output_note: input.outputNote,
4997
+ preset_json: JSON.stringify(input.preset),
4998
+ thinking_enabled: input.thinkingEnabled ? 1 : 0,
4999
+ thinking_effort: input.thinkingEffort,
5000
+ multimodal_enabled: input.multimodalEnabled ? 1 : 0,
5001
+ note: input.note,
5002
+ created_at: timestamp,
5003
+ updated_at: timestamp,
5004
+ desktop_local: 1
5005
+ }
5006
+ };
5007
+ }
5008
+ desktopLocalAiRun(runId, workId, actorScope) {
5009
+ const run = this.desktopLocalAiRuns.get(runId);
5010
+ if (!run || run.workId !== workId || run.actorScope !== actorScope)
5011
+ throw notFound("Desktop 本地 AI 请求");
5012
+ return run;
5013
+ }
5014
+ awaitDesktopLocalAiCompletion(run, request) {
5015
+ if (run.controller.signal.aborted)
5016
+ return Promise.reject(new Error("Desktop local AI run cancelled"));
5017
+ if (run.pending)
5018
+ return Promise.reject(new Error("Desktop local AI run already has a pending completion"));
5019
+ return new Promise((resolve, reject) => {
5020
+ const timeout = setTimeout(() => {
5021
+ if (run.pending?.request.requestId !== request.requestId)
5022
+ return;
5023
+ run.pending = null;
5024
+ run.status = "running";
5025
+ run.updatedAt = Date.now();
5026
+ run.controller.signal.removeEventListener("abort", onAbort);
5027
+ reject(new Error(`AI 请求超时(${Math.round(request.timeoutMs / 1_000)} 秒)`));
5028
+ }, request.timeoutMs);
5029
+ const onAbort = () => {
5030
+ if (run.pending?.request.requestId !== request.requestId)
5031
+ return;
5032
+ run.pending = null;
5033
+ clearTimeout(timeout);
5034
+ reject(new Error("Desktop local AI run cancelled"));
5035
+ };
5036
+ const dispose = () => {
5037
+ clearTimeout(timeout);
5038
+ run.controller.signal.removeEventListener("abort", onAbort);
5039
+ };
5040
+ run.controller.signal.addEventListener("abort", onAbort, { once: true });
5041
+ run.pending = {
5042
+ request,
5043
+ resolve: (response) => {
5044
+ dispose();
5045
+ resolve(response);
5046
+ },
5047
+ reject: (error) => {
5048
+ dispose();
5049
+ reject(error);
5050
+ },
5051
+ dispose
5052
+ };
5053
+ run.status = "awaiting-completion";
5054
+ run.updatedAt = Date.now();
5055
+ });
5056
+ }
5057
+ pruneDesktopLocalAiRuns() {
5058
+ const cutoff = Date.now() - DESKTOP_LOCAL_AI_RUN_RETENTION_MS;
5059
+ for (const [runId, run] of this.desktopLocalAiRuns) {
5060
+ if (run.updatedAt >= cutoff || run.status === "running" || run.status === "awaiting-completion")
5061
+ continue;
5062
+ this.desktopLocalAiRuns.delete(runId);
5063
+ }
5064
+ }
4838
5065
  completionContextUsage(input, model, messages, tools, reportedUsage) {
4839
- const baseUsage = this.getContextUsage(input);
5066
+ const baseUsage = this.contextUsageForModel(input, model);
4840
5067
  const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
4841
5068
  const serializedMessageTokens = estimateCompletionMessageTokens(messages);
4842
5069
  const systemPromptTokens = messages
@@ -4958,12 +5185,15 @@ export class AiManager {
4958
5185
  return this.mergeInstructionEntityMatches(input.scope, matches);
4959
5186
  }
4960
5187
  async prepareChatImageAttachments(workId, modelId, attachmentIds, permissions) {
5188
+ const { model, provider } = this.resolveModel(workId, "chat", modelId);
5189
+ return this.prepareChatImageAttachmentsForModel(workId, model, provider, attachmentIds, permissions);
5190
+ }
5191
+ async prepareChatImageAttachmentsForModel(workId, model, provider, attachmentIds, permissions) {
4961
5192
  const ids = [...new Set(attachmentIds.map((attachmentId) => String(attachmentId).trim()).filter(Boolean))];
4962
5193
  if (ids.length === 0)
4963
5194
  return [];
4964
5195
  if (ids.length > 4)
4965
5196
  throw new AppError(400, "AI_CHAT_IMAGE_LIMIT", "一次最多添加 4 张图片附件");
4966
- const { model, provider } = this.resolveModel(workId, "chat", modelId);
4967
5197
  if (!boolValue(model, "multimodal_enabled")) {
4968
5198
  throw new AppError(400, "MODEL_NOT_MULTIMODAL", "当前选择的模型不是多模态模型,无法处理图片附件");
4969
5199
  }
@@ -5006,11 +5236,10 @@ export class AiManager {
5006
5236
  }
5007
5237
  return prepared;
5008
5238
  }
5009
- async prepareConversationImageAttachments(workId, modelId, conversation) {
5239
+ async prepareConversationImageAttachments(workId, model, provider, conversation) {
5010
5240
  const preparedByMessage = new Map();
5011
5241
  if (!conversation)
5012
5242
  return preparedByMessage;
5013
- const { model, provider } = this.resolveModel(workId, "chat", modelId);
5014
5243
  if (!boolValue(model, "multimodal_enabled") || !supportsMultimodalProviderProtocol(provider)) {
5015
5244
  return preparedByMessage;
5016
5245
  }
@@ -5023,7 +5252,7 @@ export class AiManager {
5023
5252
  : [];
5024
5253
  if (ids.length === 0)
5025
5254
  continue;
5026
- preparedByMessage.set(message.id, await this.prepareChatImageAttachments(workId, modelId, ids, permissions));
5255
+ preparedByMessage.set(message.id, await this.prepareChatImageAttachmentsForModel(workId, model, provider, ids, permissions));
5027
5256
  }
5028
5257
  return preparedByMessage;
5029
5258
  }
@@ -5486,8 +5715,10 @@ export class AiManager {
5486
5715
  ? this.roleplayCharacterId(workId, conversationId)
5487
5716
  : roleplayCharacterIdOverride;
5488
5717
  const permissions = this.store.getWork(workId).modulePermissions;
5718
+ const requested = requestedToolIds ? new Set(requestedToolIds) : null;
5719
+ if (requested?.size === 0)
5720
+ return [];
5489
5721
  if (roleplayCharacterId) {
5490
- const requested = requestedToolIds ? new Set(requestedToolIds) : null;
5491
5722
  if (!canReadWorkModule(permissions, "characters"))
5492
5723
  return [];
5493
5724
  const roleplayTools = [];
@@ -5518,7 +5749,6 @@ export class AiManager {
5518
5749
  : this.store.getWorkAiSettings(workId).agentTools;
5519
5750
  const enabled = new Set(sourceTools
5520
5751
  .filter((item) => typeof item === "string" && CONFIGURED_AGENT_TOOL_IDS.includes(item)));
5521
- const requested = requestedToolIds ? new Set(requestedToolIds) : null;
5522
5752
  return CONFIGURED_AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId)
5523
5753
  && (!requested || requested.has(toolId))
5524
5754
  && this.canReadWithAgentTool(permissions, toolId));
@@ -6722,13 +6952,13 @@ export class AiManager {
6722
6952
  max_tokens: Math.min(Number(parameters.max_tokens) || DEFAULT_MAX_TOKENS, contextWindow - inputTokens)
6723
6953
  };
6724
6954
  }
6725
- constrainParametersForTokenQuota(workId, provider, messages, parameters, tools = [], additionalUsedTokens = 0) {
6955
+ constrainParametersForTokenQuota(workId, provider, messages, parameters, tools = [], additionalUsedTokens = 0, includeProviderQuota = true) {
6726
6956
  const workStatus = this.getWorkTokenQuotaStatus(workId);
6727
- const providerStatus = this.getProviderTokenQuotaStatus(stringValue(provider, "id"));
6957
+ const providerStatus = includeProviderQuota ? this.getProviderTokenQuotaStatus(stringValue(provider, "id")) : null;
6728
6958
  const dailyTokenQuota = workStatus.dailyTokenQuota === null ? null : Number(workStatus.dailyTokenQuota);
6729
6959
  const monthlyTokenQuota = workStatus.monthlyTokenQuota === null ? null : Number(workStatus.monthlyTokenQuota);
6730
- const providerDailyTokenQuota = providerStatus.dailyTokenQuota === null ? null : Number(providerStatus.dailyTokenQuota);
6731
- const providerMonthlyTokenQuota = providerStatus.monthlyTokenQuota === null ? null : Number(providerStatus.monthlyTokenQuota);
6960
+ const providerDailyTokenQuota = providerStatus?.dailyTokenQuota === null || !providerStatus ? null : Number(providerStatus.dailyTokenQuota);
6961
+ const providerMonthlyTokenQuota = providerStatus?.monthlyTokenQuota === null || !providerStatus ? null : Number(providerStatus.monthlyTokenQuota);
6732
6962
  if (dailyTokenQuota === null && monthlyTokenQuota === null && providerDailyTokenQuota === null && providerMonthlyTokenQuota === null)
6733
6963
  return parameters;
6734
6964
  const additionalTokens = Math.max(0, additionalUsedTokens);
@@ -6753,8 +6983,10 @@ export class AiManager {
6753
6983
  resetsAt: String(workStatus.monthlyResetsAt),
6754
6984
  startedAt: String(workStatus.monthStartedAt),
6755
6985
  timezone: String(workStatus.timezone)
6756
- },
6757
- {
6986
+ }
6987
+ ];
6988
+ if (providerStatus) {
6989
+ quotas.push({
6758
6990
  scope: "provider",
6759
6991
  period: "daily",
6760
6992
  quota: providerDailyTokenQuota,
@@ -6764,8 +6996,7 @@ export class AiManager {
6764
6996
  timezone: String(providerStatus.timezone),
6765
6997
  providerId: stringValue(provider, "id"),
6766
6998
  providerName: stringValue(provider, "name")
6767
- },
6768
- {
6999
+ }, {
6769
7000
  scope: "provider",
6770
7001
  period: "monthly",
6771
7002
  quota: providerMonthlyTokenQuota,
@@ -6775,8 +7006,8 @@ export class AiManager {
6775
7006
  timezone: String(providerStatus.timezone),
6776
7007
  providerId: stringValue(provider, "id"),
6777
7008
  providerName: stringValue(provider, "name")
6778
- }
6779
- ];
7009
+ });
7010
+ }
6780
7011
  for (const item of quotas) {
6781
7012
  if (item.quota === null)
6782
7013
  continue;
@@ -6832,8 +7063,8 @@ export class AiManager {
6832
7063
  ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
6833
7064
  : null;
6834
7065
  const generationRoleplayCharacterId = this.roleplayCharacterIdFromConversation(input.workId, conversation);
6835
- const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
6836
- const conversationImageAttachments = await this.prepareConversationImageAttachments(input.workId, String(model.id), conversation);
7066
+ const { model, provider } = input.runtime ?? this.resolveModel(input.workId, input.taskType, input.modelId);
7067
+ const conversationImageAttachments = await this.prepareConversationImageAttachments(input.workId, model, provider, conversation);
6837
7068
  const preset = safeJsonObject(stringValue(model, "preset_json"));
6838
7069
  const requestedParameters = {
6839
7070
  ...this.sanitizeParameters({ ...preset, ...(input.parameters ?? {}) }, stringValue(model, "model_id")),
@@ -6880,14 +7111,24 @@ export class AiManager {
6880
7111
  modelId: stringValue(model, "id")
6881
7112
  });
6882
7113
  }
6883
- parameters = this.constrainParametersForTokenQuota(input.workId, provider, messages, parameters, tools);
7114
+ parameters = this.constrainParametersForTokenQuota(input.workId, provider, messages, parameters, tools, 0, input.runtime === undefined);
7115
+ input.onPrepared?.(this.completionContextUsage(effectiveInput, model, messages, tools));
6884
7116
  const completionMessages = [...messages];
6885
7117
  const callId = id("call");
6886
7118
  const timestamp = now();
6887
7119
  const traceRounds = [];
7120
+ const storedParameters = input.runtime
7121
+ ? {
7122
+ ...parameters,
7123
+ __desktopLocalAi: {
7124
+ provider: this.mapProvider(provider),
7125
+ model: this.mapModel(model)
7126
+ }
7127
+ }
7128
+ : parameters;
6888
7129
  this.store.db.transaction(() => {
6889
7130
  this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
6890
- status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskId ?? null, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
7131
+ status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskId ?? null, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(storedParameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
6891
7132
  if (input.taskId) {
6892
7133
  this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, source_refs_json, created_at, updated_at)
6893
7134
  VALUES (?, ?, ?, '[]', ?, ?, ?)`, callId, input.taskId, JSON.stringify(sanitizeCompletionTraceMessages(messages)), JSON.stringify(taskTraceSourceRefs(messages, [])), timestamp, timestamp);
@@ -6937,11 +7178,16 @@ export class AiManager {
6937
7178
  return "mixed";
6938
7179
  };
6939
7180
  try {
6940
- const { accessToken, credentialSecret } = await this.resolveProviderAccessToken(provider);
6941
- activeSecrets = [credentialSecret, accessToken];
6942
- const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
6943
- const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis"
6944
- ? AI_LONG_RUNNING_TIMEOUT_MS
7181
+ let accessToken = "";
7182
+ let endpoint = "";
7183
+ if (!input.runtime) {
7184
+ const credential = await this.resolveProviderAccessToken(provider);
7185
+ accessToken = credential.accessToken;
7186
+ activeSecrets = [credential.credentialSecret, credential.accessToken];
7187
+ endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
7188
+ }
7189
+ const timeoutMs = isLongRunningAiAnalysisTaskType(input.taskType)
7190
+ ? providerAnalysisTimeoutSeconds(provider) * 1_000
6945
7191
  : AI_INTERACTIVE_TIMEOUT_MS;
6946
7192
  const legacyMaximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
6947
7193
  const maximumAttempts = Math.max(legacyMaximumAttempts, this.retryPolicy.retryCount + 1, this.retryPolicy.backoffRetryCount + 1);
@@ -6957,11 +7203,11 @@ export class AiManager {
6957
7203
  const requestParameters = options.parameters ?? parameters;
6958
7204
  const purpose = options.purpose ?? "generation";
6959
7205
  const requestTools = toolChoice === "auto" ? tools : [];
6960
- const streamResponse = Boolean(onDelta) && purpose === "generation";
7206
+ const streamResponse = !input.runtime && Boolean(onDelta) && purpose === "generation";
6961
7207
  const processRound = streamResponse ? streamingGenerationRound + 1 : 0;
6962
7208
  if (streamResponse)
6963
7209
  streamingGenerationRound = processRound;
6964
- const roundParameters = this.constrainParametersForTokenQuota(input.workId, provider, requestMessages, this.constrainParametersForContext(model, requestMessages, requestParameters, requestTools), requestTools, trackedInputTokens + trackedOutputTokens);
7210
+ const roundParameters = this.constrainParametersForTokenQuota(input.workId, provider, requestMessages, this.constrainParametersForContext(model, requestMessages, requestParameters, requestTools), requestTools, trackedInputTokens + trackedOutputTokens, input.runtime === undefined);
6965
7211
  const traceRound = {
6966
7212
  round: traceRounds.length + 1,
6967
7213
  requestedAt: now(),
@@ -6976,6 +7222,16 @@ export class AiManager {
6976
7222
  attempts: [],
6977
7223
  toolExecutions: []
6978
7224
  };
7225
+ const completionRequestBody = buildCompletionRequestBody({
7226
+ protocol,
7227
+ model: stringValue(model, "model_id"),
7228
+ messages: requestMessages,
7229
+ parameters: roundParameters,
7230
+ maxTokensParameter: providerMaxTokensParameter(provider),
7231
+ tools: requestTools,
7232
+ toolChoice,
7233
+ ...(streamResponse ? { stream: true } : {})
7234
+ });
6979
7235
  traceRounds.push(traceRound);
6980
7236
  saveTrace();
6981
7237
  let streamedThinkingStep = null;
@@ -6997,6 +7253,31 @@ export class AiManager {
6997
7253
  let streamedRoundContent = "";
6998
7254
  try {
6999
7255
  const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
7256
+ if (input.runtime) {
7257
+ const response = await input.runtime.completionTransport({
7258
+ requestId: id("desktop-local-ai-completion"),
7259
+ localModelId: input.runtime.localModelId,
7260
+ taskType: input.taskType,
7261
+ purpose,
7262
+ body: completionRequestBody,
7263
+ timeoutMs
7264
+ });
7265
+ if (response.status < 200 || response.status >= 300) {
7266
+ return {
7267
+ ok: false,
7268
+ status: response.status,
7269
+ body: response.body,
7270
+ retryAfter: response.retryAfter
7271
+ };
7272
+ }
7273
+ try {
7274
+ const payload = parseCompletionPayload(protocol, JSON.parse(response.body));
7275
+ return { ok: true, status: response.status, payload, delivery: "json" };
7276
+ }
7277
+ catch {
7278
+ throw new Error(`${providerProtocolLabelText(protocol)} returned invalid JSON: ${response.body.slice(0, 500)}`);
7279
+ }
7280
+ }
7000
7281
  const controller = new AbortController();
7001
7282
  const forwardAbort = () => controller.abort(input.signal?.reason);
7002
7283
  if (input.signal?.aborted)
@@ -7015,16 +7296,7 @@ export class AiManager {
7015
7296
  const response = await this.outboundFetch(endpoint, {
7016
7297
  method: "POST",
7017
7298
  headers: providerRequestHeaders(protocol, accessToken, streamResponse ? "text/event-stream" : "application/json"),
7018
- body: JSON.stringify(buildCompletionRequestBody({
7019
- protocol,
7020
- model: stringValue(model, "model_id"),
7021
- messages: requestMessages,
7022
- parameters: roundParameters,
7023
- maxTokensParameter: providerMaxTokensParameter(provider),
7024
- tools: requestTools,
7025
- toolChoice,
7026
- ...(streamResponse ? { stream: true } : {})
7027
- })),
7299
+ body: JSON.stringify(completionRequestBody),
7028
7300
  signal: controller.signal
7029
7301
  });
7030
7302
  responseReceived = true;
@@ -11677,19 +11949,22 @@ export class AiManager {
11677
11949
  return row;
11678
11950
  }
11679
11951
  mapProvider(row) {
11952
+ const desktopLocal = boolValue(row, "desktop_local");
11680
11953
  let apiKeyHint = stringValue(row, "key_hint");
11681
- try {
11682
- const secret = this.decryptKey(row);
11683
- apiKeyHint = providerCredentialHint(providerProtocol(row), secret);
11684
- }
11685
- catch {
11686
- // 凭据无法解密时保留数据库中的旧掩码,避免影响供应商列表展示。
11954
+ if (!desktopLocal) {
11955
+ try {
11956
+ const secret = this.decryptKey(row);
11957
+ apiKeyHint = providerCredentialHint(providerProtocol(row), secret);
11958
+ }
11959
+ catch {
11960
+ // 凭据无法解密时保留数据库中的旧掩码,避免影响供应商列表展示。
11961
+ }
11687
11962
  }
11688
11963
  return {
11689
11964
  id: stringValue(row, "id"),
11690
- scope: "platform",
11965
+ scope: desktopLocal ? "local" : "platform",
11691
11966
  name: stringValue(row, "name"),
11692
- baseUrl: stringValue(row, "base_url"),
11967
+ baseUrl: desktopLocal ? "" : stringValue(row, "base_url"),
11693
11968
  protocol: providerProtocol(row),
11694
11969
  maxTokensParameter: providerMaxTokensParameter(row),
11695
11970
  thinkingType: providerThinkingType(row),
@@ -11698,6 +11973,7 @@ export class AiManager {
11698
11973
  connectionStatus: stringValue(row, "connection_status"),
11699
11974
  concurrencyLimit: numberValue(row, "concurrency_limit") || 10,
11700
11975
  rpmLimit: numberValue(row, "rpm_limit") || 10,
11976
+ analysisTimeoutSeconds: providerAnalysisTimeoutSeconds(row),
11701
11977
  dailyTokenQuota: nullableNumberValue(row, "daily_token_quota"),
11702
11978
  monthlyTokenQuota: nullableNumberValue(row, "monthly_token_quota"),
11703
11979
  defaultModelId: row.default_model_id === null ? null : stringValue(row, "default_model_id"),
@@ -11709,8 +11985,10 @@ export class AiManager {
11709
11985
  };
11710
11986
  }
11711
11987
  mapModel(row) {
11988
+ const desktopLocal = boolValue(row, "desktop_local");
11712
11989
  return {
11713
11990
  id: stringValue(row, "id"),
11991
+ ...(desktopLocal ? { scope: "local" } : {}),
11714
11992
  providerId: stringValue(row, "provider_id"),
11715
11993
  displayName: stringValue(row, "display_name"),
11716
11994
  modelId: stringValue(row, "model_id"),
@@ -11722,15 +12000,57 @@ export class AiManager {
11722
12000
  thinkingEnabled: boolValue(row, "thinking_enabled"),
11723
12001
  thinkingEffort: stringValue(row, "thinking_effort") || "default",
11724
12002
  multimodalEnabled: boolValue(row, "multimodal_enabled"),
11725
- imageToolDefault: String(this.store.getPlatformAiSettings().imageToolModelId ?? "") === stringValue(row, "id"),
12003
+ imageToolDefault: !desktopLocal && String(this.store.getPlatformAiSettings().imageToolModelId ?? "") === stringValue(row, "id"),
11726
12004
  enabled: boolValue(row, "enabled"),
11727
12005
  note: stringValue(row, "note"),
11728
12006
  createdAt: stringValue(row, "created_at"),
11729
12007
  updatedAt: stringValue(row, "updated_at")
11730
12008
  };
11731
12009
  }
12010
+ aiCallTarget(row) {
12011
+ const parameters = safeJsonObject(stringValue(row, "parameters_json"));
12012
+ const desktopLocal = parameters.__desktopLocalAi;
12013
+ if (desktopLocal && typeof desktopLocal === "object" && !Array.isArray(desktopLocal)) {
12014
+ const snapshot = desktopLocal;
12015
+ if (snapshot.provider && typeof snapshot.provider === "object" && !Array.isArray(snapshot.provider)
12016
+ && snapshot.model && typeof snapshot.model === "object" && !Array.isArray(snapshot.model)) {
12017
+ return {
12018
+ provider: structuredClone(snapshot.provider),
12019
+ model: structuredClone(snapshot.model)
12020
+ };
12021
+ }
12022
+ }
12023
+ return {
12024
+ provider: this.getProvider(stringValue(row, "provider_id")),
12025
+ model: this.getModel(stringValue(row, "model_id"))
12026
+ };
12027
+ }
12028
+ publicAiCallParameters(row) {
12029
+ const { __desktopLocalAi: _desktopLocalAi, ...parameters } = safeJsonObject(stringValue(row, "parameters_json"));
12030
+ return parameters;
12031
+ }
12032
+ mapCall(row) {
12033
+ const target = this.aiCallTarget(row);
12034
+ return {
12035
+ id: stringValue(row, "id"),
12036
+ workId: stringValue(row, "work_id"),
12037
+ taskId: row.task_id === null ? null : stringValue(row, "task_id"),
12038
+ taskType: stringValue(row, "task_type"),
12039
+ provider: target.provider,
12040
+ model: target.model,
12041
+ contextScope: json(stringValue(row, "context_scope_json"), {}),
12042
+ parameters: this.publicAiCallParameters(row),
12043
+ status: stringValue(row, "status"),
12044
+ failure: row.failure === null ? null : stringValue(row, "failure"),
12045
+ inputChars: numberValue(row, "input_chars"),
12046
+ outputChars: numberValue(row, "output_chars"),
12047
+ createdAt: stringValue(row, "created_at"),
12048
+ completedAt: row.completed_at === null ? null : stringValue(row, "completed_at")
12049
+ };
12050
+ }
11732
12051
  mapSuggestion(row) {
11733
- const call = this.store.db.get("SELECT provider_id, model_id FROM ai_calls WHERE id = ?", stringValue(row, "call_id"));
12052
+ const call = this.store.db.get("SELECT provider_id, model_id, parameters_json FROM ai_calls WHERE id = ?", stringValue(row, "call_id"));
12053
+ const target = call ? this.aiCallTarget(call) : null;
11734
12054
  const guard = this.store.getLatestContinuationGuard(stringValue(row, "id"));
11735
12055
  return {
11736
12056
  id: stringValue(row, "id"),
@@ -11746,8 +12066,8 @@ export class AiManager {
11746
12066
  status: stringValue(row, "status"),
11747
12067
  outputTokens: estimateAiTokens(stringValue(row, "content")),
11748
12068
  guard,
11749
- provider: call ? this.getProvider(stringValue(call, "provider_id")) : null,
11750
- model: call ? this.getModel(stringValue(call, "model_id")) : null,
12069
+ provider: target?.provider ?? null,
12070
+ model: target?.model ?? null,
11751
12071
  createdAt: stringValue(row, "created_at"),
11752
12072
  decidedAt: row.decided_at === null ? null : stringValue(row, "decided_at")
11753
12073
  };