@things-factory/board-ai 10.0.0-beta.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/client/components/board-ai-chat.test.ts +120 -0
  2. package/client/components/board-ai-chat.ts +1502 -0
  3. package/client/components/chat-input-builder.ts +40 -0
  4. package/client/components/markdown.test.ts +220 -0
  5. package/client/components/markdown.ts +184 -0
  6. package/client/index.ts +11 -0
  7. package/client/tsconfig.json +13 -0
  8. package/client/utils/board-edit-patch.ts +200 -0
  9. package/config/config.development.js +43 -0
  10. package/config/config.production.js +15 -0
  11. package/dist-client/components/board-ai-chat.d.ts +127 -0
  12. package/dist-client/components/board-ai-chat.js +1455 -0
  13. package/dist-client/components/board-ai-chat.js.map +1 -0
  14. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  15. package/dist-client/components/board-ai-chat.test.js +112 -0
  16. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  17. package/dist-client/components/chat-input-builder.d.ts +30 -0
  18. package/dist-client/components/chat-input-builder.js +25 -0
  19. package/dist-client/components/chat-input-builder.js.map +1 -0
  20. package/dist-client/components/markdown.d.ts +16 -0
  21. package/dist-client/components/markdown.js +167 -0
  22. package/dist-client/components/markdown.js.map +1 -0
  23. package/dist-client/components/markdown.test.d.ts +1 -0
  24. package/dist-client/components/markdown.test.js +187 -0
  25. package/dist-client/components/markdown.test.js.map +1 -0
  26. package/dist-client/index.d.ts +11 -0
  27. package/dist-client/index.js +12 -0
  28. package/dist-client/index.js.map +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -0
  30. package/dist-client/utils/board-edit-patch.d.ts +73 -0
  31. package/dist-client/utils/board-edit-patch.js +159 -0
  32. package/dist-client/utils/board-edit-patch.js.map +1 -0
  33. package/dist-server/index.d.ts +21 -0
  34. package/dist-server/index.js +25 -0
  35. package/dist-server/index.js.map +1 -0
  36. package/dist-server/service/apply-patch.d.ts +46 -0
  37. package/dist-server/service/apply-patch.js +211 -0
  38. package/dist-server/service/apply-patch.js.map +1 -0
  39. package/dist-server/service/assistant.d.ts +75 -0
  40. package/dist-server/service/assistant.js +1298 -0
  41. package/dist-server/service/assistant.js.map +1 -0
  42. package/dist-server/service/board-ai-resolver.d.ts +40 -0
  43. package/dist-server/service/board-ai-resolver.js +260 -0
  44. package/dist-server/service/board-ai-resolver.js.map +1 -0
  45. package/dist-server/service/chat-message/chat-message.d.ts +24 -0
  46. package/dist-server/service/chat-message/chat-message.js +108 -0
  47. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  48. package/dist-server/service/chat-message/index.d.ts +3 -0
  49. package/dist-server/service/chat-message/index.js +7 -0
  50. package/dist-server/service/chat-message/index.js.map +1 -0
  51. package/dist-server/service/chat-session/chat-session.d.ts +22 -0
  52. package/dist-server/service/chat-session/chat-session.js +109 -0
  53. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  54. package/dist-server/service/chat-session/index.d.ts +3 -0
  55. package/dist-server/service/chat-session/index.js +7 -0
  56. package/dist-server/service/chat-session/index.js.map +1 -0
  57. package/dist-server/service/chat-session-resolver.d.ts +13 -0
  58. package/dist-server/service/chat-session-resolver.js +178 -0
  59. package/dist-server/service/chat-session-resolver.js.map +1 -0
  60. package/dist-server/service/index.d.ts +14 -0
  61. package/dist-server/service/index.js +26 -0
  62. package/dist-server/service/index.js.map +1 -0
  63. package/dist-server/service/patch-entry/index.d.ts +3 -0
  64. package/dist-server/service/patch-entry/index.js +7 -0
  65. package/dist-server/service/patch-entry/index.js.map +1 -0
  66. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  67. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  68. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  69. package/dist-server/service/types.d.ts +137 -0
  70. package/dist-server/service/types.js +3 -0
  71. package/dist-server/service/types.js.map +1 -0
  72. package/dist-server/tsconfig.tsbuildinfo +1 -0
  73. package/package.json +47 -0
  74. package/server/index.ts +21 -0
  75. package/server/service/apply-patch.test.ts +640 -0
  76. package/server/service/apply-patch.ts +250 -0
  77. package/server/service/assistant.test.ts +1317 -0
  78. package/server/service/assistant.ts +1431 -0
  79. package/server/service/board-ai-resolver.ts +239 -0
  80. package/server/service/chat-message/chat-message.ts +110 -0
  81. package/server/service/chat-message/index.ts +5 -0
  82. package/server/service/chat-session/chat-session.ts +103 -0
  83. package/server/service/chat-session/index.ts +5 -0
  84. package/server/service/chat-session-resolver.ts +154 -0
  85. package/server/service/index.ts +24 -0
  86. package/server/service/patch-entry/index.ts +5 -0
  87. package/server/service/patch-entry/patch-entry.ts +89 -0
  88. package/server/service/types.ts +138 -0
  89. package/things-factory.config.js +1 -0
  90. package/translations/en.json +39 -0
  91. package/translations/ja.json +39 -0
  92. package/translations/ko.json +40 -0
  93. package/translations/ms.json +39 -0
  94. package/translations/zh.json +39 -0
  95. package/tsconfig.json +9 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * GraphQL resolver — AI 주도 보드 모델링 (chat).
3
+ *
4
+ * Mutation: boardAIChat — 자연어 채팅. sessionId 가 있으면 메시지/패치 영속.
5
+ * sessionId 가 없으면 ad-hoc 모드로 단발 호출 (영속 없음).
6
+ */
7
+ import { Resolver, Mutation, Arg, Ctx, Directive, Field, InputType, Int, ObjectType } from 'type-graphql'
8
+ import { GraphQLJSON } from 'graphql-scalars'
9
+ import '@things-factory/auth-base'
10
+
11
+ import { config } from '@things-factory/env'
12
+ import { getRepository } from '@things-factory/shell'
13
+ import { createAIClient, getDefaultAIClient, type AIClient } from '@things-factory/ai-client-base'
14
+ import type { ComponentCategory } from '@things-factory/board-import'
15
+
16
+ import { DefaultBoardAIAssistant } from './assistant.js'
17
+ import type { LLMMessage } from './types.js'
18
+ import { ChatSession } from './chat-session/chat-session.js'
19
+ import { ChatMessage } from './chat-message/chat-message.js'
20
+ import { PatchEntry } from './patch-entry/patch-entry.js'
21
+
22
+ @InputType()
23
+ class BoardAILLMMessageInput {
24
+ @Field({ description: "Role: 'user' or 'assistant'" })
25
+ role: string
26
+
27
+ @Field({ description: 'Message content' })
28
+ content: string
29
+ }
30
+
31
+ @InputType()
32
+ class BoardAIChatInput {
33
+ @Field({
34
+ nullable: true,
35
+ description: 'ChatSession id. Omit for ad-hoc (no persistence). When given, messages/patches are persisted.'
36
+ })
37
+ sessionId?: string
38
+
39
+ @Field(() => [BoardAILLMMessageInput], {
40
+ description: 'Conversation history (latest user message at the end). For persisted sessions, only the last user message is appended; older are loaded from DB.'
41
+ })
42
+ messages: BoardAILLMMessageInput[]
43
+
44
+ @Field(() => GraphQLJSON, { nullable: true, description: 'Current BoardModel JSON.' })
45
+ currentBoard?: any
46
+
47
+ @Field(() => [String], { nullable: true })
48
+ scopes?: string[]
49
+
50
+ @Field(() => [String], { nullable: true })
51
+ knownTypes?: string[]
52
+
53
+ @Field(() => [String], { nullable: true })
54
+ categories?: string[]
55
+
56
+ @Field(() => GraphQLJSON, {
57
+ nullable: true,
58
+ description:
59
+ 'Component schemas — array of { type, description?, group?, properties? } so the LLM knows valid props per type.'
60
+ })
61
+ componentSchemas?: any
62
+
63
+ @Field(() => [Int], {
64
+ nullable: true,
65
+ description:
66
+ 'Refids of components currently selected in the modeller (universal numeric handle, things-scene auto-assigned). For "selected" / "선택한" intent. Note: distinct from `id` which is a data-binding name.'
67
+ })
68
+ selectedRefids?: number[]
69
+
70
+ @Field({ nullable: true })
71
+ model?: string
72
+
73
+ @Field({ nullable: true })
74
+ temperature?: number
75
+
76
+ @Field({ nullable: true })
77
+ maxTokens?: number
78
+ }
79
+
80
+ @ObjectType()
81
+ class BoardAIChatOutput {
82
+ @Field({ description: 'Conversational reply.' })
83
+ reply: string
84
+
85
+ @Field(() => GraphQLJSON, { nullable: true, description: 'BoardEditPatch.' })
86
+ patch?: any
87
+
88
+ @Field({ nullable: true, description: 'Clarifying question when input is ambiguous.' })
89
+ followUp?: string
90
+
91
+ @Field({ description: 'AI client identifier (provider:model).' })
92
+ clientId: string
93
+
94
+ @Field({ nullable: true, description: 'Echo of session id (when persisted).' })
95
+ sessionId?: string
96
+
97
+ @Field({ nullable: true, description: 'Persisted ChatMessage id of the user input.' })
98
+ userMessageId?: string
99
+
100
+ @Field({ nullable: true, description: 'Persisted ChatMessage id of the AI reply.' })
101
+ assistantMessageId?: string
102
+
103
+ @Field({ nullable: true, description: 'Persisted PatchEntry id (when patch was generated).' })
104
+ patchId?: string
105
+
106
+ @Field(() => GraphQLJSON, {
107
+ nullable: true,
108
+ description:
109
+ 'Tool usages collected during agentic loop — sequence of {name, arguments, result, kind}. UI fold-able box for transparency / debug.'
110
+ })
111
+ toolUsages?: any
112
+ }
113
+
114
+ @Resolver()
115
+ export class BoardAIChatResolver {
116
+ /**
117
+ * NOTE: 일부러 @Directive('@transaction') 사용하지 않는다.
118
+ * LLM 호출은 수 초~수십 초 — 트랜잭션 안에서 호출하면 SQLite write lock 을
119
+ * 그 시간 동안 보유 → 다른 작업 (cache 정리 등) 과 SQLITE_BUSY 충돌.
120
+ * 따라서 DB 쓰기를 LLM 호출 전후의 짧은 단위로 나누고 각각 auto-commit.
121
+ * 데이터 일관성: 채팅 이력은 부분 누락보다 부분 보존이 안전 (대화 컨텍스트).
122
+ */
123
+ @Mutation(() => BoardAIChatOutput, {
124
+ description: 'AI 주도 보드 모델링 — 자연어 채팅으로 보드 생성·구조편집·스타일링. sessionId 로 영속 컨텍스트.'
125
+ })
126
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
127
+ async boardAIChat(
128
+ @Arg('input') input: BoardAIChatInput,
129
+ @Ctx() context: ResolverContext
130
+ ): Promise<BoardAIChatOutput> {
131
+ const base = resolveAIClient()
132
+ if (!base) {
133
+ throw new Error(
134
+ 'No AI client configured. Set config.aiClient = { provider: "anthropic" | "gemini", model, apiKey } or call setDefaultAIClient(...).'
135
+ )
136
+ }
137
+
138
+ const { domain, user } = context.state
139
+ const sessionRepo = getRepository(ChatSession)
140
+ const messageRepo = getRepository(ChatMessage)
141
+ const patchRepo = getRepository(PatchEntry)
142
+
143
+ // ── Phase A: LLM 호출 전 짧은 DB 쓰기 ─────────────────────────
144
+ let session: ChatSession | undefined
145
+ if (input.sessionId) {
146
+ const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } })
147
+ if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
148
+ session = found
149
+ }
150
+
151
+ const lastUserMsg = input.messages[input.messages.length - 1]
152
+ let userMessageId: string | undefined
153
+ if (session && lastUserMsg && lastUserMsg.role === 'user') {
154
+ const saved = await messageRepo.save({
155
+ session: { id: session.id } as any,
156
+ role: 'user',
157
+ content: lastUserMsg.content
158
+ })
159
+ userMessageId = saved.id
160
+ }
161
+
162
+ // ── Phase B: LLM 호출 (DB 잠금 없음, 오래 걸려도 OK) ───────────
163
+ const assistant = new DefaultBoardAIAssistant(base, {
164
+ scopes: input.scopes,
165
+ knownTypes: input.knownTypes,
166
+ categories: input.categories as ComponentCategory[] | undefined
167
+ })
168
+
169
+ const llmMessages: LLMMessage[] = input.messages.map(m => ({
170
+ role: m.role === 'assistant' ? 'assistant' : 'user',
171
+ content: m.content
172
+ }))
173
+
174
+ const r = await assistant.chat(llmMessages, input.currentBoard ?? undefined, {
175
+ scopes: input.scopes,
176
+ knownTypes: input.knownTypes,
177
+ categories: input.categories as ComponentCategory[] | undefined,
178
+ selectedRefids: input.selectedRefids,
179
+ componentSchemas: Array.isArray(input.componentSchemas) ? input.componentSchemas : undefined,
180
+ model: input.model,
181
+ temperature: input.temperature ?? undefined,
182
+ maxTokens: input.maxTokens ?? undefined
183
+ })
184
+
185
+ // ── Phase C: LLM 호출 후 짧은 DB 쓰기 ─────────────────────────
186
+ let assistantMessageId: string | undefined
187
+ let patchId: string | undefined
188
+ if (session) {
189
+ let patchEntry: PatchEntry | undefined
190
+ if (r.patch) {
191
+ patchEntry = await patchRepo.save({
192
+ session: { id: session.id } as any,
193
+ source: 'ai',
194
+ ops: JSON.stringify(r.patch.ops),
195
+ summary: r.patch.summary,
196
+ confidence: r.patch.confidence,
197
+ reverted: false
198
+ })
199
+ patchId = patchEntry.id
200
+ }
201
+
202
+ const aiMsg = await messageRepo.save({
203
+ session: { id: session.id } as any,
204
+ role: 'assistant',
205
+ content: r.reply,
206
+ relatedPatchId: patchEntry?.id,
207
+ toolUsages: r.toolUsages && r.toolUsages.length > 0 ? JSON.stringify(r.toolUsages) : undefined
208
+ })
209
+ assistantMessageId = aiMsg.id
210
+
211
+ await sessionRepo.update(session.id, {
212
+ aiClientId: base.id,
213
+ updater: user
214
+ })
215
+ }
216
+
217
+ return {
218
+ reply: r.reply,
219
+ patch: r.patch ?? null,
220
+ followUp: r.followUp ?? null,
221
+ clientId: base.id,
222
+ sessionId: session?.id,
223
+ userMessageId,
224
+ assistantMessageId,
225
+ patchId,
226
+ toolUsages: r.toolUsages ?? null
227
+ }
228
+ }
229
+ }
230
+
231
+ function resolveAIClient(): AIClient | undefined {
232
+ const existing = getDefaultAIClient()
233
+ if (existing) return existing
234
+ const cfg = config.get('aiClient', null) as any
235
+ if (cfg && cfg.provider) {
236
+ return createAIClient(cfg)
237
+ }
238
+ return undefined
239
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ChatMessage — ChatSession 의 한 메시지.
3
+ *
4
+ * role: 'user' | 'assistant' | 'system'
5
+ * 'system' 은 사용자 직접 편집/import 진행 등 시스템이 자동 생성하는 컨텍스트 메시지.
6
+ *
7
+ * relatedPatchId / relatedImportSessionId 로 메시지가 트리거한 패치·임포트와 연결.
8
+ */
9
+ import {
10
+ Column,
11
+ CreateDateColumn,
12
+ Entity,
13
+ Index,
14
+ ManyToOne,
15
+ PrimaryGeneratedColumn,
16
+ RelationId
17
+ } from 'typeorm'
18
+ import { Field, ID, ObjectType } from 'type-graphql'
19
+ import { GraphQLJSON } from 'graphql-scalars'
20
+
21
+ import { config } from '@things-factory/env'
22
+
23
+ import { ChatSession } from '../chat-session/chat-session.js'
24
+
25
+ const ORMCONFIG = config.get('ormconfig', {})
26
+ const DATABASE_TYPE = ORMCONFIG.type
27
+
28
+ @Entity()
29
+ @Index('ix_chat_message_1', (message: ChatMessage) => [message.session, message.createdAt])
30
+ @ObjectType({ description: 'A single chat message in a ChatSession.' })
31
+ export class ChatMessage {
32
+ @PrimaryGeneratedColumn('uuid')
33
+ @Field(type => ID, { nullable: true })
34
+ readonly id?: string
35
+
36
+ @ManyToOne(type => ChatSession, session => session.messages, { onDelete: 'CASCADE' })
37
+ session?: ChatSession
38
+
39
+ @RelationId((message: ChatMessage) => message.session)
40
+ sessionId?: string
41
+
42
+ @Column({ type: 'varchar', length: 16 })
43
+ @Field({ description: "'user' | 'assistant' | 'system'" })
44
+ role!: string
45
+
46
+ @Column({
47
+ type:
48
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
49
+ ? 'longtext'
50
+ : DATABASE_TYPE == 'oracle'
51
+ ? 'clob'
52
+ : DATABASE_TYPE == 'mssql'
53
+ ? 'nvarchar'
54
+ : 'text',
55
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
56
+ })
57
+ @Field({ description: 'Message content' })
58
+ content!: string
59
+
60
+ @Column({ nullable: true })
61
+ @Field({ nullable: true, description: 'PatchEntry.id this message triggered (if any).' })
62
+ relatedPatchId?: string
63
+
64
+ @Column({ nullable: true })
65
+ @Field({ nullable: true, description: 'ImportSession.id this message triggered (if any).' })
66
+ relatedImportSessionId?: string
67
+
68
+ /**
69
+ * AI 가 응답을 만드는 동안 호출한 도구 trace — JSON-stringified.
70
+ *
71
+ * 컬럼은 string (PatchEntry.ops 와 동일 패턴), GraphQL 노출은 toolUsagesJson getter
72
+ * 로 parse 된 객체. UI 의 fold-able 박스 ("AI 가 이런 도구를 사용했습니다") 가
73
+ * 페이지 reload / loadHistory 후에도 살아있도록 영속.
74
+ *
75
+ * 형태: ToolUsage[] (assistant.ts 의 시간순 trace, summarizeToolResult 로 압축).
76
+ * null 이면 도구 호출 없었던 메시지 (text-only 응답).
77
+ */
78
+ @Column({
79
+ type:
80
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
81
+ ? 'longtext'
82
+ : DATABASE_TYPE == 'oracle'
83
+ ? 'clob'
84
+ : DATABASE_TYPE == 'mssql'
85
+ ? 'nvarchar'
86
+ : 'text',
87
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
88
+ nullable: true
89
+ })
90
+ toolUsages?: string
91
+
92
+ /** GraphQL 노출용 — JSON 배열로 parse. */
93
+ @Field(() => GraphQLJSON, {
94
+ nullable: true,
95
+ description:
96
+ 'Tool usage trace — array of {name, arguments, result, kind}. Only on assistant messages.'
97
+ })
98
+ get toolUsagesJson(): any {
99
+ if (!this.toolUsages) return null
100
+ try {
101
+ return JSON.parse(this.toolUsages)
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ @CreateDateColumn()
108
+ @Field({ nullable: true })
109
+ createdAt?: Date
110
+ }
@@ -0,0 +1,5 @@
1
+ import { ChatMessage } from './chat-message.js'
2
+
3
+ export { ChatMessage }
4
+
5
+ export const entities = [ChatMessage]
@@ -0,0 +1,103 @@
1
+ /**
2
+ * ChatSession — Board 와 1:1 결합되는 AI 협력 세션.
3
+ *
4
+ * 보드 1개 = ChatSession 1개. 보드 수명 = 협력 수명. 보드 다시 열면 컨텍스트 복원.
5
+ * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).
6
+ * 이번 단계에서는 ChatSession 이 boardId 만 unique 로 보유 (단계적 마이그레이션 위함).
7
+ */
8
+ import {
9
+ Column,
10
+ CreateDateColumn,
11
+ DeleteDateColumn,
12
+ Entity,
13
+ Index,
14
+ ManyToOne,
15
+ OneToMany,
16
+ PrimaryGeneratedColumn,
17
+ RelationId,
18
+ UpdateDateColumn
19
+ } from 'typeorm'
20
+ import { Field, ID, ObjectType } from 'type-graphql'
21
+
22
+ import { Domain } from '@things-factory/shell'
23
+ import { User } from '@things-factory/auth-base'
24
+ import { config } from '@things-factory/env'
25
+
26
+ import { ChatMessage } from '../chat-message/chat-message.js'
27
+ import { PatchEntry } from '../patch-entry/patch-entry.js'
28
+
29
+ const ORMCONFIG = config.get('ormconfig', {})
30
+ const DATABASE_TYPE = ORMCONFIG.type
31
+
32
+ @Entity()
33
+ @Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId], {
34
+ unique: true,
35
+ where: '"deleted_at" IS NULL'
36
+ })
37
+ @ObjectType({
38
+ description: 'AI 협력 세션 — Board 와 1:1 결합. 메시지/패치 이력을 영속하여 컨텍스트 복원.'
39
+ })
40
+ export class ChatSession {
41
+ @PrimaryGeneratedColumn('uuid')
42
+ @Field(type => ID, { nullable: true })
43
+ readonly id?: string
44
+
45
+ @ManyToOne(type => Domain)
46
+ @Field(type => Domain, { nullable: true })
47
+ domain?: Domain
48
+
49
+ @RelationId((session: ChatSession) => session.domain)
50
+ domainId?: string
51
+
52
+ /** Board.id 와 연결. Board 가 owning side 가 될 예정. unique 로 1:1 강제. */
53
+ @Column({ nullable: true, unique: true })
54
+ @Field({ nullable: true, description: 'Connected Board id (1:1)' })
55
+ boardId?: string
56
+
57
+ @ManyToOne(type => User)
58
+ @Field(type => User, { nullable: true })
59
+ creator?: User
60
+
61
+ @ManyToOne(type => User)
62
+ @Field(type => User, { nullable: true })
63
+ updater?: User
64
+
65
+ /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */
66
+ @Column({
67
+ nullable: true,
68
+ type:
69
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
70
+ ? 'longtext'
71
+ : DATABASE_TYPE == 'oracle'
72
+ ? 'clob'
73
+ : DATABASE_TYPE == 'mssql'
74
+ ? 'nvarchar'
75
+ : 'text',
76
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
77
+ })
78
+ @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })
79
+ lastSummary?: string
80
+
81
+ /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */
82
+ @Column({ nullable: true })
83
+ @Field({ nullable: true })
84
+ aiClientId?: string
85
+
86
+ @CreateDateColumn()
87
+ @Field({ nullable: true })
88
+ createdAt?: Date
89
+
90
+ @UpdateDateColumn()
91
+ @Field({ nullable: true })
92
+ updatedAt?: Date
93
+
94
+ @DeleteDateColumn()
95
+ deletedAt?: Date
96
+
97
+ // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──
98
+ @OneToMany(type => ChatMessage, message => message.session)
99
+ messages?: ChatMessage[]
100
+
101
+ @OneToMany(type => PatchEntry, patch => patch.session)
102
+ patches?: PatchEntry[]
103
+ }
@@ -0,0 +1,5 @@
1
+ import { ChatSession } from './chat-session.js'
2
+
3
+ export { ChatSession }
4
+
5
+ export const entities = [ChatSession]
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ChatSession lifecycle resolver — start/get/recordDirectPatch/revertPatch + 메시지·패치 페이징 query.
3
+ *
4
+ * 핵심: `recordDirectPatch` — 사용자가 모델러에서 직접 편집 시 클라이언트가 호출.
5
+ * 이게 PatchEntry(source:'user-direct') + system 메시지 자동 추가 → AI 가 모든 변경을 인지.
6
+ */
7
+ import { Resolver, Query, Mutation, Arg, Ctx, Directive, Int } from 'type-graphql'
8
+ import { GraphQLJSON } from 'graphql-scalars'
9
+ import '@things-factory/auth-base'
10
+
11
+ import { getRepository } from '@things-factory/shell'
12
+
13
+ import { ChatSession } from './chat-session/chat-session.js'
14
+ import { ChatMessage } from './chat-message/chat-message.js'
15
+ import { PatchEntry } from './patch-entry/patch-entry.js'
16
+
17
+ @Resolver()
18
+ export class ChatSessionResolver {
19
+ @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
20
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
21
+ async chatSession(
22
+ @Arg('id') id: string,
23
+ @Ctx() context: ResolverContext
24
+ ): Promise<ChatSession | null> {
25
+ const { domain } = context.state
26
+ return (await getRepository(ChatSession).findOneBy({ id, domain: { id: domain.id } })) ?? null
27
+ }
28
+
29
+ @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by board id.' })
30
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
31
+ async chatSessionByBoard(
32
+ @Arg('boardId') boardId: string,
33
+ @Ctx() context: ResolverContext
34
+ ): Promise<ChatSession | null> {
35
+ const { domain } = context.state
36
+ return (
37
+ (await getRepository(ChatSession).findOneBy({ boardId, domain: { id: domain.id } })) ?? null
38
+ )
39
+ }
40
+
41
+ @Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
42
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
43
+ async chatMessages(
44
+ @Arg('sessionId') sessionId: string,
45
+ @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
46
+ @Arg('offset', () => Int, { nullable: true, defaultValue: 0 }) offset: number,
47
+ @Ctx() context: ResolverContext
48
+ ): Promise<ChatMessage[]> {
49
+ const { domain } = context.state
50
+ const session = await getRepository(ChatSession).findOneBy({
51
+ id: sessionId,
52
+ domain: { id: domain.id }
53
+ })
54
+ if (!session) return []
55
+ return await getRepository(ChatMessage).find({
56
+ where: { session: { id: sessionId } as any },
57
+ order: { createdAt: 'ASC' },
58
+ take: limit,
59
+ skip: offset
60
+ })
61
+ }
62
+
63
+ @Query(() => [PatchEntry], { description: 'List patch entries of a session, newest first.' })
64
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
65
+ async chatPatches(
66
+ @Arg('sessionId') sessionId: string,
67
+ @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
68
+ @Ctx() context: ResolverContext
69
+ ): Promise<PatchEntry[]> {
70
+ const { domain } = context.state
71
+ const session = await getRepository(ChatSession).findOneBy({
72
+ id: sessionId,
73
+ domain: { id: domain.id }
74
+ })
75
+ if (!session) return []
76
+ return await getRepository(PatchEntry).find({
77
+ where: { session: { id: sessionId } as any },
78
+ order: { createdAt: 'DESC' },
79
+ take: limit
80
+ })
81
+ }
82
+
83
+ @Directive('@transaction')
84
+ @Mutation(() => ChatSession, {
85
+ description: 'Start (or get existing) AI chat session for a board. Idempotent.'
86
+ })
87
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
88
+ async startBoardAISession(
89
+ @Arg('boardId') boardId: string,
90
+ @Ctx() context: ResolverContext
91
+ ): Promise<ChatSession> {
92
+ const { domain, user, tx } = context.state
93
+ const repo = getRepository(ChatSession, tx)
94
+
95
+ const existing = await repo.findOneBy({ boardId, domain: { id: domain.id } })
96
+ if (existing) return existing
97
+
98
+ return await repo.save({
99
+ domain,
100
+ boardId,
101
+ creator: user,
102
+ updater: user
103
+ } as any)
104
+ }
105
+
106
+ @Directive('@transaction')
107
+ @Mutation(() => PatchEntry, {
108
+ description: 'Record a patch from user direct edit. Adds a system message so AI sees the change next turn.'
109
+ })
110
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
111
+ async recordDirectPatch(
112
+ @Arg('sessionId') sessionId: string,
113
+ @Arg('ops', () => GraphQLJSON) ops: any[],
114
+ @Arg('summary', { nullable: true }) summary: string | undefined,
115
+ @Ctx() context: ResolverContext
116
+ ): Promise<PatchEntry> {
117
+ const { domain, tx } = context.state
118
+ const session = await getRepository(ChatSession, tx).findOneBy({
119
+ id: sessionId,
120
+ domain: { id: domain.id }
121
+ })
122
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
123
+
124
+ const entry = await getRepository(PatchEntry, tx).save({
125
+ session: { id: session.id } as any,
126
+ source: 'user-direct',
127
+ ops: JSON.stringify(ops || []),
128
+ summary,
129
+ reverted: false
130
+ } as any)
131
+
132
+ // AI 컨텍스트 흐름 — system 메시지 자동 추가
133
+ await getRepository(ChatMessage, tx).save({
134
+ session: { id: session.id } as any,
135
+ role: 'system',
136
+ content: `User directly edited the board: ${summary ?? `${(ops || []).length} op(s)`}`,
137
+ relatedPatchId: entry.id
138
+ } as any)
139
+
140
+ return entry
141
+ }
142
+
143
+ @Directive('@transaction')
144
+ @Mutation(() => Boolean, { description: 'Mark a patch as reverted (does not undo, only flags).' })
145
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
146
+ async revertPatch(
147
+ @Arg('patchId') patchId: string,
148
+ @Ctx() context: ResolverContext
149
+ ): Promise<boolean> {
150
+ const { tx } = context.state
151
+ await getRepository(PatchEntry, tx).update(patchId, { reverted: true })
152
+ return true
153
+ }
154
+ }
@@ -0,0 +1,24 @@
1
+ import { BoardAIChatResolver } from './board-ai-resolver.js'
2
+ import { ChatSessionResolver } from './chat-session-resolver.js'
3
+ import { entities as ChatSessionEntities } from './chat-session/index.js'
4
+ import { entities as ChatMessageEntities } from './chat-message/index.js'
5
+ import { entities as PatchEntryEntities } from './patch-entry/index.js'
6
+
7
+ export * from './types.js'
8
+ export * from './assistant.js'
9
+ export * from './apply-patch.js'
10
+ export * from './board-ai-resolver.js'
11
+ export * from './chat-session-resolver.js'
12
+ export * from './chat-session/index.js'
13
+ export * from './chat-message/index.js'
14
+ export * from './patch-entry/index.js'
15
+
16
+ export const entities = [
17
+ ...ChatSessionEntities,
18
+ ...ChatMessageEntities,
19
+ ...PatchEntryEntities
20
+ ]
21
+
22
+ export const schema = {
23
+ resolverClasses: [BoardAIChatResolver, ChatSessionResolver]
24
+ }
@@ -0,0 +1,5 @@
1
+ import { PatchEntry } from './patch-entry.js'
2
+
3
+ export { PatchEntry }
4
+
5
+ export const entities = [PatchEntry]