| 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 | 32 | } from "./ProjectionPipeline.ts"; |
| 33 | 33 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 34 | 34 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; |
| 35 | import { ServerConfig, type ServerConfigShape } from "../../config.ts"; | |
| 35 | 36 | |
| 36 | 37 | const projectionLayer = it.layer( |
| 37 | 38 | OrchestrationProjectionPipelineLive.pipe( |
| ... | ... | @@ -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 | 250 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => |
| 162 | 251 | Effect.gen(function* () { |
| 163 | 252 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+134-2| ... | ... | @@ -1,5 +1,10 @@ |
| 1 | import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer, Option, Stream } from "effect"; | |
| 1 | import { | |
| 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 | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 4 | 9 | |
| 5 | 10 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; |
| ... | ... | @@ -27,6 +32,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ |
| 27 | 32 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; |
| 28 | 33 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; |
| 29 | 34 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; |
| 35 | import { ServerConfig } from "../../config.ts"; | |
| 30 | 36 | import { |
| 31 | 37 | OrchestrationProjectionPipeline, |
| 32 | 38 | type OrchestrationProjectionPipelineShape, |
| ... | ... | @@ -51,6 +57,107 @@ interface ProjectorDefinition { |
| 51 | 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 | 161 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| 55 | 162 | if (typeof payload !== "object" || payload === null) { |
| 56 | 163 | return null; |
| ... | ... | @@ -175,6 +282,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 175 | 282 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 176 | 283 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; |
| 177 | 284 | |
| 285 | const fileSystem = yield* FileSystem.FileSystem; | |
| 286 | const path = yield* Path.Path; | |
| 287 | ||
| 178 | 288 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => |
| 179 | 289 | Effect.gen(function* () { |
| 180 | 290 | switch (event.type) { |
| ... | ... | @@ -367,12 +477,33 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 367 | 477 | : existingMessage && event.payload.text.length === 0 |
| 368 | 478 | ? existingMessage.text |
| 369 | 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 | 498 | yield* projectionThreadMessageRepository.upsert({ |
| 371 | 499 | messageId: event.payload.messageId, |
| 372 | 500 | threadId: event.payload.threadId, |
| 373 | 501 | turnId: event.payload.turnId, |
| 374 | 502 | role: event.payload.role, |
| 375 | 503 | text: nextText, |
| 504 | ...(nextAttachments && nextAttachments.length > 0 | |
| 505 | ? { attachments: [...nextAttachments] } | |
| 506 | : {}), | |
| 376 | 507 | isStreaming: event.payload.streaming, |
| 377 | 508 | createdAt: existingMessage?.createdAt ?? event.payload.createdAt, |
| 378 | 509 | updatedAt: event.payload.updatedAt, |
| ... | ... | @@ -901,6 +1032,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 901 | 1032 | OrchestrationProjectionPipeline, |
| 902 | 1033 | makeOrchestrationProjectionPipeline, |
| 903 | 1034 | ).pipe( |
| 1035 | Layer.provideMerge(NodeServices.layer), | |
| 904 | 1036 | Layer.provideMerge(ProjectionProjectRepositoryLive), |
| 905 | 1037 | Layer.provideMerge(ProjectionThreadRepositoryLive), |
| 906 | 1038 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+8-5| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | ChatAttachment, | |
| 2 | 3 | IsoDateTime, |
| 3 | 4 | MessageId, |
| 4 | 5 | OrchestrationCheckpointFile, |
| ... | ... | @@ -43,11 +44,11 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 43 | 44 | }), |
| 44 | 45 | ); |
| 45 | 46 | const ProjectionThreadDbRowSchema = ProjectionThread; |
| 46 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | |
| 47 | Struct.assign({ | |
| 48 | isStreaming: Schema.Number, | |
| 49 | }), | |
| 50 | ); | |
| 47 | const ProjectionThreadMessageDbRowSchema = Schema.Struct({ | |
| 48 | ...ProjectionThreadMessage.fields, | |
| 49 | isStreaming: Schema.Number, | |
| 50 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), | |
| 51 | }); | |
| 51 | 52 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 52 | 53 | Struct.assign({ |
| 53 | 54 | payload: Schema.fromJsonString(Schema.Unknown), |
| ... | ... | @@ -171,6 +172,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 171 | 172 | turn_id AS "turnId", |
| 172 | 173 | role, |
| 173 | 174 | text, |
| 175 | attachments_json AS "attachments", | |
| 174 | 176 | is_streaming AS "isStreaming", |
| 175 | 177 | created_at AS "createdAt", |
| 176 | 178 | updated_at AS "updatedAt" |
| ... | ... | @@ -376,6 +378,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 376 | 378 | id: row.messageId, |
| 377 | 379 | role: row.role, |
| 378 | 380 | text: row.text, |
| 381 | ...(row.attachments !== null ? { attachments: row.attachments } : {}), | |
| 379 | 382 | turnId: row.turnId, |
| 380 | 383 | streaming: row.isStreaming === 1, |
| 381 | 384 | createdAt: row.createdAt, |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+19-7| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | 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 | 6 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 6 | 7 | import { |
| ... | ... | @@ -11,11 +12,11 @@ import { |
| 11 | 12 | ProjectionThreadMessage, |
| 12 | 13 | } from "../Services/ProjectionThreadMessages.ts"; |
| 13 | 14 | |
| 14 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | |
| 15 | Struct.assign({ | |
| 16 | isStreaming: Schema.Number, | |
| 17 | }), | |
| 18 | ); | |
| 15 | const ProjectionThreadMessageDbRowSchema = Schema.Struct({ | |
| 16 | ...ProjectionThreadMessage.fields, | |
| 17 | isStreaming: Schema.Number, | |
| 18 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), | |
| 19 | }); | |
| 19 | 20 | |
| 20 | 21 | const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 21 | 22 | const sql = yield* SqlClient.SqlClient; |
| ... | ... | @@ -30,6 +31,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 30 | 31 | turn_id, |
| 31 | 32 | role, |
| 32 | 33 | text, |
| 34 | attachments_json, | |
| 33 | 35 | is_streaming, |
| 34 | 36 | created_at, |
| 35 | 37 | updated_at |
| ... | ... | @@ -40,6 +42,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 40 | 42 | ${row.turnId}, |
| 41 | 43 | ${row.role}, |
| 42 | 44 | ${row.text}, |
| 45 | ${row.attachments !== undefined ? JSON.stringify(row.attachments) : null}, | |
| 43 | 46 | ${row.isStreaming ? 1 : 0}, |
| 44 | 47 | ${row.createdAt}, |
| 45 | 48 | ${row.updatedAt} |
| ... | ... | @@ -50,6 +53,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 50 | 53 | turn_id = excluded.turn_id, |
| 51 | 54 | role = excluded.role, |
| 52 | 55 | text = excluded.text, |
| 56 | attachments_json = excluded.attachments_json, | |
| 53 | 57 | is_streaming = excluded.is_streaming, |
| 54 | 58 | created_at = excluded.created_at, |
| 55 | 59 | updated_at = excluded.updated_at |
| ... | ... | @@ -67,6 +71,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 67 | 71 | turn_id AS "turnId", |
| 68 | 72 | role, |
| 69 | 73 | text, |
| 74 | attachments_json AS "attachments", | |
| 70 | 75 | is_streaming AS "isStreaming", |
| 71 | 76 | created_at AS "createdAt", |
| 72 | 77 | updated_at AS "updatedAt" |
| ... | ... | @@ -97,8 +102,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 97 | 102 | ), |
| 98 | 103 | Effect.map((rows) => |
| 99 | 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 | 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 | 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; |
| 19 | 19 | import Migration0005 from "./Migrations/005_Projections.ts"; |
| 20 | 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; |
| 21 | import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; | |
| 21 | 22 | |
| 22 | 23 | /** |
| 23 | 24 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ |
| 36 | 37 | "4_ProviderSessionRuntime": Migration0004, |
| 37 | 38 | "5_Projections": Migration0005, |
| 38 | 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 | 7 | * @module ProjectionThreadMessageRepository |
| 8 | 8 | */ |
| 9 | 9 | import { |
| 10 | ChatAttachment, | |
| 10 | 11 | OrchestrationMessageRole, |
| 11 | 12 | MessageId, |
| 12 | 13 | ThreadId, |
| ... | ... | @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ |
| 24 | 25 | turnId: Schema.NullOr(TurnId), |
| 25 | 26 | role: OrchestrationMessageRole, |
| 26 | 27 | text: Schema.String, |
| 28 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | |
| 27 | 29 | isStreaming: Schema.Boolean, |
| 28 | 30 | createdAt: IsoDateTime, |
| 29 | 31 | updatedAt: IsoDateTime, |
apps/server/src/wsServer.test.ts+18| ... | ... | @@ -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 | 459 | it("bootstraps the cwd project on startup when enabled", async () => { |
| 442 | 460 | server = await createTestServer({ |
| 443 | 461 | cwd: "/test/bootstrap-workspace", |
apps/server/src/wsServer.ts+127-65| ... | ... | @@ -6,11 +6,10 @@ |
| 6 | 6 | * |
| 7 | 7 | * @module Server |
| 8 | 8 | */ |
| 9 | import fs from "node:fs"; | |
| 10 | 9 | import http from "node:http"; |
| 11 | import path from "node:path"; | |
| 12 | 10 | import type { Duplex } from "node:stream"; |
| 13 | 11 | |
| 12 | import Mime from "@effect/platform-node/Mime"; | |
| 14 | 13 | import { |
| 15 | 14 | CommandId, |
| 16 | 15 | ORCHESTRATION_WS_CHANNELS, |
| ... | ... | @@ -24,8 +23,22 @@ import { |
| 24 | 23 | WsPush, |
| 25 | 24 | WsResponse, |
| 26 | 25 | } from "@t3tools/contracts"; |
| 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 27 | 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 | 42 | import { WebSocketServer, type WebSocket } from "ws"; |
| 30 | 43 | |
| 31 | 44 | import { createLogger } from "./logger"; |
| ... | ... | @@ -44,20 +57,7 @@ import { ServerConfig } from "./config"; |
| 44 | 57 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 45 | 58 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 46 | 59 | |
| 47 | const MIME_TYPES: Record<string, string> = { | |
| 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 | }; | |
| 60 | const ATTACHMENTS_ROUTE_PREFIX = "/attachments/"; | |
| 61 | 61 | |
| 62 | 62 | /** |
| 63 | 63 | * ServerShape - Service API for server lifecycle control. |
| ... | ... | @@ -69,7 +69,7 @@ export interface ServerShape { |
| 69 | 69 | readonly start: Effect.Effect< |
| 70 | 70 | http.Server, |
| 71 | 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 | 168 | export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 169 | 169 | http.Server, |
| 170 | 170 | ServerLifecycleError, |
| 171 | Scope.Scope | ServerRuntimeServices | ServerConfig | |
| 171 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path | |
| 172 | 172 | > { |
| 173 | 173 | const serverConfig = yield* ServerConfig; |
| 174 | 174 | const { |
| ... | ... | @@ -187,6 +187,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 187 | 187 | const terminalManager = yield* TerminalManager; |
| 188 | 188 | const keybindingsManager = yield* Keybindings; |
| 189 | 189 | const git = yield* GitCore; |
| 190 | const fileSystem = yield* FileSystem.FileSystem; | |
| 191 | const path = yield* Path.Path; | |
| 190 | 192 | |
| 191 | 193 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( |
| 192 | 194 | Effect.catch((error) => |
| ... | ... | @@ -233,62 +235,122 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 233 | 235 | |
| 234 | 236 | // HTTP server — serves static files or redirects to Vite dev server |
| 235 | 237 | const httpServer = http.createServer((req, res) => { |
| 236 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 237 | ||
| 238 | if (tryHandleProjectFaviconRequest(url, res)) { | |
| 239 | return; | |
| 240 | } | |
| 238 | const respond = ( | |
| 239 | statusCode: number, | |
| 240 | headers: Record<string, string>, | |
| 241 | body?: string | Uint8Array, | |
| 242 | ) => { | |
| 243 | res.writeHead(statusCode, headers); | |
| 244 | res.end(body); | |
| 245 | }; | |
| 241 | 246 | |
| 242 | // In dev mode, redirect to Vite dev server | |
| 243 | if (devUrl) { | |
| 244 | res.writeHead(302, { Location: devUrl.href }); | |
| 245 | res.end(); | |
| 246 | return; | |
| 247 | } | |
| 247 | void Effect.runPromise( | |
| 248 | Effect.gen(function* () { | |
| 249 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 250 | if (tryHandleProjectFaviconRequest(url, res)) { | |
| 251 | return; | |
| 252 | } | |
| 248 | 253 | |
| 249 | // Serve static files from the web app build | |
| 250 | if (!staticDir) { | |
| 251 | res.writeHead(503, { "Content-Type": "text/plain" }); | |
| 252 | res.end("No static directory configured and no dev URL set."); | |
| 253 | return; | |
| 254 | } | |
| 254 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { | |
| 255 | const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); | |
| 256 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); | |
| 257 | const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); | |
| 258 | ||
| 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 | |
| 259 | const ext = path.extname(filePath); | |
| 260 | if (!ext) { | |
| 261 | filePath = path.join(filePath, "index.html"); | |
| 262 | } | |
| 274 | const fileInfo = yield* fileSystem | |
| 275 | .stat(filePath) | |
| 276 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 277 | if (!fileInfo || fileInfo.type !== "File") { | |
| 278 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); | |
| 279 | return; | |
| 280 | } | |
| 263 | 281 | |
| 264 | fs.stat(filePath, (err, stats) => { | |
| 265 | if (err || !stats?.isFile()) { | |
| 266 | // SPA fallback | |
| 267 | const indexPath = path.join(staticDir, "index.html"); | |
| 268 | fs.readFile(indexPath, (readErr, data) => { | |
| 269 | if (readErr) { | |
| 270 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 271 | res.end("Not Found"); | |
| 282 | const data = yield* fileSystem | |
| 283 | .readFile(filePath) | |
| 284 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 285 | if (!data) { | |
| 286 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | |
| 272 | 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); | |
| 281 | const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream"; | |
| 290 | const contentType = Mime.getType(filePath) ?? "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) => { | |
| 284 | if (readErr) { | |
| 285 | res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 286 | res.end("Internal Server Error"); | |
| 302 | // In dev mode, redirect to Vite dev server | |
| 303 | if (devUrl) { | |
| 304 | respond(302, { Location: devUrl.href }); | |
| 287 | 305 | return; |
| 288 | 306 | } |
| 289 | res.writeHead(200, { "Content-Type": contentType }); | |
| 290 | res.end(data); | |
| 291 | }); | |
| 307 | ||
| 308 | // Serve static files from the web app build | |
| 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 | 754 | }); |
| 693 | 755 | |
| 694 | 756 | export const ServerLive = Layer.succeed(Server, { |
| 695 | start: createServer(), | |
| 757 | start: createServer().pipe(Effect.provide(NodeServices.layer)), | |
| 696 | 758 | stopSignal: Effect.never, |
| 697 | 759 | } satisfies ServerShape); |
apps/web/src/store.ts+33-1| ... | ... | @@ -192,6 +192,38 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { |
| 192 | 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 | 227 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 196 | 228 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 197 | 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 | 488 | name: attachment.name, |
| 457 | 489 | mimeType: attachment.mimeType, |
| 458 | 490 | sizeBytes: attachment.sizeBytes, |
| 459 | previewUrl: attachment.dataUrl, | |
| 491 | previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), | |
| 460 | 492 | })); |
| 461 | 493 | const normalizedMessage: ChatMessage = { |
| 462 | 494 | id: message.id, |