authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 15:47:29-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 21:30:46-08:00
log992db1658633ff268be5d1167634966ae63ed859
tree19de896b15b24912a0d4a8c0cb5385dd0007e479
parent2aad749e2474f79a532764c9f6f7ce4abc3a68f0

Persist and serve projected message image attachments

- 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 serving

10 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";
33import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";33import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
34import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";34import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
35import { ServerConfig, type ServerConfigShape } from "../../config.ts";
3536
36const projectionLayer = it.layer(37const 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 );
160161
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 @@
1import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts";1import {
2import { Effect, Layer, Option, Stream } from "effect";2 ApprovalRequestId,
3 type ChatAttachment,
4 type OrchestrationEvent,
5} from "@t3tools/contracts";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";8import * as SqlClient from "effect/unstable/sql/SqlClient";
49
5import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";10import { 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/
27import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";32import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";
28import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";33import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
29import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";34import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";
35import { ServerConfig } from "../../config.ts";
30import {36import {
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}
5359
60const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
61const ATTACHMENTS_STATE_SUBDIRECTORY = "attachments";
62const 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
74function 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
89function 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
103const 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
54function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {161function 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;
177284
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 === 0477 : existingMessage && event.payload.text.length === 0
368 ? existingMessage.text478 ? 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 @@
1import {1import {
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);
45const ProjectionThreadDbRowSchema = ProjectionThread;46const ProjectionThreadDbRowSchema = ProjectionThread;
46const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(47const 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});
51const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(52const 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema, Struct } from "effect";3import { Effect, Layer, Schema } from "effect";
4import { ChatAttachment } from "@t3tools/contracts";
45
5import { toPersistenceSqlError } from "../Errors.ts";6import { toPersistenceSqlError } from "../Errors.ts";
6import {7import {
...@@ -11,11 +12,11 @@ import {...@@ -11,11 +12,11 @@ import {
11 ProjectionThreadMessage,12 ProjectionThreadMessage,
12} from "../Services/ProjectionThreadMessages.ts";13} from "../Services/ProjectionThreadMessages.ts";
1314
14const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(15const 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});
1920
20const makeProjectionThreadMessageRepository = Effect.gen(function* () {21const 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_at37 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_at59 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";
18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
19import Migration0005 from "./Migrations/005_Projections.ts";19import Migration0005 from "./Migrations/005_Projections.ts";
20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
2122
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});
4042
41/**43/**
apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts created+11
...@@ -0,0 +1,11 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 ProjectionThreadMessageRepository7 * @module ProjectionThreadMessageRepository
8 */8 */
9import {9import {
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 });
440440
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 Server7 * @module Server
8 */8 */
9import fs from "node:fs";
10import http from "node:http";9import http from "node:http";
11import path from "node:path";
12import type { Duplex } from "node:stream";10import type { Duplex } from "node:stream";
1311
12import Mime from "@effect/platform-node/Mime";
14import {13import {
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";
26import * as NodeServices from "@effect/platform-node/NodeServices";
27import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";27import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
28import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect";28import {
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";
29import { WebSocketServer, type WebSocket } from "ws";42import { WebSocketServer, type WebSocket } from "ws";
3043
31import { createLogger } from "./logger";44import { createLogger } from "./logger";
...@@ -44,20 +57,7 @@ import { ServerConfig } from "./config";...@@ -44,20 +57,7 @@ import { ServerConfig } from "./config";
44import { GitCore } from "./git/Services/GitCore.ts";57import { GitCore } from "./git/Services/GitCore.ts";
45import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";58import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
4659
47const MIME_TYPES: Record<string, string> = {60const 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};
6161
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 | ServerConfig72 Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
73 >;73 >;
7474
75 /**75 /**
...@@ -168,7 +168,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro...@@ -168,7 +168,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
168export const createServer = Effect.fn(function* (): Effect.fn.Return<168export const createServer = Effect.fn(function* (): Effect.fn.Return<
169 http.Server,169 http.Server,
170 ServerLifecycleError,170 ServerLifecycleError,
171 Scope.Scope | ServerRuntimeServices | ServerConfig171 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;
190192
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<
233235
234 // HTTP server — serves static files or redirects to Vite dev server236 // 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 = (
237239 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 };
241246
242 // In dev mode, redirect to Vite dev server247 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 }
248253
249 // Serve static files from the web app build254 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 }
255267
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 }
257273
258 // SPA fallback: if no file extension and not found, serve index.html274 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 }
263281
264 fs.stat(filePath, (err, stats) => {282 const data = yield* fileSystem
265 if (err || !stats?.isFile()) {283 .readFile(filePath)
266 // SPA fallback284 .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 }
279289
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 }
282301
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 });
294356
...@@ -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});
693755
694export const ServerLive = Layer.succeed(Server, {756export 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}
194194
195function 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
220function toAttachmentPreviewUrl(rawUrl: string): string {
221 if (rawUrl.startsWith("/")) {
222 return `${resolveWsHttpOrigin()}${rawUrl}`;
223 }
224 return rawUrl;
225}
226
195function normalizeTerminalIds(terminalIds: string[]): string[] {227function 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,