@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.
- package/client/components/board-ai-chat.test.ts +120 -0
- package/client/components/board-ai-chat.ts +1502 -0
- package/client/components/chat-input-builder.ts +40 -0
- package/client/components/markdown.test.ts +220 -0
- package/client/components/markdown.ts +184 -0
- package/client/index.ts +11 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/board-edit-patch.ts +200 -0
- package/config/config.development.js +43 -0
- package/config/config.production.js +15 -0
- package/dist-client/components/board-ai-chat.d.ts +127 -0
- package/dist-client/components/board-ai-chat.js +1455 -0
- package/dist-client/components/board-ai-chat.js.map +1 -0
- package/dist-client/components/board-ai-chat.test.d.ts +1 -0
- package/dist-client/components/board-ai-chat.test.js +112 -0
- package/dist-client/components/board-ai-chat.test.js.map +1 -0
- package/dist-client/components/chat-input-builder.d.ts +30 -0
- package/dist-client/components/chat-input-builder.js +25 -0
- package/dist-client/components/chat-input-builder.js.map +1 -0
- package/dist-client/components/markdown.d.ts +16 -0
- package/dist-client/components/markdown.js +167 -0
- package/dist-client/components/markdown.js.map +1 -0
- package/dist-client/components/markdown.test.d.ts +1 -0
- package/dist-client/components/markdown.test.js +187 -0
- package/dist-client/components/markdown.test.js.map +1 -0
- package/dist-client/index.d.ts +11 -0
- package/dist-client/index.js +12 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/board-edit-patch.d.ts +73 -0
- package/dist-client/utils/board-edit-patch.js +159 -0
- package/dist-client/utils/board-edit-patch.js.map +1 -0
- package/dist-server/index.d.ts +21 -0
- package/dist-server/index.js +25 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/apply-patch.d.ts +46 -0
- package/dist-server/service/apply-patch.js +211 -0
- package/dist-server/service/apply-patch.js.map +1 -0
- package/dist-server/service/assistant.d.ts +75 -0
- package/dist-server/service/assistant.js +1298 -0
- package/dist-server/service/assistant.js.map +1 -0
- package/dist-server/service/board-ai-resolver.d.ts +40 -0
- package/dist-server/service/board-ai-resolver.js +260 -0
- package/dist-server/service/board-ai-resolver.js.map +1 -0
- package/dist-server/service/chat-message/chat-message.d.ts +24 -0
- package/dist-server/service/chat-message/chat-message.js +108 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -0
- package/dist-server/service/chat-message/index.d.ts +3 -0
- package/dist-server/service/chat-message/index.js +7 -0
- package/dist-server/service/chat-message/index.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +22 -0
- package/dist-server/service/chat-session/chat-session.js +109 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -0
- package/dist-server/service/chat-session/index.d.ts +3 -0
- package/dist-server/service/chat-session/index.js +7 -0
- package/dist-server/service/chat-session/index.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +13 -0
- package/dist-server/service/chat-session-resolver.js +178 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -0
- package/dist-server/service/index.d.ts +14 -0
- package/dist-server/service/index.js +26 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/patch-entry/index.d.ts +3 -0
- package/dist-server/service/patch-entry/index.js +7 -0
- package/dist-server/service/patch-entry/index.js.map +1 -0
- package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
- package/dist-server/service/patch-entry/patch-entry.js +96 -0
- package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
- package/dist-server/service/types.d.ts +137 -0
- package/dist-server/service/types.js +3 -0
- package/dist-server/service/types.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +47 -0
- package/server/index.ts +21 -0
- package/server/service/apply-patch.test.ts +640 -0
- package/server/service/apply-patch.ts +250 -0
- package/server/service/assistant.test.ts +1317 -0
- package/server/service/assistant.ts +1431 -0
- package/server/service/board-ai-resolver.ts +239 -0
- package/server/service/chat-message/chat-message.ts +110 -0
- package/server/service/chat-message/index.ts +5 -0
- package/server/service/chat-session/chat-session.ts +103 -0
- package/server/service/chat-session/index.ts +5 -0
- package/server/service/chat-session-resolver.ts +154 -0
- package/server/service/index.ts +24 -0
- package/server/service/patch-entry/index.ts +5 -0
- package/server/service/patch-entry/patch-entry.ts +89 -0
- package/server/service/types.ts +138 -0
- package/things-factory.config.js +1 -0
- package/translations/en.json +39 -0
- package/translations/ja.json +39 -0
- package/translations/ko.json +40 -0
- package/translations/ms.json +39 -0
- package/translations/zh.json +39 -0
- 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,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,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
|
+
}
|