| 1 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 2 | import { |
| 3 | CommandId, |
| 4 | type ChatAttachment, |
| 5 | type MessageId, |
| 6 | type ServerSettingsError, |
| 7 | type ThreadId, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Context from "effect/Context"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; |
| 14 | import * as Schedule from "effect/Schedule"; |
| 15 | |
| 16 | import * as ServerSettings from "../serverSettings.ts"; |
| 17 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 18 | import type { OrchestratorV2Error } from "./Orchestrator.ts"; |
| 19 | import * as ProjectStore from "./ProjectStore.ts"; |
| 20 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 21 | |
| 22 | import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; |
| 23 | export { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; |
| 24 | |
| 25 | export class ThreadTitleRegenerationService extends Context.Service< |
| 26 | ThreadTitleRegenerationService, |
| 27 | { |
| 28 | readonly execute: (input: { |
| 29 | readonly threadId: ThreadId; |
| 30 | readonly requestId: CommandId; |
| 31 | readonly kind: |
| 32 | | { readonly type: "initial"; readonly messageId: MessageId } |
| 33 | | { readonly type: "regenerate" }; |
| 34 | }) => Effect.Effect< |
| 35 | void, |
| 36 | | OrchestratorV2Error |
| 37 | | ProjectStore.ProjectStoreV2Error |
| 38 | | ServerSettingsError |
| 39 | // `complete` dispatches thread.title.regeneration.complete, a command |
| 40 | // the enablement chokepoint never gates; the type stays accurate anyway. |
| 41 | | ThreadManagementService.ThreadManagementProviderInstanceDisabledError |
| 42 | >; |
| 43 | } |
| 44 | >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} |
| 45 | |
| 46 | const make = Effect.gen(function* () { |
| 47 | const threads = yield* ThreadManagementService.ThreadManagementService; |
| 48 | const projects = yield* ProjectStore.ProjectStoreV2; |
| 49 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 50 | const textGeneration = yield* TextGeneration.TextGeneration; |
| 51 | |
| 52 | const complete = (input: { |
| 53 | readonly threadId: ThreadId; |
| 54 | readonly requestId: CommandId; |
| 55 | readonly title?: string; |
| 56 | }) => |
| 57 | threads |
| 58 | .dispatch({ |
| 59 | type: "thread.title.regeneration.complete", |
| 60 | commandId: CommandId.make(`${input.requestId}:title-complete`), |
| 61 | threadId: input.threadId, |
| 62 | requestId: input.requestId, |
| 63 | ...(input.title === undefined ? {} : { title: input.title }), |
| 64 | }) |
| 65 | .pipe(Effect.asVoid); |
| 66 | |
| 67 | const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn( |
| 68 | "ThreadTitleRegenerationService.execute", |
| 69 | )(function* (input) { |
| 70 | const outcome: |
| 71 | | { readonly type: "stale" } |
| 72 | | { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () { |
| 73 | const projection = yield* threads.getThreadRecords( |
| 74 | input.threadId, |
| 75 | ["messages"], |
| 76 | input.kind.type === "initial" |
| 77 | ? { messageIds: [input.kind.messageId] } |
| 78 | : { messageRoles: ["user", "assistant"] }, |
| 79 | ); |
| 80 | if (projection.thread.titleRegeneration?.requestId !== input.requestId) { |
| 81 | return { type: "stale" as const }; |
| 82 | } |
| 83 | |
| 84 | const project = yield* projects.get(projection.thread.projectId); |
| 85 | if (Option.isNone(project)) { |
| 86 | return { type: "complete" as const }; |
| 87 | } |
| 88 | |
| 89 | let context: { |
| 90 | readonly message: string; |
| 91 | readonly attachments: ReadonlyArray<ChatAttachment>; |
| 92 | }; |
| 93 | if (input.kind.type === "initial") { |
| 94 | const messageId = input.kind.messageId; |
| 95 | const message = projection.messages.find( |
| 96 | (candidate) => candidate.id === messageId && !candidate.streaming, |
| 97 | ); |
| 98 | context = |
| 99 | message === undefined |
| 100 | ? { message: "", attachments: [] } |
| 101 | : { message: message.text, attachments: message.attachments }; |
| 102 | } else { |
| 103 | context = formatThreadTitleContext( |
| 104 | projection.messages.filter((message) => !message.streaming), |
| 105 | ); |
| 106 | } |
| 107 | if (context.message.length === 0 && context.attachments.length === 0) { |
| 108 | return { type: "complete" as const }; |
| 109 | } |
| 110 | |
| 111 | const settings = resolveProjectSettings( |
| 112 | yield* serverSettings.getSettings, |
| 113 | projection.thread.projectId, |
| 114 | ).settings; |
| 115 | const result = yield* textGeneration.generateThreadTitle({ |
| 116 | cwd: projection.thread.worktreePath ?? project.value.workspaceRoot, |
| 117 | message: context.message, |
| 118 | attachments: context.attachments, |
| 119 | ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}), |
| 120 | modelSelection: settings.textGenerationModelSelection, |
| 121 | }); |
| 122 | const generatedTitle = result.title.trim(); |
| 123 | return generatedTitle === "New thread" || |
| 124 | (input.kind.type === "regenerate" && generatedTitle === projection.thread.title.trim()) |
| 125 | ? { type: "complete" as const } |
| 126 | : { type: "complete" as const, title: result.title }; |
| 127 | }).pipe( |
| 128 | Effect.retry({ |
| 129 | times: input.kind.type === "initial" ? 2 : 0, |
| 130 | schedule: Schedule.exponential("2 seconds"), |
| 131 | }), |
| 132 | Effect.catchCause((cause) => |
| 133 | Cause.hasInterruptsOnly(cause) |
| 134 | ? Effect.interrupt |
| 135 | : Effect.logWarning("Thread title generation failed", { |
| 136 | threadId: input.threadId, |
| 137 | requestId: input.requestId, |
| 138 | cause, |
| 139 | }).pipe(Effect.as({ type: "complete" as const })), |
| 140 | ), |
| 141 | ); |
| 142 | |
| 143 | if (outcome.type === "stale") { |
| 144 | return; |
| 145 | } |
| 146 | yield* complete({ |
| 147 | ...input, |
| 148 | ...(outcome.title === undefined ? {} : { title: outcome.title }), |
| 149 | }); |
| 150 | }); |
| 151 | |
| 152 | return ThreadTitleRegenerationService.of({ execute }); |
| 153 | }); |
| 154 | |
| 155 | export const layer = Layer.effect(ThreadTitleRegenerationService, make); |