| author | |
| committer | |
| log | 992db1658633ff268be5d1167634966ae63ed859 |
| tree | 19de896b15b24912a0d4a8c0cb5385dd0007e479 |
| parent | 2aad749e2474f79a532764c9f6f7ce4abc3a68f0 |
- Store message attachments in `projection_thread_messages.attachments_json`
- Materialize base64 image attachments to `stateDir/attachments` during projection
- Serve `/attachments/*` from the server and resolve preview URLs from WebSocket origin
- Add migration and tests for projection materialization and attachment serving10 files changed, 443 insertions(+), 80 deletions(-)
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+89| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | } from "./ProjectionPipeline.ts"; | 32 | } from "./ProjectionPipeline.ts"; |
| 33 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 33 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 34 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | 34 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; |
| 35 | import { ServerConfig, type ServerConfigShape } from "../../config.ts"; | ||
| 35 | 36 | ||
| 36 | const projectionLayer = it.layer( | 37 | const projectionLayer = it.layer( |
| 37 | OrchestrationProjectionPipelineLive.pipe( | 38 | OrchestrationProjectionPipelineLive.pipe( |
| ... | @@ -158,6 +159,94 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -158,6 +159,94 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 158 | }), | 159 | }), |
| 159 | ); | 160 | ); |
| 160 | 161 | ||
| 162 | it.effect("materializes message image attachments into stateDir and stores URL references", () => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 165 | const eventStore = yield* OrchestrationEventStore; | ||
| 166 | const sql = yield* SqlClient.SqlClient; | ||
| 167 | const now = new Date().toISOString(); | ||
| 168 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); | ||
| 169 | |||
| 170 | const serverConfig = { | ||
| 171 | mode: "web", | ||
| 172 | port: 0, | ||
| 173 | host: undefined, | ||
| 174 | cwd: "/tmp/project-attachments", | ||
| 175 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 176 | stateDir, | ||
| 177 | staticDir: undefined, | ||
| 178 | devUrl: undefined, | ||
| 179 | noBrowser: true, | ||
| 180 | authToken: undefined, | ||
| 181 | autoBootstrapProjectFromCwd: false, | ||
| 182 | logWebSocketEvents: false, | ||
| 183 | } satisfies ServerConfigShape; | ||
| 184 | |||
| 185 | yield* eventStore.append({ | ||
| 186 | type: "thread.message-sent", | ||
| 187 | eventId: EventId.makeUnsafe("evt-attachments"), | ||
| 188 | aggregateKind: "thread", | ||
| 189 | aggregateId: ThreadId.makeUnsafe("thread-attachments"), | ||
| 190 | occurredAt: now, | ||
| 191 | commandId: CommandId.makeUnsafe("cmd-attachments"), | ||
| 192 | causationEventId: null, | ||
| 193 | correlationId: CommandId.makeUnsafe("cmd-attachments"), | ||
| 194 | metadata: {}, | ||
| 195 | payload: { | ||
| 196 | threadId: ThreadId.makeUnsafe("thread-attachments"), | ||
| 197 | messageId: MessageId.makeUnsafe("message-attachments"), | ||
| 198 | role: "user", | ||
| 199 | text: "Inspect this", | ||
| 200 | attachments: [ | ||
| 201 | { | ||
| 202 | type: "image", | ||
| 203 | name: "example.png", | ||
| 204 | mimeType: "image/png", | ||
| 205 | sizeBytes: 5, | ||
| 206 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 207 | }, | ||
| 208 | ], | ||
| 209 | turnId: null, | ||
| 210 | streaming: false, | ||
| 211 | createdAt: now, | ||
| 212 | updatedAt: now, | ||
| 213 | }, | ||
| 214 | }); | ||
| 215 | |||
| 216 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | ||
| 217 | |||
| 218 | const rows = yield* sql<{ | ||
| 219 | readonly attachmentsJson: string | null; | ||
| 220 | }>` | ||
| 221 | SELECT | ||
| 222 | attachments_json AS "attachmentsJson" | ||
| 223 | FROM projection_thread_messages | ||
| 224 | WHERE message_id = 'message-attachments' | ||
| 225 | `; | ||
| 226 | assert.equal(rows.length, 1); | ||
| 227 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ | ||
| 228 | { | ||
| 229 | type: "image", | ||
| 230 | name: "example.png", | ||
| 231 | mimeType: "image/png", | ||
| 232 | sizeBytes: 5, | ||
| 233 | dataUrl: "/attachments/thread-attachments/message-attachments/0.png", | ||
| 234 | }, | ||
| 235 | ]); | ||
| 236 | |||
| 237 | const attachmentPath = path.join( | ||
| 238 | stateDir, | ||
| 239 | "attachments", | ||
| 240 | "thread-attachments", | ||
| 241 | "message-attachments", | ||
| 242 | "0.png", | ||
| 243 | ); | ||
| 244 | assert.equal(fs.existsSync(attachmentPath), true); | ||
| 245 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64")); | ||
| 246 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 247 | }), | ||
| 248 | ); | ||
| 249 | |||
| 161 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => | 250 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => |
| 162 | Effect.gen(function* () { | 251 | Effect.gen(function* () { |
| 163 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 252 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+134-2| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { Effect, Layer, Option, Stream } from "effect"; | 2 | ApprovalRequestId, |
| 3 | type ChatAttachment, | ||
| 4 | type OrchestrationEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 7 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; | ||
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 4 | 9 | ||
| 5 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; | 10 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; |
| ... | @@ -27,6 +32,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ | ... | @@ -27,6 +32,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ |
| 27 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; | 32 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; |
| 28 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; | 33 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; |
| 29 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; | 34 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; |
| 35 | import { ServerConfig } from "../../config.ts"; | ||
| 30 | import { | 36 | import { |
| 31 | OrchestrationProjectionPipeline, | 37 | OrchestrationProjectionPipeline, |
| 32 | type OrchestrationProjectionPipelineShape, | 38 | type OrchestrationProjectionPipelineShape, |
| ... | @@ -51,6 +57,107 @@ interface ProjectorDefinition { | ... | @@ -51,6 +57,107 @@ interface ProjectorDefinition { |
| 51 | readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>; | 57 | readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>; |
| 52 | } | 58 | } |
| 53 | 59 | ||
| 60 | const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; | ||
| 61 | const ATTACHMENTS_STATE_SUBDIRECTORY = "attachments"; | ||
| 62 | const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = { | ||
| 63 | "image/png": ".png", | ||
| 64 | "image/jpeg": ".jpg", | ||
| 65 | "image/jpg": ".jpg", | ||
| 66 | "image/gif": ".gif", | ||
| 67 | "image/webp": ".webp", | ||
| 68 | "image/svg+xml": ".svg", | ||
| 69 | "image/bmp": ".bmp", | ||
| 70 | "image/tiff": ".tiff", | ||
| 71 | "image/heic": ".heic", | ||
| 72 | }; | ||
| 73 | |||
| 74 | function parseBase64DataUrl( | ||
| 75 | dataUrl: string, | ||
| 76 | ): { readonly mimeType: string; readonly base64: string } | null { | ||
| 77 | const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); | ||
| 78 | if (!match) { | ||
| 79 | return null; | ||
| 80 | } | ||
| 81 | const mimeType = match[1]?.trim().toLowerCase(); | ||
| 82 | const base64 = match[2]?.replace(/\s+/g, ""); | ||
| 83 | if (!mimeType || !base64) { | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | return { mimeType, base64 }; | ||
| 87 | } | ||
| 88 | |||
| 89 | function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string { | ||
| 90 | const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[attachment.mimeType.toLowerCase()]; | ||
| 91 | if (fromMime) { | ||
| 92 | return fromMime; | ||
| 93 | } | ||
| 94 | const fromName = attachment.name.includes(".") | ||
| 95 | ? `.${attachment.name.split(".").slice(-1)[0]?.toLowerCase() ?? ""}` | ||
| 96 | : ""; | ||
| 97 | if (/^\.[a-z0-9]{1,12}$/.test(fromName)) { | ||
| 98 | return fromName; | ||
| 99 | } | ||
| 100 | return ".bin"; | ||
| 101 | } | ||
| 102 | |||
| 103 | const materializeAttachmentsForProjection = Effect.fn(function* (input: { | ||
| 104 | readonly threadId: string; | ||
| 105 | readonly messageId: string; | ||
| 106 | readonly attachments: ReadonlyArray<ChatAttachment>; | ||
| 107 | }) { | ||
| 108 | if (input.attachments.length === 0) { | ||
| 109 | return [] as ReadonlyArray<ChatAttachment>; | ||
| 110 | } | ||
| 111 | |||
| 112 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | ||
| 113 | if (Option.isNone(serverConfig)) { | ||
| 114 | return input.attachments; | ||
| 115 | } | ||
| 116 | |||
| 117 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 118 | const path = yield* Path.Path; | ||
| 119 | const attachmentsRootDir = path.join(serverConfig.value.stateDir, ATTACHMENTS_STATE_SUBDIRECTORY); | ||
| 120 | const threadSegment = encodeURIComponent(input.threadId); | ||
| 121 | const messageSegment = encodeURIComponent(input.messageId); | ||
| 122 | |||
| 123 | return yield* Effect.forEach( | ||
| 124 | input.attachments, | ||
| 125 | (attachment, index) => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | if (attachment.type !== "image" || !attachment.dataUrl.startsWith("data:")) { | ||
| 128 | return attachment; | ||
| 129 | } | ||
| 130 | |||
| 131 | const parsed = parseBase64DataUrl(attachment.dataUrl); | ||
| 132 | if (!parsed) { | ||
| 133 | return attachment; | ||
| 134 | } | ||
| 135 | |||
| 136 | const bytes = Buffer.from(parsed.base64, "base64"); | ||
| 137 | if (bytes.byteLength === 0) { | ||
| 138 | return attachment; | ||
| 139 | } | ||
| 140 | |||
| 141 | const fileName = `${index}${inferImageExtension(attachment)}`; | ||
| 142 | const relativePath = `${threadSegment}/${messageSegment}/${fileName}`; | ||
| 143 | const absolutePath = path.join(attachmentsRootDir, threadSegment, messageSegment, fileName); | ||
| 144 | yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); | ||
| 145 | const alreadyExists = yield* fileSystem | ||
| 146 | .exists(absolutePath) | ||
| 147 | .pipe(Effect.catch(() => Effect.succeed(false))); | ||
| 148 | if (!alreadyExists) { | ||
| 149 | yield* fileSystem.writeFile(absolutePath, bytes); | ||
| 150 | } | ||
| 151 | |||
| 152 | return { | ||
| 153 | ...attachment, | ||
| 154 | dataUrl: `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath}`, | ||
| 155 | } satisfies ChatAttachment; | ||
| 156 | }), | ||
| 157 | { concurrency: 1 }, | ||
| 158 | ); | ||
| 159 | }); | ||
| 160 | |||
| 54 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { | 161 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| 55 | if (typeof payload !== "object" || payload === null) { | 162 | if (typeof payload !== "object" || payload === null) { |
| 56 | return null; | 163 | return null; |
| ... | @@ -175,6 +282,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -175,6 +282,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 175 | const projectionTurnRepository = yield* ProjectionTurnRepository; | 282 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 176 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; | 283 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; |
| 177 | 284 | ||
| 285 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 286 | const path = yield* Path.Path; | ||
| 287 | |||
| 178 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => | 288 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => |
| 179 | Effect.gen(function* () { | 289 | Effect.gen(function* () { |
| 180 | switch (event.type) { | 290 | switch (event.type) { |
| ... | @@ -367,12 +477,33 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -367,12 +477,33 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 367 | : existingMessage && event.payload.text.length === 0 | 477 | : existingMessage && event.payload.text.length === 0 |
| 368 | ? existingMessage.text | 478 | ? existingMessage.text |
| 369 | : event.payload.text; | 479 | : event.payload.text; |
| 480 | const nextAttachments = | ||
| 481 | event.payload.attachments !== undefined | ||
| 482 | ? yield* materializeAttachmentsForProjection({ | ||
| 483 | threadId: event.payload.threadId, | ||
| 484 | messageId: event.payload.messageId, | ||
| 485 | attachments: event.payload.attachments, | ||
| 486 | }).pipe( | ||
| 487 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 488 | Effect.provideService(Path.Path, path), | ||
| 489 | Effect.catch((cause) => | ||
| 490 | Effect.logWarning("failed to persist message attachments", { | ||
| 491 | threadId: event.payload.threadId, | ||
| 492 | messageId: event.payload.messageId, | ||
| 493 | cause, | ||
| 494 | }).pipe(Effect.as(event.payload.attachments)), | ||
| 495 | ), | ||
| 496 | ) | ||
| 497 | : existingMessage?.attachments; | ||
| 370 | yield* projectionThreadMessageRepository.upsert({ | 498 | yield* projectionThreadMessageRepository.upsert({ |
| 371 | messageId: event.payload.messageId, | 499 | messageId: event.payload.messageId, |
| 372 | threadId: event.payload.threadId, | 500 | threadId: event.payload.threadId, |
| 373 | turnId: event.payload.turnId, | 501 | turnId: event.payload.turnId, |
| 374 | role: event.payload.role, | 502 | role: event.payload.role, |
| 375 | text: nextText, | 503 | text: nextText, |
| 504 | ...(nextAttachments && nextAttachments.length > 0 | ||
| 505 | ? { attachments: [...nextAttachments] } | ||
| 506 | : {}), | ||
| 376 | isStreaming: event.payload.streaming, | 507 | isStreaming: event.payload.streaming, |
| 377 | createdAt: existingMessage?.createdAt ?? event.payload.createdAt, | 508 | createdAt: existingMessage?.createdAt ?? event.payload.createdAt, |
| 378 | updatedAt: event.payload.updatedAt, | 509 | updatedAt: event.payload.updatedAt, |
| ... | @@ -901,6 +1032,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( | ... | @@ -901,6 +1032,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 901 | OrchestrationProjectionPipeline, | 1032 | OrchestrationProjectionPipeline, |
| 902 | makeOrchestrationProjectionPipeline, | 1033 | makeOrchestrationProjectionPipeline, |
| 903 | ).pipe( | 1034 | ).pipe( |
| 1035 | Layer.provideMerge(NodeServices.layer), | ||
| 904 | Layer.provideMerge(ProjectionProjectRepositoryLive), | 1036 | Layer.provideMerge(ProjectionProjectRepositoryLive), |
| 905 | Layer.provideMerge(ProjectionThreadRepositoryLive), | 1037 | Layer.provideMerge(ProjectionThreadRepositoryLive), |
| 906 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), | 1038 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+8-5| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | ChatAttachment, | ||
| 2 | IsoDateTime, | 3 | IsoDateTime, |
| 3 | MessageId, | 4 | MessageId, |
| 4 | OrchestrationCheckpointFile, | 5 | OrchestrationCheckpointFile, |
| ... | @@ -43,11 +44,11 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | ... | @@ -43,11 +44,11 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 43 | }), | 44 | }), |
| 44 | ); | 45 | ); |
| 45 | const ProjectionThreadDbRowSchema = ProjectionThread; | 46 | const ProjectionThreadDbRowSchema = ProjectionThread; |
| 46 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | 47 | const ProjectionThreadMessageDbRowSchema = Schema.Struct({ |
| 47 | Struct.assign({ | 48 | ...ProjectionThreadMessage.fields, |
| 48 | isStreaming: Schema.Number, | 49 | isStreaming: Schema.Number, |
| 49 | }), | 50 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), |
| 50 | ); | 51 | }); |
| 51 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | 52 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 52 | Struct.assign({ | 53 | Struct.assign({ |
| 53 | payload: Schema.fromJsonString(Schema.Unknown), | 54 | payload: Schema.fromJsonString(Schema.Unknown), |
| ... | @@ -171,6 +172,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -171,6 +172,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 171 | turn_id AS "turnId", | 172 | turn_id AS "turnId", |
| 172 | role, | 173 | role, |
| 173 | text, | 174 | text, |
| 175 | attachments_json AS "attachments", | ||
| 174 | is_streaming AS "isStreaming", | 176 | is_streaming AS "isStreaming", |
| 175 | created_at AS "createdAt", | 177 | created_at AS "createdAt", |
| 176 | updated_at AS "updatedAt" | 178 | updated_at AS "updatedAt" |
| ... | @@ -376,6 +378,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -376,6 +378,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 376 | id: row.messageId, | 378 | id: row.messageId, |
| 377 | role: row.role, | 379 | role: row.role, |
| 378 | text: row.text, | 380 | text: row.text, |
| 381 | ...(row.attachments !== null ? { attachments: row.attachments } : {}), | ||
| 379 | turnId: row.turnId, | 382 | turnId: row.turnId, |
| 380 | streaming: row.isStreaming === 1, | 383 | streaming: row.isStreaming === 1, |
| 381 | createdAt: row.createdAt, | 384 | createdAt: row.createdAt, |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+19-7| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | import { Effect, Layer, Schema, Struct } from "effect"; | 3 | import { Effect, Layer, Schema } from "effect"; |
| 4 | import { ChatAttachment } from "@t3tools/contracts"; | ||
| 4 | 5 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | 6 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 6 | import { | 7 | import { |
| ... | @@ -11,11 +12,11 @@ import { | ... | @@ -11,11 +12,11 @@ import { |
| 11 | ProjectionThreadMessage, | 12 | ProjectionThreadMessage, |
| 12 | } from "../Services/ProjectionThreadMessages.ts"; | 13 | } from "../Services/ProjectionThreadMessages.ts"; |
| 13 | 14 | ||
| 14 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | 15 | const ProjectionThreadMessageDbRowSchema = Schema.Struct({ |
| 15 | Struct.assign({ | 16 | ...ProjectionThreadMessage.fields, |
| 16 | isStreaming: Schema.Number, | 17 | isStreaming: Schema.Number, |
| 17 | }), | 18 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), |
| 18 | ); | 19 | }); |
| 19 | 20 | ||
| 20 | const makeProjectionThreadMessageRepository = Effect.gen(function* () { | 21 | const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 21 | const sql = yield* SqlClient.SqlClient; | 22 | const sql = yield* SqlClient.SqlClient; |
| ... | @@ -30,6 +31,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -30,6 +31,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 30 | turn_id, | 31 | turn_id, |
| 31 | role, | 32 | role, |
| 32 | text, | 33 | text, |
| 34 | attachments_json, | ||
| 33 | is_streaming, | 35 | is_streaming, |
| 34 | created_at, | 36 | created_at, |
| 35 | updated_at | 37 | updated_at |
| ... | @@ -40,6 +42,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -40,6 +42,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 40 | ${row.turnId}, | 42 | ${row.turnId}, |
| 41 | ${row.role}, | 43 | ${row.role}, |
| 42 | ${row.text}, | 44 | ${row.text}, |
| 45 | ${row.attachments !== undefined ? JSON.stringify(row.attachments) : null}, | ||
| 43 | ${row.isStreaming ? 1 : 0}, | 46 | ${row.isStreaming ? 1 : 0}, |
| 44 | ${row.createdAt}, | 47 | ${row.createdAt}, |
| 45 | ${row.updatedAt} | 48 | ${row.updatedAt} |
| ... | @@ -50,6 +53,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -50,6 +53,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 50 | turn_id = excluded.turn_id, | 53 | turn_id = excluded.turn_id, |
| 51 | role = excluded.role, | 54 | role = excluded.role, |
| 52 | text = excluded.text, | 55 | text = excluded.text, |
| 56 | attachments_json = excluded.attachments_json, | ||
| 53 | is_streaming = excluded.is_streaming, | 57 | is_streaming = excluded.is_streaming, |
| 54 | created_at = excluded.created_at, | 58 | created_at = excluded.created_at, |
| 55 | updated_at = excluded.updated_at | 59 | updated_at = excluded.updated_at |
| ... | @@ -67,6 +71,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -67,6 +71,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 67 | turn_id AS "turnId", | 71 | turn_id AS "turnId", |
| 68 | role, | 72 | role, |
| 69 | text, | 73 | text, |
| 74 | attachments_json AS "attachments", | ||
| 70 | is_streaming AS "isStreaming", | 75 | is_streaming AS "isStreaming", |
| 71 | created_at AS "createdAt", | 76 | created_at AS "createdAt", |
| 72 | updated_at AS "updatedAt" | 77 | updated_at AS "updatedAt" |
| ... | @@ -97,8 +102,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -97,8 +102,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 97 | ), | 102 | ), |
| 98 | Effect.map((rows) => | 103 | Effect.map((rows) => |
| 99 | rows.map((row) => ({ | 104 | rows.map((row) => ({ |
| 100 | ...row, | 105 | messageId: row.messageId, |
| 106 | threadId: row.threadId, | ||
| 107 | turnId: row.turnId, | ||
| 108 | role: row.role, | ||
| 109 | text: row.text, | ||
| 101 | isStreaming: row.isStreaming === 1, | 110 | isStreaming: row.isStreaming === 1, |
| 111 | createdAt: row.createdAt, | ||
| 112 | updatedAt: row.updatedAt, | ||
| 113 | ...(row.attachments !== null ? { attachments: row.attachments } : {}), | ||
| 102 | })), | 114 | })), |
| 103 | ), | 115 | ), |
| 104 | ); | 116 | ); |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; | ... | @@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; | 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; |
| 19 | import Migration0005 from "./Migrations/005_Projections.ts"; | 19 | import Migration0005 from "./Migrations/005_Projections.ts"; |
| 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; | 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; |
| 21 | import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; | ||
| 21 | 22 | ||
| 22 | /** | 23 | /** |
| 23 | * Migration loader with all migrations defined inline. | 24 | * Migration loader with all migrations defined inline. |
| ... | @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ | ... | @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ |
| 36 | "4_ProviderSessionRuntime": Migration0004, | 37 | "4_ProviderSessionRuntime": Migration0004, |
| 37 | "5_Projections": Migration0005, | 38 | "5_Projections": Migration0005, |
| 38 | "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, | 39 | "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, |
| 40 | "7_ProjectionThreadMessageAttachments": Migration0007, | ||
| 39 | }); | 41 | }); |
| 40 | 42 | ||
| 41 | /** | 43 | /** |
apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | yield* sql` | ||
| 8 | ALTER TABLE projection_thread_messages | ||
| 9 | ADD COLUMN attachments_json TEXT | ||
| 10 | `; | ||
| 11 | }); | ||
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+2| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module ProjectionThreadMessageRepository | 7 | * @module ProjectionThreadMessageRepository |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | ChatAttachment, | ||
| 10 | OrchestrationMessageRole, | 11 | OrchestrationMessageRole, |
| 11 | MessageId, | 12 | MessageId, |
| 12 | ThreadId, | 13 | ThreadId, |
| ... | @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ | ... | @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ |
| 24 | turnId: Schema.NullOr(TurnId), | 25 | turnId: Schema.NullOr(TurnId), |
| 25 | role: OrchestrationMessageRole, | 26 | role: OrchestrationMessageRole, |
| 26 | text: Schema.String, | 27 | text: Schema.String, |
| 28 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | ||
| 27 | isStreaming: Schema.Boolean, | 29 | isStreaming: Schema.Boolean, |
| 28 | createdAt: IsoDateTime, | 30 | createdAt: IsoDateTime, |
| 29 | updatedAt: IsoDateTime, | 31 | updatedAt: IsoDateTime, |
apps/server/src/wsServer.test.ts+18| ... | @@ -438,6 +438,24 @@ describe("WebSocket Server", () => { | ... | @@ -438,6 +438,24 @@ describe("WebSocket Server", () => { |
| 438 | }); | 438 | }); |
| 439 | }); | 439 | }); |
| 440 | 440 | ||
| 441 | it("serves persisted attachments from stateDir", async () => { | ||
| 442 | const stateDir = makeTempDir("t3code-state-attachments-"); | ||
| 443 | const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png"); | ||
| 444 | fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); | ||
| 445 | fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); | ||
| 446 | |||
| 447 | server = await createTestServer({ cwd: "/test/project", stateDir }); | ||
| 448 | const addr = server.address(); | ||
| 449 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 450 | expect(port).toBeGreaterThan(0); | ||
| 451 | |||
| 452 | const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); | ||
| 453 | expect(response.status).toBe(200); | ||
| 454 | expect(response.headers.get("content-type")).toContain("image/png"); | ||
| 455 | const bytes = Buffer.from(await response.arrayBuffer()); | ||
| 456 | expect(bytes).toEqual(Buffer.from("hello-attachment")); | ||
| 457 | }); | ||
| 458 | |||
| 441 | it("bootstraps the cwd project on startup when enabled", async () => { | 459 | it("bootstraps the cwd project on startup when enabled", async () => { |
| 442 | server = await createTestServer({ | 460 | server = await createTestServer({ |
| 443 | cwd: "/test/bootstrap-workspace", | 461 | cwd: "/test/bootstrap-workspace", |
apps/server/src/wsServer.ts+127-65| ... | @@ -6,11 +6,10 @@ | ... | @@ -6,11 +6,10 @@ |
| 6 | * | 6 | * |
| 7 | * @module Server | 7 | * @module Server |
| 8 | */ | 8 | */ |
| 9 | import fs from "node:fs"; | ||
| 10 | import http from "node:http"; | 9 | import http from "node:http"; |
| 11 | import path from "node:path"; | ||
| 12 | import type { Duplex } from "node:stream"; | 10 | import type { Duplex } from "node:stream"; |
| 13 | 11 | ||
| 12 | import Mime from "@effect/platform-node/Mime"; | ||
| 14 | import { | 13 | import { |
| 15 | CommandId, | 14 | CommandId, |
| 16 | ORCHESTRATION_WS_CHANNELS, | 15 | ORCHESTRATION_WS_CHANNELS, |
| ... | @@ -24,8 +23,22 @@ import { | ... | @@ -24,8 +23,22 @@ import { |
| 24 | WsPush, | 23 | WsPush, |
| 25 | WsResponse, | 24 | WsResponse, |
| 26 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 27 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 27 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 28 | import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; | 28 | import { |
| 29 | Cause, | ||
| 30 | Effect, | ||
| 31 | Exit, | ||
| 32 | FileSystem, | ||
| 33 | Layer, | ||
| 34 | Path, | ||
| 35 | Ref, | ||
| 36 | Schema, | ||
| 37 | Scope, | ||
| 38 | ServiceMap, | ||
| 39 | Stream, | ||
| 40 | Struct, | ||
| 41 | } from "effect"; | ||
| 29 | import { WebSocketServer, type WebSocket } from "ws"; | 42 | import { WebSocketServer, type WebSocket } from "ws"; |
| 30 | 43 | ||
| 31 | import { createLogger } from "./logger"; | 44 | import { createLogger } from "./logger"; |
| ... | @@ -44,20 +57,7 @@ import { ServerConfig } from "./config"; | ... | @@ -44,20 +57,7 @@ import { ServerConfig } from "./config"; |
| 44 | import { GitCore } from "./git/Services/GitCore.ts"; | 57 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 45 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 58 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 46 | 59 | ||
| 47 | const MIME_TYPES: Record<string, string> = { | 60 | const ATTACHMENTS_ROUTE_PREFIX = "/attachments/"; |
| 48 | ".html": "text/html; charset=utf-8", | ||
| 49 | ".js": "application/javascript; charset=utf-8", | ||
| 50 | ".css": "text/css; charset=utf-8", | ||
| 51 | ".json": "application/json; charset=utf-8", | ||
| 52 | ".png": "image/png", | ||
| 53 | ".jpg": "image/jpeg", | ||
| 54 | ".svg": "image/svg+xml", | ||
| 55 | ".ico": "image/x-icon", | ||
| 56 | ".woff": "font/woff", | ||
| 57 | ".woff2": "font/woff2", | ||
| 58 | ".ttf": "font/ttf", | ||
| 59 | ".map": "application/json", | ||
| 60 | }; | ||
| 61 | 61 | ||
| 62 | /** | 62 | /** |
| 63 | * ServerShape - Service API for server lifecycle control. | 63 | * ServerShape - Service API for server lifecycle control. |
| ... | @@ -69,7 +69,7 @@ export interface ServerShape { | ... | @@ -69,7 +69,7 @@ export interface ServerShape { |
| 69 | readonly start: Effect.Effect< | 69 | readonly start: Effect.Effect< |
| 70 | http.Server, | 70 | http.Server, |
| 71 | ServerLifecycleError, | 71 | ServerLifecycleError, |
| 72 | Scope.Scope | ServerRuntimeServices | ServerConfig | 72 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path |
| 73 | >; | 73 | >; |
| 74 | 74 | ||
| 75 | /** | 75 | /** |
| ... | @@ -168,7 +168,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro | ... | @@ -168,7 +168,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro |
| 168 | export const createServer = Effect.fn(function* (): Effect.fn.Return< | 168 | export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 169 | http.Server, | 169 | http.Server, |
| 170 | ServerLifecycleError, | 170 | ServerLifecycleError, |
| 171 | Scope.Scope | ServerRuntimeServices | ServerConfig | 171 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path |
| 172 | > { | 172 | > { |
| 173 | const serverConfig = yield* ServerConfig; | 173 | const serverConfig = yield* ServerConfig; |
| 174 | const { | 174 | const { |
| ... | @@ -187,6 +187,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -187,6 +187,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 187 | const terminalManager = yield* TerminalManager; | 187 | const terminalManager = yield* TerminalManager; |
| 188 | const keybindingsManager = yield* Keybindings; | 188 | const keybindingsManager = yield* Keybindings; |
| 189 | const git = yield* GitCore; | 189 | const git = yield* GitCore; |
| 190 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 191 | const path = yield* Path.Path; | ||
| 190 | 192 | ||
| 191 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( | 193 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( |
| 192 | Effect.catch((error) => | 194 | Effect.catch((error) => |
| ... | @@ -233,62 +235,122 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -233,62 +235,122 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 233 | 235 | ||
| 234 | // HTTP server — serves static files or redirects to Vite dev server | 236 | // HTTP server — serves static files or redirects to Vite dev server |
| 235 | const httpServer = http.createServer((req, res) => { | 237 | const httpServer = http.createServer((req, res) => { |
| 236 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | 238 | const respond = ( |
| 237 | 239 | statusCode: number, | |
| 238 | if (tryHandleProjectFaviconRequest(url, res)) { | 240 | headers: Record<string, string>, |
| 239 | return; | 241 | body?: string | Uint8Array, |
| 240 | } | 242 | ) => { |
| 243 | res.writeHead(statusCode, headers); | ||
| 244 | res.end(body); | ||
| 245 | }; | ||
| 241 | 246 | ||
| 242 | // In dev mode, redirect to Vite dev server | 247 | void Effect.runPromise( |
| 243 | if (devUrl) { | 248 | Effect.gen(function* () { |
| 244 | res.writeHead(302, { Location: devUrl.href }); | 249 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); |
| 245 | res.end(); | 250 | if (tryHandleProjectFaviconRequest(url, res)) { |
| 246 | return; | 251 | return; |
| 247 | } | 252 | } |
| 248 | 253 | ||
| 249 | // Serve static files from the web app build | 254 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { |
| 250 | if (!staticDir) { | 255 | const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); |
| 251 | res.writeHead(503, { "Content-Type": "text/plain" }); | 256 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); |
| 252 | res.end("No static directory configured and no dev URL set."); | 257 | const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); |
| 253 | return; | 258 | |
| 254 | } | 259 | if ( |
| 260 | normalizedRelativePath.length === 0 || | ||
| 261 | normalizedRelativePath.startsWith("..") || | ||
| 262 | normalizedRelativePath.includes("\0") | ||
| 263 | ) { | ||
| 264 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | ||
| 265 | return; | ||
| 266 | } | ||
| 255 | 267 | ||
| 256 | let filePath = path.join(staticDir, url.pathname); | 268 | const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); |
| 269 | if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { | ||
| 270 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | ||
| 271 | return; | ||
| 272 | } | ||
| 257 | 273 | ||
| 258 | // SPA fallback: if no file extension and not found, serve index.html | 274 | const fileInfo = yield* fileSystem |
| 259 | const ext = path.extname(filePath); | 275 | .stat(filePath) |
| 260 | if (!ext) { | 276 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 261 | filePath = path.join(filePath, "index.html"); | 277 | if (!fileInfo || fileInfo.type !== "File") { |
| 262 | } | 278 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); |
| 279 | return; | ||
| 280 | } | ||
| 263 | 281 | ||
| 264 | fs.stat(filePath, (err, stats) => { | 282 | const data = yield* fileSystem |
| 265 | if (err || !stats?.isFile()) { | 283 | .readFile(filePath) |
| 266 | // SPA fallback | 284 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 267 | const indexPath = path.join(staticDir, "index.html"); | 285 | if (!data) { |
| 268 | fs.readFile(indexPath, (readErr, data) => { | 286 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); |
| 269 | if (readErr) { | ||
| 270 | res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| 271 | res.end("Not Found"); | ||
| 272 | return; | 287 | return; |
| 273 | } | 288 | } |
| 274 | res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | ||
| 275 | res.end(data); | ||
| 276 | }); | ||
| 277 | return; | ||
| 278 | } | ||
| 279 | 289 | ||
| 280 | const fileExt = path.extname(filePath); | 290 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; |
| 281 | const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream"; | 291 | respond( |
| 292 | 200, | ||
| 293 | { | ||
| 294 | "Content-Type": contentType, | ||
| 295 | "Cache-Control": "public, max-age=31536000, immutable", | ||
| 296 | }, | ||
| 297 | data, | ||
| 298 | ); | ||
| 299 | return; | ||
| 300 | } | ||
| 282 | 301 | ||
| 283 | fs.readFile(filePath, (readErr, data) => { | 302 | // In dev mode, redirect to Vite dev server |
| 284 | if (readErr) { | 303 | if (devUrl) { |
| 285 | res.writeHead(500, { "Content-Type": "text/plain" }); | 304 | respond(302, { Location: devUrl.href }); |
| 286 | res.end("Internal Server Error"); | ||
| 287 | return; | 305 | return; |
| 288 | } | 306 | } |
| 289 | res.writeHead(200, { "Content-Type": contentType }); | 307 | |
| 290 | res.end(data); | 308 | // Serve static files from the web app build |
| 291 | }); | 309 | if (!staticDir) { |
| 310 | respond( | ||
| 311 | 503, | ||
| 312 | { "Content-Type": "text/plain" }, | ||
| 313 | "No static directory configured and no dev URL set.", | ||
| 314 | ); | ||
| 315 | return; | ||
| 316 | } | ||
| 317 | |||
| 318 | let filePath = path.join(staticDir, url.pathname); | ||
| 319 | const ext = path.extname(filePath); | ||
| 320 | if (!ext) { | ||
| 321 | filePath = path.join(filePath, "index.html"); | ||
| 322 | } | ||
| 323 | |||
| 324 | const fileInfo = yield* fileSystem | ||
| 325 | .stat(filePath) | ||
| 326 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 327 | if (!fileInfo || fileInfo.type !== "File") { | ||
| 328 | const indexPath = path.join(staticDir, "index.html"); | ||
| 329 | const indexData = yield* fileSystem | ||
| 330 | .readFile(indexPath) | ||
| 331 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 332 | if (!indexData) { | ||
| 333 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); | ||
| 334 | return; | ||
| 335 | } | ||
| 336 | respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); | ||
| 337 | return; | ||
| 338 | } | ||
| 339 | |||
| 340 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; | ||
| 341 | const data = yield* fileSystem | ||
| 342 | .readFile(filePath) | ||
| 343 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 344 | if (!data) { | ||
| 345 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | ||
| 346 | return; | ||
| 347 | } | ||
| 348 | respond(200, { "Content-Type": contentType }, data); | ||
| 349 | }), | ||
| 350 | ).catch(() => { | ||
| 351 | if (!res.headersSent) { | ||
| 352 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | ||
| 353 | } | ||
| 292 | }); | 354 | }); |
| 293 | }); | 355 | }); |
| 294 | 356 | ||
| ... | @@ -692,6 +754,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -692,6 +754,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 692 | }); | 754 | }); |
| 693 | 755 | ||
| 694 | export const ServerLive = Layer.succeed(Server, { | 756 | export const ServerLive = Layer.succeed(Server, { |
| 695 | start: createServer(), | 757 | start: createServer().pipe(Effect.provide(NodeServices.layer)), |
| 696 | stopSignal: Effect.never, | 758 | stopSignal: Effect.never, |
| 697 | } satisfies ServerShape); | 759 | } satisfies ServerShape); |
apps/web/src/store.ts+33-1| ... | @@ -192,6 +192,38 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { | ... | @@ -192,6 +192,38 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { |
| 192 | return providerName === "claudeCode" ? "claudeCode" : "codex"; | 192 | return providerName === "claudeCode" ? "claudeCode" : "codex"; |
| 193 | } | 193 | } |
| 194 | 194 | ||
| 195 | function resolveWsHttpOrigin(): string { | ||
| 196 | if (typeof window === "undefined") { | ||
| 197 | return ""; | ||
| 198 | } | ||
| 199 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); | ||
| 200 | const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 201 | const wsCandidate = | ||
| 202 | typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 | ||
| 203 | ? bridgeWsUrl | ||
| 204 | : typeof envWsUrl === "string" && envWsUrl.length > 0 | ||
| 205 | ? envWsUrl | ||
| 206 | : null; | ||
| 207 | if (!wsCandidate) { | ||
| 208 | return window.location.origin; | ||
| 209 | } | ||
| 210 | |||
| 211 | try { | ||
| 212 | const wsUrl = new URL(wsCandidate); | ||
| 213 | const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:"; | ||
| 214 | return `${protocol}//${wsUrl.host}`; | ||
| 215 | } catch { | ||
| 216 | return window.location.origin; | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | function toAttachmentPreviewUrl(rawUrl: string): string { | ||
| 221 | if (rawUrl.startsWith("/")) { | ||
| 222 | return `${resolveWsHttpOrigin()}${rawUrl}`; | ||
| 223 | } | ||
| 224 | return rawUrl; | ||
| 225 | } | ||
| 226 | |||
| 195 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 227 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 196 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | 228 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 197 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); | 229 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); |
| ... | @@ -456,7 +488,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -456,7 +488,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 456 | name: attachment.name, | 488 | name: attachment.name, |
| 457 | mimeType: attachment.mimeType, | 489 | mimeType: attachment.mimeType, |
| 458 | sizeBytes: attachment.sizeBytes, | 490 | sizeBytes: attachment.sizeBytes, |
| 459 | previewUrl: attachment.dataUrl, | 491 | previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), |
| 460 | })); | 492 | })); |
| 461 | const normalizedMessage: ChatMessage = { | 493 | const normalizedMessage: ChatMessage = { |
| 462 | id: message.id, | 494 | id: message.id, |