@things-factory/board-ai 10.1.46 → 10.1.47
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/dist-client/server/service/assistant.js +2 -0
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/types.d.ts +6 -0
- package/dist-client/server/service/types.js.map +1 -1
- package/dist-client/server/service/validation/tool-validation.d.ts +1 -1
- package/dist-client/server/service/validation/tool-validation.js +27 -1
- package/dist-client/server/service/validation/tool-validation.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant.js +2 -0
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/binding-tools.d.ts +23 -0
- package/dist-server/service/binding-tools.js +269 -0
- package/dist-server/service/binding-tools.js.map +1 -0
- package/dist-server/service/board-ai-resolver.d.ts +5 -0
- package/dist-server/service/board-ai-resolver.js +55 -24
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +1 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/types.d.ts +6 -0
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/service/validation/tool-validation.d.ts +1 -1
- package/dist-server/service/validation/tool-validation.js +27 -1
- package/dist-server/service/validation/tool-validation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/assistant.ts +2 -0
- package/server/service/binding-tools.ts +293 -0
- package/server/service/board-ai-resolver.ts +33 -6
- package/server/service/index.ts +1 -0
- package/server/service/types.ts +6 -0
- package/server/service/validation/tool-validation.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/board-ai",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.47",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"@operato/i18n": "^10.0.0",
|
|
33
33
|
"@operato/scene-ops": "^10.14.1",
|
|
34
34
|
"@operato/styles": "^10.0.0",
|
|
35
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
35
|
+
"@things-factory/ai-assistant": "^10.1.47",
|
|
36
36
|
"@things-factory/ai-client-base": "^10.1.39",
|
|
37
|
-
"@things-factory/auth-base": "^10.1.
|
|
38
|
-
"@things-factory/board-import": "^10.1.
|
|
37
|
+
"@things-factory/auth-base": "^10.1.47",
|
|
38
|
+
"@things-factory/board-import": "^10.1.47",
|
|
39
39
|
"@things-factory/env": "^10.1.20",
|
|
40
|
-
"@things-factory/shell": "^10.1.
|
|
40
|
+
"@things-factory/shell": "^10.1.47",
|
|
41
41
|
"dompurify": "^3.0.0",
|
|
42
42
|
"graphql-tag": "^2.12.6",
|
|
43
43
|
"lit": "^3.1.2",
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"copyfiles": "^2.4.1",
|
|
48
48
|
"rimraf": "^5.0.0"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "185f20d7d9a15715a0ee2757a6a8189ec36cfb9d"
|
|
51
51
|
}
|
|
@@ -179,8 +179,10 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
|
|
|
179
179
|
* 답해, 사용자에게 없는 버튼을 누르라고 안내했다. 규율은 도구를 등록한 쪽이 소유하고 여기서는
|
|
180
180
|
* 모아 붙이기만 한다(도메인 지식이 board-ai 프롬프트로 새지 않는다). */
|
|
181
181
|
const guidance = getCategoryGuidance(options.toolCategories)
|
|
182
|
+
const surface = (options.surfacePrompt ?? '').trim()
|
|
182
183
|
const systemPrompt =
|
|
183
184
|
this.buildSystemPromptForTools(knownTypes, categories, schemas) +
|
|
185
|
+
(surface ? `\n\n==== THIS SURFACE (declared by the app) ====\n${surface}` : '') +
|
|
184
186
|
(guidance.length ? `\n\n==== TOOL USE RULES (from the tools exposed to this conversation) ====\n${guidance.join('\n')}` : '')
|
|
185
187
|
|
|
186
188
|
const selectedRefids = (options.selectedRefids ?? []).filter(
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data binding as first-class board tools — `getBindings`, `bindData`, `unbindData`.
|
|
3
|
+
*
|
|
4
|
+
* ── Why these exist ──────────────────────────────────────────────────────────
|
|
5
|
+
* A binding was reachable only through `modifyComponentByRefid` with a raw `mappings` array. The
|
|
6
|
+
* model had to know the mapping shape, where the mapping lives (on the component that HAS the
|
|
7
|
+
* data, not on the one that shows it), that a target is a selector, and that a `#id` selector
|
|
8
|
+
* needs the target to carry an id. It guessed, and a wrong guess is silent: the patch applies,
|
|
9
|
+
* nothing moves on the board, no error anywhere.
|
|
10
|
+
*
|
|
11
|
+
* These tools take refids and plain words and do the bookkeeping here:
|
|
12
|
+
* - the mapping is appended to the SOURCE's `mappings` (same target + property replaces);
|
|
13
|
+
* - a target given by refid gets an `id` if it has none, and the mapping points at `#id`;
|
|
14
|
+
* - the rule's parameter lands where the engine reads it (`mapping.param`).
|
|
15
|
+
*
|
|
16
|
+
* The engine side is `things-scene/src/components/data/data.ts` (spreading) and `evaluator.ts`
|
|
17
|
+
* (value / map / range / eval). The property panel writes the same shape.
|
|
18
|
+
*
|
|
19
|
+
* Writes return scene-edit `modify` ops, so they go through the same preview → apply → revert →
|
|
20
|
+
* save path as every other board edit. Nothing here touches the database.
|
|
21
|
+
*/
|
|
22
|
+
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
23
|
+
|
|
24
|
+
import { collectAllComponents, findComponentByRefid } from './component-tree'
|
|
25
|
+
|
|
26
|
+
type Rule = 'value' | 'map' | 'range' | 'eval'
|
|
27
|
+
const RULES: Rule[] = ['value', 'map', 'range', 'eval']
|
|
28
|
+
|
|
29
|
+
interface Mapping {
|
|
30
|
+
source?: string
|
|
31
|
+
target?: string
|
|
32
|
+
property?: string
|
|
33
|
+
accessor?: string
|
|
34
|
+
rule?: Rule
|
|
35
|
+
param?: any
|
|
36
|
+
partial?: boolean
|
|
37
|
+
ndnsp?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* The write builder receives the board's top-level components; wrap them so the tree helpers work. */
|
|
41
|
+
const boardOf = (components: ReadonlyArray<any> | undefined) => ({ components: [...(components ?? [])] }) as any
|
|
42
|
+
|
|
43
|
+
function idFor(component: any, refid: number, taken: Set<string>): string {
|
|
44
|
+
const base =
|
|
45
|
+
String(component?.type || 'component')
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
48
|
+
.replace(/^-+|-+$/g, '') || 'component'
|
|
49
|
+
let id = `${base}-${refid}`
|
|
50
|
+
for (let n = 2; taken.has(id); n++) id = `${base}-${refid}-${n}`
|
|
51
|
+
return id
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a mapping means, in the words the model and the person use. */
|
|
55
|
+
function describeMapping(m: Mapping, index: number, idToRefids: Map<string, number[]>) {
|
|
56
|
+
const target = (m.target || '(self)').trim()
|
|
57
|
+
const refids = target.startsWith('#') ? idToRefids.get(target.slice(1)) : undefined
|
|
58
|
+
return {
|
|
59
|
+
index,
|
|
60
|
+
target,
|
|
61
|
+
...(refids ? { targetRefids: refids } : {}),
|
|
62
|
+
property: m.property,
|
|
63
|
+
accessor: m.accessor || undefined,
|
|
64
|
+
rule: m.rule || 'value',
|
|
65
|
+
...(m.param !== undefined ? { param: m.param } : {}),
|
|
66
|
+
...(m.partial ? { partial: true } : {}),
|
|
67
|
+
...(m.ndnsp ? { ndnsp: true } : {})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const getBindings: ToolSpec = {
|
|
72
|
+
kind: 'read',
|
|
73
|
+
name: 'getBindings',
|
|
74
|
+
/* Answers from the board the surface sent; nothing behind it to gate. */
|
|
75
|
+
doors: [],
|
|
76
|
+
description:
|
|
77
|
+
'List the data bindings on the board (or on one component). A binding lives on the component that holds the data ' +
|
|
78
|
+
'(the source) and spreads a value into a property of a target. Returns, per source component: refid, id, type, ' +
|
|
79
|
+
'whether it holds initial data, and its mappings with target selector, resolved target refids, property, accessor, rule and rule parameter. ' +
|
|
80
|
+
'Also lists components that already have an id (usable as targets without changes).',
|
|
81
|
+
schema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
refid: { type: 'number', description: 'Only this component. Omit for every component that has bindings.' }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
builder: (args: any, context: any) => {
|
|
88
|
+
const flat = collectAllComponents(context?.board)
|
|
89
|
+
const idToRefids = new Map<string, number[]>()
|
|
90
|
+
for (const f of flat) {
|
|
91
|
+
const id = (f.component as any)?.id
|
|
92
|
+
if (id && typeof f.refid === 'number') idToRefids.set(id, [...(idToRefids.get(id) ?? []), f.refid])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const only = typeof args?.refid === 'number' ? args.refid : undefined
|
|
96
|
+
const sources = flat
|
|
97
|
+
.filter(f => (only === undefined ? Array.isArray((f.component as any)?.mappings) && (f.component as any).mappings.length > 0 : f.refid === only))
|
|
98
|
+
.map(f => {
|
|
99
|
+
const c: any = f.component
|
|
100
|
+
const mappings: Mapping[] = Array.isArray(c.mappings) ? c.mappings : []
|
|
101
|
+
return {
|
|
102
|
+
refid: f.refid,
|
|
103
|
+
id: c.id || undefined,
|
|
104
|
+
type: c.type,
|
|
105
|
+
hasInitialData: c.data !== undefined && c.data !== null && c.data !== '',
|
|
106
|
+
mappings: mappings.map((m, i) => describeMapping(m, i, idToRefids))
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if (only !== undefined && sources.length === 0) {
|
|
111
|
+
return { error: `No component with refid ${only} on this board.` }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
sources,
|
|
116
|
+
componentsWithId: [...idToRefids.entries()].map(([id, refids]) => ({ id, refids }))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const bindData: ToolSpec = {
|
|
122
|
+
kind: 'write',
|
|
123
|
+
name: 'bindData',
|
|
124
|
+
/* Proposes an edit the person saves through the board's own door — offer it only to those who can. */
|
|
125
|
+
doors: ['Mutation.updateBoard'],
|
|
126
|
+
description:
|
|
127
|
+
'Bind data from a source component to a property of a target component. Use this for every data binding - never write ' +
|
|
128
|
+
'`mappings` through modifyComponentByRefid. The binding is stored on the SOURCE (the component that holds or receives the data, ' +
|
|
129
|
+
'e.g. a data source, or the component itself). Give the target as targetRefid (an id is assigned to it if missing) or as a selector. ' +
|
|
130
|
+
'rule "value" passes the value through; "map" translates exact values ({"ON":"#2e7d32","OFF":"#c62828","default":"#9e9e9e"}); ' +
|
|
131
|
+
'"range" maps numeric ranges written "from~to", open ends allowed ({"~50":"green","50~80":"orange","80~":"red"}); ' +
|
|
132
|
+
'"eval" runs a short expression over `value` - use only when map or range cannot express it. ' +
|
|
133
|
+
'An existing binding with the same target and property on that source is replaced.',
|
|
134
|
+
schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
sourceRefid: { type: 'number', description: 'Component whose data is spread (where the binding is stored).' },
|
|
138
|
+
targetRefid: {
|
|
139
|
+
type: 'number',
|
|
140
|
+
description: 'Component that receives the value. Omit (or equal to sourceRefid) to bind into the source itself.'
|
|
141
|
+
},
|
|
142
|
+
targetSelector: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: 'Instead of targetRefid: a selector for many targets, e.g. ".alarm" (class), "#line-3" (id), "(self)".'
|
|
145
|
+
},
|
|
146
|
+
property: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
description: 'Property path on the target, e.g. "value", "text", "fillStyle", "strokeStyle", "hidden", "data", "rotation".'
|
|
149
|
+
},
|
|
150
|
+
accessor: {
|
|
151
|
+
type: 'string',
|
|
152
|
+
description: 'Path into the source data, e.g. "temperature" or "items[0].qty". Omit to pass the whole data.'
|
|
153
|
+
},
|
|
154
|
+
rule: { type: 'string', enum: RULES, description: 'How the value is turned into the property value. Default "value".' },
|
|
155
|
+
map: { type: 'object', description: 'rule "map": exact value -> result, optional "default".' },
|
|
156
|
+
range: { type: 'object', description: 'rule "range": "from~to" -> result, open ends allowed, optional "default".' },
|
|
157
|
+
eval: { type: 'string', description: 'rule "eval": a short JavaScript expression over `value` that returns the property value.' },
|
|
158
|
+
partial: { type: 'boolean', description: 'Merge an object value into the property instead of replacing it.' },
|
|
159
|
+
skipEmpty: { type: 'boolean', description: 'Do not spread empty values (null, undefined, "") - keeps the last value.' }
|
|
160
|
+
},
|
|
161
|
+
required: ['sourceRefid', 'property']
|
|
162
|
+
},
|
|
163
|
+
builder: (args: any, context: any) => {
|
|
164
|
+
const errors: string[] = []
|
|
165
|
+
const board = boardOf(context?.components)
|
|
166
|
+
const sourceRefid = args?.sourceRefid
|
|
167
|
+
const property = typeof args?.property === 'string' ? args.property.trim() : ''
|
|
168
|
+
const rule: Rule = RULES.includes(args?.rule) ? args.rule : 'value'
|
|
169
|
+
|
|
170
|
+
const source = typeof sourceRefid === 'number' ? findComponentByRefid(board, sourceRefid)?.component : undefined
|
|
171
|
+
if (!source) errors.push(`sourceRefid ${sourceRefid} is not a component on this board.`)
|
|
172
|
+
if (!property) errors.push('property is required (the property path on the target, e.g. "value").')
|
|
173
|
+
if (typeof args?.targetRefid === 'number' && args?.targetSelector) {
|
|
174
|
+
errors.push('Give either targetRefid or targetSelector, not both.')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let param: any = undefined
|
|
178
|
+
if (rule === 'map') {
|
|
179
|
+
if (!args?.map || typeof args.map !== 'object' || Array.isArray(args.map)) errors.push('rule "map" needs a "map" object.')
|
|
180
|
+
else param = args.map
|
|
181
|
+
} else if (rule === 'range') {
|
|
182
|
+
if (!args?.range || typeof args.range !== 'object' || Array.isArray(args.range))
|
|
183
|
+
errors.push('rule "range" needs a "range" object with "from~to" keys.')
|
|
184
|
+
else {
|
|
185
|
+
const bad = Object.keys(args.range).filter(k => k !== 'default' && !k.includes('~'))
|
|
186
|
+
if (bad.length) errors.push(`range keys must be written "from~to" (open ends allowed): ${bad.join(', ')}`)
|
|
187
|
+
param = args.range
|
|
188
|
+
}
|
|
189
|
+
} else if (rule === 'eval') {
|
|
190
|
+
if (typeof args?.eval !== 'string' || !args.eval.trim()) errors.push('rule "eval" needs an "eval" expression.')
|
|
191
|
+
else param = args.eval
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ops: any[] = []
|
|
195
|
+
let target = '(self)'
|
|
196
|
+
|
|
197
|
+
if (typeof args?.targetSelector === 'string' && args.targetSelector.trim()) {
|
|
198
|
+
target = args.targetSelector.trim()
|
|
199
|
+
} else if (typeof args?.targetRefid === 'number' && args.targetRefid !== sourceRefid) {
|
|
200
|
+
const found = findComponentByRefid(board, args.targetRefid)?.component as any
|
|
201
|
+
if (!found) {
|
|
202
|
+
errors.push(`targetRefid ${args.targetRefid} is not a component on this board.`)
|
|
203
|
+
} else if (found.id) {
|
|
204
|
+
target = `#${found.id}`
|
|
205
|
+
} else {
|
|
206
|
+
const taken = new Set(
|
|
207
|
+
collectAllComponents(board)
|
|
208
|
+
.map(f => (f.component as any)?.id)
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
)
|
|
211
|
+
const id = idFor(found, args.targetRefid, taken)
|
|
212
|
+
ops.push({ op: 'modify', refid: args.targetRefid, patch: { id } })
|
|
213
|
+
target = `#${id}`
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (errors.length) return { ops: [], errors }
|
|
218
|
+
|
|
219
|
+
const mapping: Mapping = {
|
|
220
|
+
target,
|
|
221
|
+
property,
|
|
222
|
+
rule,
|
|
223
|
+
...(typeof args?.accessor === 'string' && args.accessor.trim() ? { accessor: args.accessor.trim() } : {}),
|
|
224
|
+
...(param !== undefined ? { param } : {}),
|
|
225
|
+
...(args?.partial ? { partial: true } : {}),
|
|
226
|
+
...(args?.skipEmpty ? { ndnsp: true } : {})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const existing: Mapping[] = Array.isArray((source as any).mappings) ? (source as any).mappings : []
|
|
230
|
+
const same = existing.findIndex(m => (m?.target || '(self)').trim() === target && (m?.property || '') === property)
|
|
231
|
+
const mappings = same >= 0 ? existing.map((m, i) => (i === same ? mapping : m)) : [...existing, mapping]
|
|
232
|
+
|
|
233
|
+
ops.unshift({ op: 'modify', refid: sourceRefid, patch: { mappings } })
|
|
234
|
+
return { ops }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const unbindData: ToolSpec = {
|
|
239
|
+
kind: 'write',
|
|
240
|
+
name: 'unbindData',
|
|
241
|
+
doors: ['Mutation.updateBoard'],
|
|
242
|
+
description:
|
|
243
|
+
'Remove a data binding from a source component - by its index (from getBindings) or by target selector and property. ' +
|
|
244
|
+
'Leaves the target component unchanged.',
|
|
245
|
+
schema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: {
|
|
248
|
+
sourceRefid: { type: 'number', description: 'Component the binding is stored on.' },
|
|
249
|
+
index: { type: 'number', description: 'Mapping index as returned by getBindings.' },
|
|
250
|
+
target: { type: 'string', description: 'Instead of index: the mapping target selector, e.g. "#gauge-12".' },
|
|
251
|
+
property: { type: 'string', description: 'Instead of index: the mapping property, with target.' }
|
|
252
|
+
},
|
|
253
|
+
required: ['sourceRefid']
|
|
254
|
+
},
|
|
255
|
+
builder: (args: any, context: any) => {
|
|
256
|
+
const board = boardOf(context?.components)
|
|
257
|
+
const source = typeof args?.sourceRefid === 'number' ? (findComponentByRefid(board, args.sourceRefid)?.component as any) : undefined
|
|
258
|
+
if (!source) return { ops: [], errors: [`sourceRefid ${args?.sourceRefid} is not a component on this board.`] }
|
|
259
|
+
|
|
260
|
+
const existing: Mapping[] = Array.isArray(source.mappings) ? source.mappings : []
|
|
261
|
+
let at = -1
|
|
262
|
+
if (typeof args?.index === 'number') at = args.index
|
|
263
|
+
else if (typeof args?.target === 'string' || typeof args?.property === 'string')
|
|
264
|
+
at = existing.findIndex(
|
|
265
|
+
m =>
|
|
266
|
+
(args.target === undefined || (m?.target || '(self)').trim() === String(args.target).trim()) &&
|
|
267
|
+
(args.property === undefined || m?.property === args.property)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if (at < 0 || at >= existing.length) {
|
|
271
|
+
return { ops: [], errors: [`No such binding on refid ${args.sourceRefid} (it has ${existing.length}). Call getBindings first.`] }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ops: [{ op: 'modify', refid: args.sourceRefid, patch: { mappings: existing.filter((_, i) => i !== at) } }] }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const boardBindingToolsCategory: ToolCategory = {
|
|
279
|
+
name: 'board-binding',
|
|
280
|
+
description: 'Data binding on the open board: read, add, replace and remove mappings.',
|
|
281
|
+
guidance: [
|
|
282
|
+
'Data binding rules:',
|
|
283
|
+
'- For any request to connect, bind, link, drive or "show" data on components, use bindData. Never write `mappings` with modifyComponentByRefid.',
|
|
284
|
+
'- Call getBindings before changing or explaining bindings, so you speak from what is on the board.',
|
|
285
|
+
'- A binding is stored on the SOURCE - the component whose data is spread (a data source, or the component itself). Pick the target by refid.',
|
|
286
|
+
'- Prefer rule "map" for states and "range" for thresholds; use "eval" only when neither can express it.',
|
|
287
|
+
'- After a bindData or unbindData call, say which source feeds which target property and with what rule. The person reviews and saves; do not say it is saved.'
|
|
288
|
+
].join('\n'),
|
|
289
|
+
specs: [getBindings, bindData, unbindData]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Registered on load, like board-import's category.
|
|
293
|
+
registerToolCategory(boardBindingToolsCategory)
|
|
@@ -18,6 +18,7 @@ import { DefaultBoardAIAssistant } from './assistant.js'
|
|
|
18
18
|
import { effectiveBoardTools } from './board-tool-rights.js'
|
|
19
19
|
import type { LLMMessage } from './types.js'
|
|
20
20
|
import { ChatSession } from '@things-factory/ai-assistant'
|
|
21
|
+
import { resolveModeSettings, requestTranslator, type ModeSettings } from '@things-factory/ai-assistant'
|
|
21
22
|
import { ChatMessage } from '@things-factory/ai-assistant'
|
|
22
23
|
import { buildLlmHistory } from '@things-factory/ai-assistant'
|
|
23
24
|
import { splitByCap, summaryInstruction } from '@things-factory/ai-assistant'
|
|
@@ -75,6 +76,15 @@ class BoardAIMentionPickInput {
|
|
|
75
76
|
refid: number
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
@InputType({ description: 'Which registered conversation this board chat is, and what it is about.' })
|
|
80
|
+
export class BoardAIModeInput {
|
|
81
|
+
@Field({ description: 'A mode kind the app server registered, e.g. "board.modeller".' })
|
|
82
|
+
kind: string
|
|
83
|
+
|
|
84
|
+
@Field({ nullable: true, description: 'What the conversation is about, e.g. the board id.' })
|
|
85
|
+
target?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
@InputType()
|
|
79
89
|
class BoardAIChatInput {
|
|
80
90
|
@Field({
|
|
@@ -112,6 +122,13 @@ class BoardAIChatInput {
|
|
|
112
122
|
})
|
|
113
123
|
truncateAfterMessageId?: string
|
|
114
124
|
|
|
125
|
+
@Field(() => BoardAIModeInput, {
|
|
126
|
+
nullable: true,
|
|
127
|
+
description:
|
|
128
|
+
'The registered conversation this is (ADR-0061 decision 7). When given, the server takes the persona, tool categories, model and limits from the mode the app registered, and ignores toolCategories / model / temperature / maxTokens / requireGroundingTools sent by the caller. Omit for the older behaviour.'
|
|
129
|
+
})
|
|
130
|
+
mode?: BoardAIModeInput
|
|
131
|
+
|
|
115
132
|
@Field(() => [String], {
|
|
116
133
|
nullable: true,
|
|
117
134
|
description:
|
|
@@ -287,6 +304,15 @@ export class BoardAIChatResolver {
|
|
|
287
304
|
)
|
|
288
305
|
}
|
|
289
306
|
|
|
307
|
+
/*
|
|
308
|
+
* A declared conversation decides its own authority (ADR-0061 decision 7): with a mode, the
|
|
309
|
+
* categories, model and limits come from what the app server registered, not from the request.
|
|
310
|
+
* An unknown kind is refused here, before anything is saved, in the person's language.
|
|
311
|
+
*/
|
|
312
|
+
const modeSettings: ModeSettings | undefined = input.mode?.kind
|
|
313
|
+
? resolveModeSettings({ kind: input.mode.kind, target: input.mode.target }, requestTranslator(context))
|
|
314
|
+
: undefined
|
|
315
|
+
|
|
290
316
|
const { domain, user } = context.state
|
|
291
317
|
const sessionRepo = getRepository(ChatSession)
|
|
292
318
|
const messageRepo = getRepository(ChatMessage)
|
|
@@ -416,18 +442,19 @@ export class BoardAIChatResolver {
|
|
|
416
442
|
const r = await assistant.chat(llmMessages, input.currentBoard ?? undefined, {
|
|
417
443
|
scopes: input.scopes,
|
|
418
444
|
hostContext: input.hostContext ?? undefined,
|
|
419
|
-
|
|
420
|
-
|
|
445
|
+
surfacePrompt: modeSettings?.systemPrompt,
|
|
446
|
+
/* 대화면별 도구 노출 — 모드가 있으면 서버 선언(빈 목록 = 외부 도구 없음), 없으면 호출자 값(미지정 = 전부). */
|
|
447
|
+
toolCategories: modeSettings ? modeSettings.toolCategories ?? [] : input.toolCategories ?? undefined,
|
|
421
448
|
boardTools: await effectiveBoardTools(input.boardTools, context),
|
|
422
|
-
requireGroundingTools: input.requireGroundingTools ?? undefined,
|
|
449
|
+
requireGroundingTools: modeSettings ? modeSettings.requireToolOnFirstTurn : input.requireGroundingTools ?? undefined,
|
|
423
450
|
knownTypes: input.knownTypes,
|
|
424
451
|
categories: input.categories as ComponentCategory[] | undefined,
|
|
425
452
|
selectedRefids: input.selectedRefids,
|
|
426
453
|
mentionPicks: input.mentions,
|
|
427
454
|
componentSchemas: Array.isArray(input.componentSchemas) ? input.componentSchemas : undefined,
|
|
428
|
-
model: input.model,
|
|
429
|
-
temperature: input.temperature ?? undefined,
|
|
430
|
-
maxTokens: input.maxTokens ?? undefined,
|
|
455
|
+
model: modeSettings ? modeSettings.model : input.model,
|
|
456
|
+
temperature: modeSettings ? modeSettings.temperature : input.temperature ?? undefined,
|
|
457
|
+
maxTokens: modeSettings ? modeSettings.maxTokens : input.maxTokens ?? undefined,
|
|
431
458
|
// 외부 도메인 (board-import 등) 의 read/external tool builder 가 server-side
|
|
432
459
|
// 의존성 (도메인 / 유저 / 트랜잭션) 을 사용할 수 있도록 ResolverContext.state 전달.
|
|
433
460
|
// board-ai 코어 자체는 이 값을 알지 않고 dispatch 단계에서 closure 로만 흘림.
|
package/server/service/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * from './mention-resolver.js'
|
|
|
21
21
|
export * from './catalog-resolver.js'
|
|
22
22
|
export * from './board-ai-resolver.js'
|
|
23
23
|
export * from './patch-entry/index.js'
|
|
24
|
+
export * from './binding-tools.js'
|
|
24
25
|
|
|
25
26
|
/* 세션 계열 엔티티는 `ai-assistant` 가 낸다 — 프레임워크가 의존을 타고 재귀로 모은다. */
|
|
26
27
|
export const entities = [...PatchEntryEntities]
|
package/server/service/types.ts
CHANGED
|
@@ -19,6 +19,12 @@ import type { LLMMessage } from '@things-factory/ai-assistant'
|
|
|
19
19
|
export type { LLMMessage }
|
|
20
20
|
|
|
21
21
|
export interface ChatOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Who the assistant is on this surface — a registered mode's `systemPrompt` (ADR-0061 decision 7).
|
|
24
|
+
* Added after board-ai's own authoring instructions, which stay: they describe the board document
|
|
25
|
+
* and its tools, and the mode describes the product and the person's work.
|
|
26
|
+
*/
|
|
27
|
+
surfacePrompt?: string
|
|
22
28
|
/**
|
|
23
29
|
* 이 대화에 노출할 **외부 도구 카테고리** 화이트리스트(registerToolCategory 이름).
|
|
24
30
|
* 미지정 = 등록된 전부(현행 동작). 성격이 다른 대화면(보드 저작 도크 · 운영 도크)이
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 이게 silent 데이터 손상을 막는 마지막 능동 방어선. 검증 통과한 args 만 누적
|
|
11
11
|
* → patch 로 변환되므로 깨진 데이터가 영속까지 못 감.
|
|
12
12
|
*/
|
|
13
|
-
import type
|
|
13
|
+
import { findToolSpec, type AIToolCall } from '@things-factory/ai-client-base'
|
|
14
14
|
import {
|
|
15
15
|
validateComponentPatch,
|
|
16
16
|
validateComponent,
|
|
@@ -181,8 +181,21 @@ export function validateWriteToolCall(
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
// 그 외 — 기존 dispatcher 검증에 위임
|
|
184
|
-
default:
|
|
184
|
+
default: {
|
|
185
|
+
/*
|
|
186
|
+
* A write tool registered by another category (e.g. board-binding) checks its own arguments
|
|
187
|
+
* and says what is wrong in `errors`. Its ops were dropped with the errors unread, so the model
|
|
188
|
+
* heard nothing and told the person the edit was made. Return them as a validation failure.
|
|
189
|
+
*/
|
|
190
|
+
const spec = findToolSpec(tc.name)
|
|
191
|
+
if (spec && spec.kind === 'write') {
|
|
192
|
+
const r: any = (spec.builder as any)(args, { components })
|
|
193
|
+
if (r && !Array.isArray(r) && Array.isArray(r.errors) && r.errors.length > 0) {
|
|
194
|
+
return { valid: false, errors: r.errors }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
185
197
|
return { valid: true }
|
|
198
|
+
}
|
|
186
199
|
}
|
|
187
200
|
}
|
|
188
201
|
|