@things-factory/ai-assistant 10.1.154 → 10.1.156
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/bootstrap.ts +27 -0
- package/client/components/assistant-chat.ts +229 -178
- package/client/components/assistant-dock.ts +53 -48
- package/client/components/assistant-session-manager.ts +82 -100
- package/client/components/assistant-session-toolbar.ts +11 -3
- package/client/components/chat-input-builder.ts +8 -2
- package/client/settings/agent-settings-model.ts +164 -0
- package/client/settings/ai-agent-setting-let.ts +684 -0
- package/client/settings/ai-usage-setting-let.ts +252 -0
- package/client/settings/usage-model.ts +46 -0
- package/client/setup-assistant-dock.ts +2 -3
- package/client/utils/agent-choice.ts +56 -0
- package/client/utils/assistant-navigation.test.ts +14 -8
- package/client/utils/assistant-navigation.ts +8 -3
- package/client/utils/composer-frame.ts +28 -2
- package/client/utils/menu-room.ts +33 -0
- package/dist-client/bootstrap.d.ts +8 -0
- package/dist-client/bootstrap.js +26 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/assistant-chat.d.ts +32 -4
- package/dist-client/components/assistant-chat.js +234 -175
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +6 -8
- package/dist-client/components/assistant-dock.js +52 -60
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +8 -7
- package/dist-client/components/assistant-session-manager.js +84 -124
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
- package/dist-client/components/assistant-session-toolbar.js +10 -3
- package/dist-client/components/assistant-session-toolbar.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +4 -0
- package/dist-client/components/chat-input-builder.js +4 -2
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/settings/agent-settings-model.d.ts +93 -0
- package/dist-client/settings/agent-settings-model.js +95 -0
- package/dist-client/settings/agent-settings-model.js.map +1 -0
- package/dist-client/settings/ai-agent-setting-let.d.ts +51 -0
- package/dist-client/settings/ai-agent-setting-let.js +672 -0
- package/dist-client/settings/ai-agent-setting-let.js.map +1 -0
- package/dist-client/settings/ai-usage-setting-let.d.ts +26 -0
- package/dist-client/settings/ai-usage-setting-let.js +276 -0
- package/dist-client/settings/ai-usage-setting-let.js.map +1 -0
- package/dist-client/settings/usage-model.d.ts +32 -0
- package/dist-client/settings/usage-model.js +25 -0
- package/dist-client/settings/usage-model.js.map +1 -0
- package/dist-client/setup-assistant-dock.d.ts +0 -1
- package/dist-client/setup-assistant-dock.js +0 -1
- package/dist-client/setup-assistant-dock.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/agent-choice.d.ts +24 -0
- package/dist-client/utils/agent-choice.js +38 -0
- package/dist-client/utils/agent-choice.js.map +1 -0
- package/dist-client/utils/assistant-navigation.d.ts +8 -3
- package/dist-client/utils/assistant-navigation.js +7 -2
- package/dist-client/utils/assistant-navigation.js.map +1 -1
- package/dist-client/utils/assistant-navigation.test.js +14 -8
- package/dist-client/utils/assistant-navigation.test.js.map +1 -1
- package/dist-client/utils/composer-frame.d.ts +23 -2
- package/dist-client/utils/composer-frame.js +20 -2
- package/dist-client/utils/composer-frame.js.map +1 -1
- package/dist-client/utils/menu-room.d.ts +13 -0
- package/dist-client/utils/menu-room.js +26 -0
- package/dist-client/utils/menu-room.js.map +1 -0
- package/dist-server/service/ai-agent-resolver.d.ts +82 -0
- package/dist-server/service/ai-agent-resolver.js +417 -0
- package/dist-server/service/ai-agent-resolver.js.map +1 -0
- package/dist-server/service/assistant-capability-resolver.d.ts +1 -1
- package/dist-server/service/assistant-capability-resolver.js +7 -7
- package/dist-server/service/assistant-capability-resolver.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.d.ts +2 -0
- package/dist-server/service/assistant-chat-resolver.js +32 -15
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +0 -1
- package/dist-server/service/assistant-mode/resolve.js +0 -1
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +1 -2
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +2 -0
- package/dist-server/service/chat-session/chat-session.js +5 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -1
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.js +0 -1
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/server/service/ai-agent-resolver.ts +330 -0
- package/server/service/assistant-capability-resolver.ts +7 -7
- package/server/service/assistant-chat-resolver.ts +32 -24
- package/server/service/assistant-mode/resolve.test.ts +0 -2
- package/server/service/assistant-mode/resolve.ts +0 -2
- package/server/service/assistant-mode/types.ts +1 -2
- package/server/service/chat-session/chat-session.ts +5 -0
- package/server/service/index.ts +3 -0
- package/server/service/testing/mode-conformance.ts +0 -1
- package/server/tool-registry-boot-report.test.ts +2 -0
- package/test/ai-agent-settings.test.ts +123 -0
- package/test/assistant-dock.test.ts +3 -2
- package/test/assistant-session-manager.test.ts +10 -4
- package/test/assistant-session-toolbar.test.ts +8 -0
- package/test/composer-frame.test.ts +40 -2
- package/test/menu-room.test.ts +33 -0
- package/test/open-doors.test.ts +3 -1
- package/test/session-discovery.test.ts +7 -6
- package/things-factory.config.js +2 -0
- package/translations/en.json +78 -12
- package/translations/es.json +78 -12
- package/translations/ja.json +78 -12
- package/translations/ko.json +78 -12
- package/translations/ms.json +78 -12
- package/translations/zh.json +78 -12
- package/client/components/chat-triggers.test.ts +0 -72
- package/client/components/chat-triggers.ts +0 -62
- package/dist-client/components/chat-triggers.d.ts +0 -16
- package/dist-client/components/chat-triggers.js +0 -42
- package/dist-client/components/chat-triggers.js.map +0 -1
- package/dist-client/components/chat-triggers.test.d.ts +0 -1
- package/dist-client/components/chat-triggers.test.js +0 -61
- package/dist-client/components/chat-triggers.test.js.map +0 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import '@things-factory/setting-base/dist-client/layout/settinglet.js'
|
|
2
|
+
import '@operato/input/ox-input-select-buttons.js'
|
|
3
|
+
|
|
4
|
+
import gql from 'graphql-tag'
|
|
5
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
6
|
+
import { customElement, state } from 'lit/decorators.js'
|
|
7
|
+
import { client } from '@operato/graphql'
|
|
8
|
+
import { i18next, localize } from '@operato/i18n'
|
|
9
|
+
import { FormFieldStyles, ScrollbarStyles } from '@operato/styles'
|
|
10
|
+
|
|
11
|
+
import { periodRange, totalsOf, type UsageBucketSize, type UsagePeriod, type UsageRow } from './usage-model.js'
|
|
12
|
+
|
|
13
|
+
const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
|
|
14
|
+
i18next.t(`ai-assistant.usage.${key}`, { ...options, defaultValue })
|
|
15
|
+
|
|
16
|
+
const USAGE_QUERY = gql`
|
|
17
|
+
query AiUsageSummary($from: DateTimeISO!, $to: DateTimeISO!, $bucket: String!, $includeDescendants: Boolean) {
|
|
18
|
+
aiUsageSummary(from: $from, to: $to, bucket: $bucket, includeDescendants: $includeDescendants) {
|
|
19
|
+
bucket
|
|
20
|
+
agentId
|
|
21
|
+
agentName
|
|
22
|
+
provider
|
|
23
|
+
model
|
|
24
|
+
domainId
|
|
25
|
+
domainName
|
|
26
|
+
userId
|
|
27
|
+
userName
|
|
28
|
+
calls
|
|
29
|
+
inputTokens
|
|
30
|
+
outputTokens
|
|
31
|
+
failures
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* AI usage of this domain - per hour, day or month × agent × person, in the domain's time zone
|
|
38
|
+
* (design/ai/agent-settings.md §7-3). With "domains below", what they did with this domain's agents is added as one
|
|
39
|
+
* line per domain; their people are not named here.
|
|
40
|
+
*/
|
|
41
|
+
@customElement('ai-usage-setting-let')
|
|
42
|
+
export class AiUsageSettingLet extends localize(i18next)(LitElement) {
|
|
43
|
+
static styles = [
|
|
44
|
+
FormFieldStyles,
|
|
45
|
+
ScrollbarStyles,
|
|
46
|
+
css`
|
|
47
|
+
:host {
|
|
48
|
+
display: block;
|
|
49
|
+
}
|
|
50
|
+
[slot='content'] {
|
|
51
|
+
display: grid;
|
|
52
|
+
gap: 8px;
|
|
53
|
+
text-transform: none;
|
|
54
|
+
}
|
|
55
|
+
.filters {
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-wrap: wrap;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 8px 12px;
|
|
60
|
+
}
|
|
61
|
+
.filters select {
|
|
62
|
+
height: var(--form-element-height-small, 26px);
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
}
|
|
65
|
+
.filters label {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 4px;
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
}
|
|
71
|
+
.table {
|
|
72
|
+
max-height: 360px;
|
|
73
|
+
overflow: auto;
|
|
74
|
+
}
|
|
75
|
+
table {
|
|
76
|
+
width: 100%;
|
|
77
|
+
border-collapse: collapse;
|
|
78
|
+
font-size: 12px;
|
|
79
|
+
}
|
|
80
|
+
th,
|
|
81
|
+
td {
|
|
82
|
+
padding: 4px 8px;
|
|
83
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
text-align: left;
|
|
86
|
+
}
|
|
87
|
+
th {
|
|
88
|
+
position: sticky;
|
|
89
|
+
top: 0;
|
|
90
|
+
background: var(--md-sys-color-surface-container);
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
93
|
+
}
|
|
94
|
+
td.number,
|
|
95
|
+
th.number {
|
|
96
|
+
text-align: right;
|
|
97
|
+
font-variant-numeric: tabular-nums;
|
|
98
|
+
}
|
|
99
|
+
td.failed {
|
|
100
|
+
color: var(--md-sys-color-error);
|
|
101
|
+
}
|
|
102
|
+
tr.total td {
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
border-top: 2px solid var(--md-sys-color-outline-variant);
|
|
105
|
+
}
|
|
106
|
+
.below {
|
|
107
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
108
|
+
}
|
|
109
|
+
.state {
|
|
110
|
+
font-size: 12px;
|
|
111
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
112
|
+
}
|
|
113
|
+
.state.failed {
|
|
114
|
+
color: var(--md-sys-color-error);
|
|
115
|
+
}
|
|
116
|
+
`
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
@state() private period: UsagePeriod = '7d'
|
|
120
|
+
@state() private bucket: UsageBucketSize = 'day'
|
|
121
|
+
@state() private includeDescendants = false
|
|
122
|
+
@state() private agentFilter = ''
|
|
123
|
+
@state() private userFilter = ''
|
|
124
|
+
@state() private rows?: UsageRow[]
|
|
125
|
+
@state() private failed?: string
|
|
126
|
+
|
|
127
|
+
connectedCallback() {
|
|
128
|
+
super.connectedCallback()
|
|
129
|
+
this.load()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private loadToken = 0
|
|
133
|
+
|
|
134
|
+
private async load() {
|
|
135
|
+
const token = ++this.loadToken
|
|
136
|
+
this.failed = undefined
|
|
137
|
+
this.rows = undefined
|
|
138
|
+
const { from, to } = periodRange(this.period, new Date())
|
|
139
|
+
try {
|
|
140
|
+
const response = await client.query({
|
|
141
|
+
query: USAGE_QUERY,
|
|
142
|
+
fetchPolicy: 'network-only',
|
|
143
|
+
variables: { from, to, bucket: this.bucket, includeDescendants: this.includeDescendants }
|
|
144
|
+
})
|
|
145
|
+
if (token !== this.loadToken) return
|
|
146
|
+
/* This client answers a refusal in `errors` (errorPolicy 'all') instead of throwing. */
|
|
147
|
+
if ((response as any).errors?.length) throw new Error((response as any).errors[0].message)
|
|
148
|
+
this.rows = response.data?.aiUsageSummary ?? []
|
|
149
|
+
} catch (error: any) {
|
|
150
|
+
if (token !== this.loadToken) return
|
|
151
|
+
this.failed = error?.graphQLErrors?.[0]?.message ?? error?.message ?? String(error)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
render() {
|
|
156
|
+
return html`
|
|
157
|
+
<setting-let>
|
|
158
|
+
<div slot="title">${t('title', 'AI usage')}</div>
|
|
159
|
+
<div slot="content">${this.renderFilters()} ${this.renderTable()}</div>
|
|
160
|
+
</setting-let>
|
|
161
|
+
`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private renderFilters() {
|
|
165
|
+
const rows = this.rows ?? []
|
|
166
|
+
const agents = [...new Map(rows.filter(one => one.agentId).map(one => [one.agentId!, one.agentName ?? t('deleted-agent', '(deleted agent)')])).entries()]
|
|
167
|
+
const users = [...new Map(rows.filter(one => one.userId).map(one => [one.userId!, one.userName ?? one.userId!])).entries()]
|
|
168
|
+
return html`
|
|
169
|
+
<div class="filters">
|
|
170
|
+
<ox-input-select-buttons
|
|
171
|
+
.value=${this.period}
|
|
172
|
+
.options=${[
|
|
173
|
+
{ display: t('today', 'Today'), value: 'today' },
|
|
174
|
+
{ display: t('7d', '7 days'), value: '7d' },
|
|
175
|
+
{ display: t('30d', '30 days'), value: '30d' }
|
|
176
|
+
]}
|
|
177
|
+
@change=${(e: CustomEvent) => e.detail && ((this.period = e.detail), this.load())}></ox-input-select-buttons>
|
|
178
|
+
<ox-input-select-buttons
|
|
179
|
+
.value=${this.bucket}
|
|
180
|
+
.options=${[
|
|
181
|
+
{ display: t('by-hour', 'Hour'), value: 'hour' },
|
|
182
|
+
{ display: t('by-day', 'Day'), value: 'day' },
|
|
183
|
+
{ display: t('by-month', 'Month'), value: 'month' }
|
|
184
|
+
]}
|
|
185
|
+
@change=${(e: CustomEvent) => e.detail && ((this.bucket = e.detail), this.load())}></ox-input-select-buttons>
|
|
186
|
+
<select aria-label=${t('agent', 'Agent')} @change=${(e: Event) => (this.agentFilter = (e.target as HTMLSelectElement).value)}>
|
|
187
|
+
<option value="">${t('all-agents', 'All agents')}</option>
|
|
188
|
+
${agents.map(([id, name]) => html`<option value=${id} ?selected=${id === this.agentFilter}>${name}</option>`)}
|
|
189
|
+
</select>
|
|
190
|
+
<select aria-label=${t('person', 'Person')} @change=${(e: Event) => (this.userFilter = (e.target as HTMLSelectElement).value)}>
|
|
191
|
+
<option value="">${t('all-people', 'Everyone')}</option>
|
|
192
|
+
${users.map(([id, name]) => html`<option value=${id} ?selected=${id === this.userFilter}>${name}</option>`)}
|
|
193
|
+
</select>
|
|
194
|
+
<label>
|
|
195
|
+
<input type="checkbox" .checked=${this.includeDescendants} @change=${(e: Event) => ((this.includeDescendants = (e.target as HTMLInputElement).checked), this.load())} />
|
|
196
|
+
${t('include-below', 'Domains below')}
|
|
197
|
+
</label>
|
|
198
|
+
</div>
|
|
199
|
+
`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private renderTable() {
|
|
203
|
+
if (this.failed) return html`<div class="state failed">${t('load-failed', 'Could not read the usage.')} ${this.failed}</div>`
|
|
204
|
+
if (!this.rows) return html`<div class="state">${t('loading', 'Reading…')}</div>`
|
|
205
|
+
|
|
206
|
+
const rows = this.rows.filter(one => (!this.agentFilter || one.agentId === this.agentFilter) && (!this.userFilter || one.userId === this.userFilter))
|
|
207
|
+
if (!rows.length) return html`<div class="state">${t('empty', 'No AI was used in this period.')}</div>`
|
|
208
|
+
|
|
209
|
+
const total = totalsOf(rows)
|
|
210
|
+
const number = (value: number) => value.toLocaleString()
|
|
211
|
+
return html`
|
|
212
|
+
<div class="table">
|
|
213
|
+
<table>
|
|
214
|
+
<thead>
|
|
215
|
+
<tr>
|
|
216
|
+
<th>${t('when', 'When')}</th>
|
|
217
|
+
<th>${t('agent', 'Agent')}</th>
|
|
218
|
+
<th>${t('person', 'Person')}</th>
|
|
219
|
+
<th class="number">${t('calls', 'Calls')}</th>
|
|
220
|
+
<th class="number">${t('input-tokens', 'Input tokens')}</th>
|
|
221
|
+
<th class="number">${t('output-tokens', 'Output tokens')}</th>
|
|
222
|
+
<th class="number">${t('failures', 'Failed')}</th>
|
|
223
|
+
</tr>
|
|
224
|
+
</thead>
|
|
225
|
+
<tbody>
|
|
226
|
+
${rows.map(
|
|
227
|
+
row => html`<tr>
|
|
228
|
+
<td>${row.bucket}</td>
|
|
229
|
+
<td title=${`${row.provider} · ${row.model}`}>${row.agentName ?? t('deleted-agent', '(deleted agent)')}</td>
|
|
230
|
+
<td>
|
|
231
|
+
${row.userName ??
|
|
232
|
+
(row.userId ? row.userId : nothing)}${!row.userId && row.domainName ? html`<span class="below">(${row.domainName})</span>` : nothing}
|
|
233
|
+
</td>
|
|
234
|
+
<td class="number">${number(row.calls)}</td>
|
|
235
|
+
<td class="number">${number(row.inputTokens)}</td>
|
|
236
|
+
<td class="number">${number(row.outputTokens)}</td>
|
|
237
|
+
<td class="number ${row.failures ? 'failed' : ''}">${number(row.failures)}</td>
|
|
238
|
+
</tr>`
|
|
239
|
+
)}
|
|
240
|
+
<tr class="total">
|
|
241
|
+
<td colspan="3">${t('total', 'Total')}</td>
|
|
242
|
+
<td class="number">${number(total.calls)}</td>
|
|
243
|
+
<td class="number">${number(total.inputTokens)}</td>
|
|
244
|
+
<td class="number">${number(total.outputTokens)}</td>
|
|
245
|
+
<td class="number">${number(total.failures)}</td>
|
|
246
|
+
</tr>
|
|
247
|
+
</tbody>
|
|
248
|
+
</table>
|
|
249
|
+
</div>
|
|
250
|
+
`
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** What the AI usage card asks for and adds up (design/ai/agent-settings.md §7-3). */
|
|
2
|
+
export type UsagePeriod = 'today' | '7d' | '30d'
|
|
3
|
+
export type UsageBucketSize = 'hour' | 'day' | 'month'
|
|
4
|
+
|
|
5
|
+
export interface UsageRow {
|
|
6
|
+
bucket: string
|
|
7
|
+
agentId?: string | null
|
|
8
|
+
agentName?: string | null
|
|
9
|
+
provider: string
|
|
10
|
+
model: string
|
|
11
|
+
domainId: string
|
|
12
|
+
domainName: string
|
|
13
|
+
userId?: string | null
|
|
14
|
+
userName?: string | null
|
|
15
|
+
calls: number
|
|
16
|
+
inputTokens: number
|
|
17
|
+
outputTokens: number
|
|
18
|
+
failures: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The span a period covers, up to the end of the current hour - usage is stored by the hour, so the hour in progress
|
|
23
|
+
* is counted whole.
|
|
24
|
+
*/
|
|
25
|
+
export function periodRange(period: UsagePeriod, now: Date): { from: Date; to: Date } {
|
|
26
|
+
const to = new Date(now)
|
|
27
|
+
to.setMinutes(0, 0, 0)
|
|
28
|
+
to.setHours(to.getHours() + 1)
|
|
29
|
+
const from = new Date(now)
|
|
30
|
+
from.setHours(0, 0, 0, 0)
|
|
31
|
+
if (period === '7d') from.setDate(from.getDate() - 6)
|
|
32
|
+
if (period === '30d') from.setDate(from.getDate() - 29)
|
|
33
|
+
return { from, to }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function totalsOf(rows: UsageRow[]) {
|
|
37
|
+
return rows.reduce(
|
|
38
|
+
(sum, row) => ({
|
|
39
|
+
calls: sum.calls + row.calls,
|
|
40
|
+
inputTokens: sum.inputTokens + row.inputTokens,
|
|
41
|
+
outputTokens: sum.outputTokens + row.outputTokens,
|
|
42
|
+
failures: sum.failures + row.failures
|
|
43
|
+
}),
|
|
44
|
+
{ calls: 0, inputTokens: 0, outputTokens: 0, failures: 0 }
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -117,7 +117,7 @@ export class AssistantDockButton extends LitElement {
|
|
|
117
117
|
|
|
118
118
|
let installed = false
|
|
119
119
|
|
|
120
|
-
export function setupAssistantDock(options: { viewpart?: string
|
|
120
|
+
export function setupAssistantDock(options: { viewpart?: string } = {}): void {
|
|
121
121
|
if (installed) return
|
|
122
122
|
installed = true
|
|
123
123
|
const viewpart = options.viewpart ?? ASSISTANT_DOCK_VIEWPART
|
|
@@ -129,9 +129,8 @@ export function setupAssistantDock(options: { viewpart?: string; overlayBoundary
|
|
|
129
129
|
* makes the element would make a new dock on every opening - and a new composer, the text being written gone
|
|
130
130
|
* (design/ai/shared-dock.md §2-1, §2-7). The same element is put back instead, as the entity360 dock is.
|
|
131
131
|
*/
|
|
132
|
-
const dock = document.createElement('ox-assistant-dock') as HTMLElement & { viewpart: string
|
|
132
|
+
const dock = document.createElement('ox-assistant-dock') as HTMLElement & { viewpart: string }
|
|
133
133
|
dock.viewpart = viewpart
|
|
134
|
-
dock.overlayBoundary = options.overlayBoundary
|
|
135
134
|
|
|
136
135
|
appendViewpart({
|
|
137
136
|
name: viewpart,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which AI agent the dock sends a turn with (design/ai/agent-settings.md §5).
|
|
3
|
+
*
|
|
4
|
+
* The server lists the agents a person may pick in this domain (`aiAgentChoices`); the dock sends the id of one, and
|
|
5
|
+
* the server decides the provider, the model and the key from it. What a person last picked is a convenience of this
|
|
6
|
+
* browser, kept per domain: an id the list no longer has is dropped and the domain's default answers.
|
|
7
|
+
*/
|
|
8
|
+
export interface AgentChoice {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
provider: string
|
|
12
|
+
model: string
|
|
13
|
+
isDefault: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The agent to send with: the remembered one while the domain still offers it, else the default, else the first. */
|
|
17
|
+
export function pickAgentId(choices: AgentChoice[], remembered?: string | null): string | undefined {
|
|
18
|
+
if (remembered && choices.some(one => one.id === remembered)) return remembered
|
|
19
|
+
return (choices.find(one => one.isDefault) ?? choices[0])?.id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A picker is drawn only when there is something to pick (the same rule as the mode picker, ADR-0072). */
|
|
23
|
+
export function offersAgentPicker(choices: AgentChoice[]): boolean {
|
|
24
|
+
return choices.length >= 2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function agentMemoryKey(contextPath: string | undefined): string {
|
|
28
|
+
return `assistant-agent:${contextPath ?? ''}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function rememberedAgent(contextPath: string | undefined): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
return localStorage.getItem(agentMemoryKey(contextPath)) ?? undefined
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rememberAgent(contextPath: string | undefined, agentId: string): void {
|
|
40
|
+
try {
|
|
41
|
+
localStorage.setItem(agentMemoryKey(contextPath), agentId)
|
|
42
|
+
} catch {
|
|
43
|
+
/* A browser that blocks storage still picks - it just asks again next time. */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PROVIDER_LABEL: Record<string, string> = { openai: 'OpenAI', anthropic: 'Claude', gemini: 'Gemini' }
|
|
48
|
+
|
|
49
|
+
/** `Claude · claude-sonnet-4-6` - what the picker shows beside a name. */
|
|
50
|
+
export function agentDetail(choice: Pick<AgentChoice, 'provider' | 'model'>): string {
|
|
51
|
+
return `${PROVIDER_LABEL[choice.provider] ?? choice.provider} · ${choice.model}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function providerLabel(provider: string): string {
|
|
55
|
+
return PROVIDER_LABEL[provider] ?? provider
|
|
56
|
+
}
|
|
@@ -2,25 +2,31 @@ import { surfaceTransition } from './assistant-navigation.js'
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
|
-
describe('
|
|
6
|
-
test.each([false, true])('a different identity resets even when
|
|
5
|
+
describe('the page decides the conversation - no choice is put to the person', () => {
|
|
6
|
+
test.each([false, true])('a different identity resets even when busy=%s', busy => {
|
|
7
7
|
for (const sameSubject of [false, true]) {
|
|
8
|
-
expect(surfaceTransition({ identityChanged: true, sameSubject,
|
|
8
|
+
expect(surfaceTransition({ identityChanged: true, sameSubject, busy })).toBe('reset')
|
|
9
9
|
}
|
|
10
10
|
})
|
|
11
|
-
test('
|
|
12
|
-
expect(surfaceTransition({ identityChanged: false, sameSubject: false,
|
|
11
|
+
test('★ another screen is followed - a conversation in progress or text being written does not hold the dock', () => {
|
|
12
|
+
expect(surfaceTransition({ identityChanged: false, sameSubject: false, busy: false })).toBe('follow')
|
|
13
13
|
})
|
|
14
|
-
test('an
|
|
15
|
-
expect(surfaceTransition({ identityChanged: false, sameSubject: false,
|
|
14
|
+
test('★ an answer in progress finishes in its own conversation first, then the dock follows', () => {
|
|
15
|
+
expect(surfaceTransition({ identityChanged: false, sameSubject: false, busy: true })).toBe('wait')
|
|
16
16
|
})
|
|
17
17
|
test('returning to the same subject refreshes without reopening', () => {
|
|
18
|
-
expect(surfaceTransition({ identityChanged: false, sameSubject: true,
|
|
18
|
+
expect(surfaceTransition({ identityChanged: false, sameSubject: true, busy: true })).toBe('refresh')
|
|
19
19
|
})
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
describe('navigation wiring', () => {
|
|
23
23
|
const dock = readFileSync(join(__dirname, '../components/assistant-dock.ts'), 'utf8')
|
|
24
|
+
test('★ what was being written stays with its subject and comes back on return', () => {
|
|
25
|
+
expect(dock).toMatch(/if \(transition !== 'reset'\) this\.keepDraft\(\)/)
|
|
26
|
+
expect(dock).toContain('void this.restoreDraft()')
|
|
27
|
+
expect(dock).not.toContain('screenChanged')
|
|
28
|
+
expect(dock).not.toContain('switch-to-screen')
|
|
29
|
+
})
|
|
24
30
|
test('history hides rather than unmounts the chat and its unsent input', () => {
|
|
25
31
|
expect(dock).toContain('?hidden=${this.showingHistory}')
|
|
26
32
|
expect(dock).toContain('ready && !this.loading && !this.failure')
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The page decides the conversation (design/ai/shared-dock.md §2-3, 2026-10-12): moving to another screen moves the
|
|
3
|
+
* dock to that screen's conversation, with no choice put to the person. What was being written stays with its subject
|
|
4
|
+
* and comes back on return. An answer in progress finishes first - then the dock follows. An identity change never
|
|
5
|
+
* carries the old conversation.
|
|
6
|
+
*/
|
|
7
|
+
export function surfaceTransition(input: { identityChanged: boolean; sameSubject: boolean; busy: boolean }): 'reset' | 'refresh' | 'wait' | 'follow' {
|
|
3
8
|
if (input.identityChanged) return 'reset'
|
|
4
9
|
if (input.sameSubject) return 'refresh'
|
|
5
|
-
return input.
|
|
10
|
+
return input.busy ? 'wait' : 'follow'
|
|
6
11
|
}
|
|
@@ -20,8 +20,19 @@ export function composerHeight(content: number, chat: number, resting: number):
|
|
|
20
20
|
|
|
21
21
|
export type ComposerKey = { key: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* What a key does in the box: send, close the wide box, take the suggestion, or nothing of ours (the textarea keeps
|
|
25
|
+
* it). The words shown in an empty box are a request the screen suggests - a plain Tab writes them in, so the person
|
|
26
|
+
* does not have to type what the screen already said.
|
|
27
|
+
*/
|
|
28
|
+
export function composerKeyAction(
|
|
29
|
+
e: ComposerKey,
|
|
30
|
+
wide: boolean,
|
|
31
|
+
box: { empty?: boolean; suggestion?: string } = {}
|
|
32
|
+
): 'send' | 'narrow' | 'take-suggestion' | null {
|
|
33
|
+
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && box.empty && box.suggestion?.trim()) {
|
|
34
|
+
return 'take-suggestion'
|
|
35
|
+
}
|
|
25
36
|
if (e.key === 'Escape') return wide ? 'narrow' : null
|
|
26
37
|
if (e.key !== 'Enter' || e.altKey) return null
|
|
27
38
|
if (e.ctrlKey || e.metaKey) return 'send'
|
|
@@ -47,3 +58,18 @@ export function tableAsWords(table: { rows: string[][]; text?: string | null }):
|
|
|
47
58
|
export function appendWords(written: string, words: string): string {
|
|
48
59
|
return written.trim() ? `${written.replace(/\s+$/, '')}\n${words}` : words
|
|
49
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The request the empty box suggests, which a Tab writes in: the conversation's first example request, its own
|
|
64
|
+
* before the host's. A placeholder is how to write, not a request, so it is never the suggestion. Once the
|
|
65
|
+
* conversation is under way an opening example no longer fits, and nothing is suggested.
|
|
66
|
+
*/
|
|
67
|
+
export function composerSuggestion(input: {
|
|
68
|
+
started: boolean
|
|
69
|
+
modeExamples?: Array<{ items: string[] }>
|
|
70
|
+
hostExamples?: Array<{ items: string[] }>
|
|
71
|
+
}): string | undefined {
|
|
72
|
+
if (input.started) return undefined
|
|
73
|
+
const groups = input.modeExamples?.length ? input.modeExamples : input.hostExamples
|
|
74
|
+
return groups?.find(group => group.items.length > 0)?.items[0]
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's menu gives the dock its room (2026-10-12). When the dock opens beside the page, the left menu folds to its
|
|
3
|
+
* icons; when the dock closes, the menu opens again - only if it is still folded the way the dock left it. A menu the
|
|
4
|
+
* person folded themselves stays folded, and the dock's fold is never saved as the person's choice: it sets the menu's
|
|
5
|
+
* state without the menu's own `setCollapsed`, which stores it.
|
|
6
|
+
*/
|
|
7
|
+
export interface MenuLike {
|
|
8
|
+
collapsed: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function foldForDock(menu: MenuLike | undefined): boolean {
|
|
12
|
+
if (!menu || menu.collapsed) return false
|
|
13
|
+
menu.collapsed = true
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unfoldAfterDock(menu: MenuLike | undefined, foldedByDock: boolean): void {
|
|
18
|
+
if (!menu || !foldedByDock || !menu.collapsed) return
|
|
19
|
+
menu.collapsed = false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The framework's menu (`ox-menu-part` of @operato/menu), wherever the layout put it. */
|
|
23
|
+
export function findMenu(root: Document | ShadowRoot = document): (HTMLElement & MenuLike) | undefined {
|
|
24
|
+
const found = root.querySelector('ox-menu-part') as (HTMLElement & MenuLike) | null
|
|
25
|
+
if (found) return found
|
|
26
|
+
for (const element of root.querySelectorAll('*')) {
|
|
27
|
+
if (element.shadowRoot) {
|
|
28
|
+
const inner = findMenu(element.shadowRoot)
|
|
29
|
+
if (inner) return inner
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import './settings/ai-agent-setting-let.js';
|
|
2
|
+
import './settings/ai-usage-setting-let.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›
|
|
5
|
+
* {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the
|
|
6
|
+
* tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.
|
|
7
|
+
*/
|
|
8
|
+
export default function bootstrap(): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { html } from 'lit';
|
|
2
|
+
import { store } from '@operato/shell';
|
|
3
|
+
import { auth, hasPrivilege } from '@things-factory/auth-base/dist-client/index.js';
|
|
4
|
+
import { ADD_SETTING } from '@things-factory/setting-base/dist-client/index.js';
|
|
5
|
+
import './settings/ai-agent-setting-let.js';
|
|
6
|
+
import './settings/ai-usage-setting-let.js';
|
|
7
|
+
/**
|
|
8
|
+
* Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›
|
|
9
|
+
* {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the
|
|
10
|
+
* tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.
|
|
11
|
+
*/
|
|
12
|
+
export default function bootstrap() {
|
|
13
|
+
auth.on('profile', async () => {
|
|
14
|
+
if (!(await hasPrivilege({ privilege: 'mutation', category: 'setting', domainOwnerGranted: true })))
|
|
15
|
+
return;
|
|
16
|
+
store.dispatch({
|
|
17
|
+
type: ADD_SETTING,
|
|
18
|
+
setting: { seq: 60, scope: 'tenant', template: html `<ai-agent-setting-let></ai-agent-setting-let>` }
|
|
19
|
+
});
|
|
20
|
+
store.dispatch({
|
|
21
|
+
type: ADD_SETTING,
|
|
22
|
+
setting: { seq: 61, scope: 'tenant', template: html `<ai-usage-setting-let></ai-usage-setting-let>` }
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=bootstrap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../client/bootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,CAAA;AAC1B,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,gDAAgD,CAAA;AACnF,OAAO,EAAE,WAAW,EAAE,MAAM,mDAAmD,CAAA;AAE/E,OAAO,oCAAoC,CAAA;AAC3C,OAAO,oCAAoC,CAAA;AAE3C;;;;GAIG;AACH,MAAM,CAAC,OAAO,UAAU,SAAS;IAC/B,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,IAAI,EAAE;QAC5B,IAAI,CAAC,CAAC,MAAM,YAAY,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC,CAAC;YAAE,OAAM;QAE3G,KAAK,CAAC,QAAQ,CAAC;YACb,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAA,+CAA+C,EAAE;SACrG,CAAC,CAAA;QACF,KAAK,CAAC,QAAQ,CAAC;YACb,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAA,+CAA+C,EAAE;SACrG,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import { html } from 'lit'\nimport { store } from '@operato/shell'\nimport { auth, hasPrivilege } from '@things-factory/auth-base/dist-client/index.js'\nimport { ADD_SETTING } from '@things-factory/setting-base/dist-client/index.js'\n\nimport './settings/ai-agent-setting-let.js'\nimport './settings/ai-usage-setting-let.js'\n\n/**\n * Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›\n * {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the\n * tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.\n */\nexport default function bootstrap() {\n auth.on('profile', async () => {\n if (!(await hasPrivilege({ privilege: 'mutation', category: 'setting', domainOwnerGranted: true }))) return\n\n store.dispatch({\n type: ADD_SETTING,\n setting: { seq: 60, scope: 'tenant', template: html`<ai-agent-setting-let></ai-agent-setting-let>` }\n })\n store.dispatch({\n type: ADD_SETTING,\n setting: { seq: 61, scope: 'tenant', template: html`<ai-usage-setting-let></ai-usage-setting-let>` }\n })\n })\n}\n"]}
|
|
@@ -298,6 +298,18 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
298
298
|
busy: boolean;
|
|
299
299
|
draft: boolean;
|
|
300
300
|
};
|
|
301
|
+
/**
|
|
302
|
+
* What is being written, handed to the host when the page moves to another subject - it belongs to the conversation
|
|
303
|
+
* it was written in, and comes back with `restoreDraft` when the person returns. The box is left empty.
|
|
304
|
+
*/
|
|
305
|
+
takeDraft(): {
|
|
306
|
+
text: string;
|
|
307
|
+
attachments: ChatAttachment[];
|
|
308
|
+
} | undefined;
|
|
309
|
+
restoreDraft(draft: {
|
|
310
|
+
text: string;
|
|
311
|
+
attachments: ChatAttachment[];
|
|
312
|
+
}): void;
|
|
301
313
|
private errorMessage?;
|
|
302
314
|
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
303
315
|
private participants;
|
|
@@ -356,6 +368,17 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
356
368
|
/** 이 subject 에 설 수 있는 대화들 — 기본이 먼저. 하나뿐이면 고르개를 그리지 않는다. */
|
|
357
369
|
private modeChoices;
|
|
358
370
|
private modeChoicesToken;
|
|
371
|
+
/** The AI agents this domain lets a person pick (design/ai/agent-settings.md §5). One or none draws no picker. */
|
|
372
|
+
private agentChoices;
|
|
373
|
+
private agentId?;
|
|
374
|
+
private agentChoicesToken;
|
|
375
|
+
private get contextPath();
|
|
376
|
+
/**
|
|
377
|
+
* Asks which agents this domain offers. The list changes when someone edits the domain's agents, so it is read
|
|
378
|
+
* again whenever the conversation's subject is - never cached for the life of the page.
|
|
379
|
+
*/
|
|
380
|
+
private loadAgentChoices;
|
|
381
|
+
private onAgentPick;
|
|
359
382
|
/**
|
|
360
383
|
* 이 화면에 설 수 있는 대화를 서버에 묻는다.
|
|
361
384
|
*
|
|
@@ -453,6 +476,8 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
453
476
|
private tableShape;
|
|
454
477
|
private renderTablePreview;
|
|
455
478
|
private onKeyDown;
|
|
479
|
+
/** The request a Tab writes into the empty box - `composerSuggestion` decides it. */
|
|
480
|
+
private get suggestion();
|
|
456
481
|
/** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
|
|
457
482
|
* big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
|
|
458
483
|
private onTextareaInput;
|
|
@@ -497,8 +522,6 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
497
522
|
* 읽었고, 그러면 호스트 값을 한쪽만 반영하는 상태가 만들어질 수 있었다.
|
|
498
523
|
*/
|
|
499
524
|
private get exampleGroups();
|
|
500
|
-
/** 이 표면이 실제로 쓸 수 있는 트리거 — 판정은 `chat-triggers.ts` 가 하고 여기서는 사실만 읽는다. */
|
|
501
|
-
private get triggerChips();
|
|
502
525
|
private static readonly EXAMPLE_GROUPS;
|
|
503
526
|
/** 보드의 세션 탭 — 다중 세션 활성화 시 패널 상단. host 가 sessions 비워두면 표시 X.
|
|
504
527
|
* 레이블은 session.name 우선, 없으면 fallback "세션 N". */
|
|
@@ -525,14 +548,19 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
525
548
|
* 돌아온다.
|
|
526
549
|
*/
|
|
527
550
|
private renderModeSubject;
|
|
551
|
+
/**
|
|
552
|
+
* Which AI answers the next turn - at the end of the row above the box, where the words about writing stand. A row of
|
|
553
|
+
* its own spent a line of the dock on one control. It is a property of the turn, not of the conversation: switching
|
|
554
|
+
* keeps the conversation and only the next answer comes from the other agent. Drawn only when there is a choice.
|
|
555
|
+
*/
|
|
556
|
+
private renderAgentPicker;
|
|
557
|
+
private renderModePicker;
|
|
528
558
|
private renderSessionTabs;
|
|
529
559
|
/** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
|
|
530
560
|
private renderRoster;
|
|
531
561
|
private _onSessionTabClick;
|
|
532
562
|
private _onSessionCreateClick;
|
|
533
563
|
private renderEmpty;
|
|
534
|
-
/** trigger chip 클릭 — textarea 에 trigger 문자 삽입 + focus + popup 자동 활성. */
|
|
535
|
-
private insertTrigger;
|
|
536
564
|
/** 예시 클릭 — 입력창에 채우고 포커스 (사용자가 수정 후 전송 가능) */
|
|
537
565
|
private useExample;
|
|
538
566
|
private renderInlineExamples;
|