Skip to content

Commit dcd52e8

Browse files
authored
Add SME chat storage and workspace UI (#363)
- Add persistence for SME conversations, messages, and knowledge docs - Wire server chat service and websocket events into the app - Add the web SME chat route, sidebar entry, and supporting panels
1 parent 4110d80 commit dcd52e8

27 files changed

Lines changed: 2284 additions & 7 deletions
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as SqlClient from "effect/unstable/sql/SqlClient";
2+
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3+
import { Effect, Layer, Option, Schema } from "effect";
4+
5+
import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
6+
7+
import {
8+
DeleteSmeConversationInput,
9+
GetSmeConversationInput,
10+
ListSmeConversationsByProjectInput,
11+
SmeConversationRepository,
12+
SmeConversationRow,
13+
type SmeConversationRepositoryShape,
14+
} from "../Services/SmeConversations.ts";
15+
16+
function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
17+
return (cause: unknown) =>
18+
Schema.isSchemaError(cause)
19+
? toPersistenceDecodeError(decodeOperation)(cause)
20+
: toPersistenceSqlError(sqlOperation)(cause);
21+
}
22+
23+
const makeSmeConversationRepository = Effect.gen(function* () {
24+
const sql = yield* SqlClient.SqlClient;
25+
26+
const upsertRow = SqlSchema.void({
27+
Request: SmeConversationRow,
28+
execute: (row) =>
29+
sql`
30+
INSERT INTO sme_conversations (
31+
conversation_id, project_id, title, model,
32+
created_at, updated_at, deleted_at
33+
)
34+
VALUES (
35+
${row.conversationId}, ${row.projectId}, ${row.title}, ${row.model},
36+
${row.createdAt}, ${row.updatedAt}, ${row.deletedAt}
37+
)
38+
ON CONFLICT (conversation_id)
39+
DO UPDATE SET
40+
title = excluded.title,
41+
model = excluded.model,
42+
updated_at = excluded.updated_at,
43+
deleted_at = excluded.deleted_at
44+
`,
45+
});
46+
47+
const getRow = SqlSchema.findOneOption({
48+
Request: GetSmeConversationInput,
49+
Result: SmeConversationRow,
50+
execute: ({ conversationId }) =>
51+
sql`
52+
SELECT
53+
conversation_id AS "conversationId",
54+
project_id AS "projectId",
55+
title,
56+
model,
57+
created_at AS "createdAt",
58+
updated_at AS "updatedAt",
59+
deleted_at AS "deletedAt"
60+
FROM sme_conversations
61+
WHERE conversation_id = ${conversationId}
62+
`,
63+
});
64+
65+
const listRows = SqlSchema.findAll({
66+
Request: ListSmeConversationsByProjectInput,
67+
Result: SmeConversationRow,
68+
execute: ({ projectId }) =>
69+
sql`
70+
SELECT
71+
conversation_id AS "conversationId",
72+
project_id AS "projectId",
73+
title,
74+
model,
75+
created_at AS "createdAt",
76+
updated_at AS "updatedAt",
77+
deleted_at AS "deletedAt"
78+
FROM sme_conversations
79+
WHERE project_id = ${projectId} AND deleted_at IS NULL
80+
ORDER BY updated_at DESC
81+
`,
82+
});
83+
84+
const deleteRow = SqlSchema.void({
85+
Request: DeleteSmeConversationInput,
86+
execute: ({ conversationId }) =>
87+
sql`
88+
UPDATE sme_conversations
89+
SET deleted_at = datetime('now')
90+
WHERE conversation_id = ${conversationId}
91+
`,
92+
});
93+
94+
const upsert: SmeConversationRepositoryShape["upsert"] = (row) =>
95+
upsertRow(row).pipe(
96+
Effect.mapError(
97+
toPersistenceSqlOrDecodeError(
98+
"SmeConversationRepository.upsert:query",
99+
"SmeConversationRepository.upsert:encodeRequest",
100+
),
101+
),
102+
);
103+
104+
const getById: SmeConversationRepositoryShape["getById"] = (input) =>
105+
getRow(input).pipe(
106+
Effect.mapError(
107+
toPersistenceSqlOrDecodeError(
108+
"SmeConversationRepository.getById:query",
109+
"SmeConversationRepository.getById:decodeRow",
110+
),
111+
),
112+
Effect.flatMap((rowOption) =>
113+
Option.match(rowOption, {
114+
onNone: () => Effect.succeed(Option.none()),
115+
onSome: (row) =>
116+
Effect.succeed(Option.some(row as Schema.Schema.Type<typeof SmeConversationRow>)),
117+
}),
118+
),
119+
);
120+
121+
const listByProjectId: SmeConversationRepositoryShape["listByProjectId"] = (input) =>
122+
listRows(input).pipe(
123+
Effect.mapError(
124+
toPersistenceSqlOrDecodeError(
125+
"SmeConversationRepository.listByProjectId:query",
126+
"SmeConversationRepository.listByProjectId:decodeRows",
127+
),
128+
),
129+
Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof SmeConversationRow>>),
130+
);
131+
132+
const deleteById: SmeConversationRepositoryShape["deleteById"] = (input) =>
133+
deleteRow(input).pipe(
134+
Effect.mapError(toPersistenceSqlError("SmeConversationRepository.deleteById:query")),
135+
);
136+
137+
return { upsert, getById, listByProjectId, deleteById } satisfies SmeConversationRepositoryShape;
138+
});
139+
140+
export const SmeConversationRepositoryLive = Layer.effect(
141+
SmeConversationRepository,
142+
makeSmeConversationRepository,
143+
);
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import * as SqlClient from "effect/unstable/sql/SqlClient";
2+
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3+
import { Effect, Layer, Option, Schema } from "effect";
4+
5+
import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
6+
7+
import {
8+
DeleteSmeDocumentInput,
9+
GetSmeDocumentInput,
10+
ListSmeDocumentsByProjectInput,
11+
SmeKnowledgeDocumentRepository,
12+
SmeKnowledgeDocumentRow,
13+
type SmeKnowledgeDocumentRepositoryShape,
14+
} from "../Services/SmeKnowledgeDocuments.ts";
15+
16+
function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
17+
return (cause: unknown) =>
18+
Schema.isSchemaError(cause)
19+
? toPersistenceDecodeError(decodeOperation)(cause)
20+
: toPersistenceSqlError(sqlOperation)(cause);
21+
}
22+
23+
const makeSmeKnowledgeDocumentRepository = Effect.gen(function* () {
24+
const sql = yield* SqlClient.SqlClient;
25+
26+
const upsertRow = SqlSchema.void({
27+
Request: SmeKnowledgeDocumentRow,
28+
execute: (row) =>
29+
sql`
30+
INSERT INTO sme_knowledge_documents (
31+
document_id, project_id, title, file_name, mime_type,
32+
size_bytes, content_text, content_hash, created_at, updated_at, deleted_at
33+
)
34+
VALUES (
35+
${row.documentId}, ${row.projectId}, ${row.title}, ${row.fileName}, ${row.mimeType},
36+
${row.sizeBytes}, ${row.contentText}, ${row.contentHash}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt}
37+
)
38+
ON CONFLICT (document_id)
39+
DO UPDATE SET
40+
title = excluded.title,
41+
file_name = excluded.file_name,
42+
mime_type = excluded.mime_type,
43+
size_bytes = excluded.size_bytes,
44+
content_text = excluded.content_text,
45+
content_hash = excluded.content_hash,
46+
updated_at = excluded.updated_at,
47+
deleted_at = excluded.deleted_at
48+
`,
49+
});
50+
51+
const getRow = SqlSchema.findOneOption({
52+
Request: GetSmeDocumentInput,
53+
Result: SmeKnowledgeDocumentRow,
54+
execute: ({ documentId }) =>
55+
sql`
56+
SELECT
57+
document_id AS "documentId",
58+
project_id AS "projectId",
59+
title,
60+
file_name AS "fileName",
61+
mime_type AS "mimeType",
62+
size_bytes AS "sizeBytes",
63+
content_text AS "contentText",
64+
content_hash AS "contentHash",
65+
created_at AS "createdAt",
66+
updated_at AS "updatedAt",
67+
deleted_at AS "deletedAt"
68+
FROM sme_knowledge_documents
69+
WHERE document_id = ${documentId}
70+
`,
71+
});
72+
73+
const listRows = SqlSchema.findAll({
74+
Request: ListSmeDocumentsByProjectInput,
75+
Result: SmeKnowledgeDocumentRow,
76+
execute: ({ projectId }) =>
77+
sql`
78+
SELECT
79+
document_id AS "documentId",
80+
project_id AS "projectId",
81+
title,
82+
file_name AS "fileName",
83+
mime_type AS "mimeType",
84+
size_bytes AS "sizeBytes",
85+
content_text AS "contentText",
86+
content_hash AS "contentHash",
87+
created_at AS "createdAt",
88+
updated_at AS "updatedAt",
89+
deleted_at AS "deletedAt"
90+
FROM sme_knowledge_documents
91+
WHERE project_id = ${projectId} AND deleted_at IS NULL
92+
ORDER BY created_at ASC
93+
`,
94+
});
95+
96+
const deleteRow = SqlSchema.void({
97+
Request: DeleteSmeDocumentInput,
98+
execute: ({ documentId }) =>
99+
sql`
100+
UPDATE sme_knowledge_documents
101+
SET deleted_at = datetime('now')
102+
WHERE document_id = ${documentId}
103+
`,
104+
});
105+
106+
const upsert: SmeKnowledgeDocumentRepositoryShape["upsert"] = (row) =>
107+
upsertRow(row).pipe(
108+
Effect.mapError(
109+
toPersistenceSqlOrDecodeError(
110+
"SmeKnowledgeDocumentRepository.upsert:query",
111+
"SmeKnowledgeDocumentRepository.upsert:encodeRequest",
112+
),
113+
),
114+
);
115+
116+
const getById: SmeKnowledgeDocumentRepositoryShape["getById"] = (input) =>
117+
getRow(input).pipe(
118+
Effect.mapError(
119+
toPersistenceSqlOrDecodeError(
120+
"SmeKnowledgeDocumentRepository.getById:query",
121+
"SmeKnowledgeDocumentRepository.getById:decodeRow",
122+
),
123+
),
124+
Effect.flatMap((rowOption) =>
125+
Option.match(rowOption, {
126+
onNone: () => Effect.succeed(Option.none()),
127+
onSome: (row) =>
128+
Effect.succeed(Option.some(row as Schema.Schema.Type<typeof SmeKnowledgeDocumentRow>)),
129+
}),
130+
),
131+
);
132+
133+
const listByProjectId: SmeKnowledgeDocumentRepositoryShape["listByProjectId"] = (input) =>
134+
listRows(input).pipe(
135+
Effect.mapError(
136+
toPersistenceSqlOrDecodeError(
137+
"SmeKnowledgeDocumentRepository.listByProjectId:query",
138+
"SmeKnowledgeDocumentRepository.listByProjectId:decodeRows",
139+
),
140+
),
141+
Effect.map(
142+
(rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof SmeKnowledgeDocumentRow>>,
143+
),
144+
);
145+
146+
const deleteById: SmeKnowledgeDocumentRepositoryShape["deleteById"] = (input) =>
147+
deleteRow(input).pipe(
148+
Effect.mapError(toPersistenceSqlError("SmeKnowledgeDocumentRepository.deleteById:query")),
149+
);
150+
151+
return {
152+
upsert,
153+
getById,
154+
listByProjectId,
155+
deleteById,
156+
} satisfies SmeKnowledgeDocumentRepositoryShape;
157+
});
158+
159+
export const SmeKnowledgeDocumentRepositoryLive = Layer.effect(
160+
SmeKnowledgeDocumentRepository,
161+
makeSmeKnowledgeDocumentRepository,
162+
);

0 commit comments

Comments
 (0)