@nauticana/sail 1.1.33 → 1.1.34
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/README.md +17 -11
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +63 -0
- package/src/agent/component/approval_inbox.ts +91 -0
- package/src/agent/component/decision_timeline.html +4 -4
- package/src/agent/component/decision_timeline.ts +2 -0
- package/src/agent/component/prompt_section_editor.html +4 -3
- package/src/agent/component/prompt_section_editor.ts +12 -5
- package/src/agent/component/release_sections.html +1 -1
- package/src/agent/component/release_sections.ts +2 -1
- package/src/agent/component/studio_page.html +269 -0
- package/src/agent/component/studio_page.ts +346 -0
- package/src/agent/component/studio_tab.ts +14 -0
- package/src/agent/component/turn_event_list.html +9 -0
- package/src/agent/index.ts +6 -0
- package/src/agent/model/agent_studio.ts +122 -9
- package/src/agent/model/approval.ts +42 -0
- package/src/agent/model/conversation.ts +9 -1
- package/src/agent/model/decision.ts +2 -1
- package/src/agent/model/turn_event.ts +10 -0
- package/src/agent/service/agent_studio.service.ts +36 -1
- package/src/agent/service/approval.service.ts +18 -0
- package/src/agent/util/release_diff.ts +33 -0
- package/src/agent/util/studio_errors.ts +11 -0
- package/src/agent/util/turn_timeline.ts +7 -1
- package/src/component/login/account_deletion.html +17 -4
- package/src/component/login/account_deletion.ts +35 -28
- package/src/component/login/social_login.ts +6 -5
- package/src/index.ts +1 -0
- package/src/model/auth.ts +17 -2
- package/src/service/auth.service.ts +10 -4
- package/src/service/caption_pipe.ts +26 -0
- package/src/service/label.service.ts +5 -4
- package/src/service/rest_url.ts +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
<div class="studio">
|
|
2
|
+
<aside class="studio__rail" aria-label="Agents">
|
|
3
|
+
<mat-slide-toggle [checked]="showArchived()" (change)="showArchived.set($event.checked)">Show archived</mat-slide-toggle>
|
|
4
|
+
@for (group of groups(); track group.name) {
|
|
5
|
+
<section class="studio__group" [attr.aria-label]="group.name">
|
|
6
|
+
<h3 class="studio__group-name">{{ group.name }}</h3>
|
|
7
|
+
<ul>
|
|
8
|
+
@for (agent of group.agents; track agent.agent_name) {
|
|
9
|
+
<li>
|
|
10
|
+
<button type="button" class="studio__agent" [class.selected]="agent.agent_name === selectedName()"
|
|
11
|
+
[attr.aria-current]="agent.agent_name === selectedName()" (click)="select(agent.agent_name)">
|
|
12
|
+
<span class="studio__agent-name">{{ agent.display_name }}</span>
|
|
13
|
+
<span [class]="'studio__readiness studio__readiness--' + agent.readiness">{{ agent.readiness }}</span>
|
|
14
|
+
<span class="studio__agent-meta">
|
|
15
|
+
@if (agent.is_default) { <span>Default</span> }
|
|
16
|
+
@if (agent.archived) { <span>Archived</span> }
|
|
17
|
+
@if (agent.published_version > 0) { <span>v{{ agent.published_version }}</span> } @else { <span>Never published</span> }
|
|
18
|
+
</span>
|
|
19
|
+
</button>
|
|
20
|
+
</li>
|
|
21
|
+
}
|
|
22
|
+
</ul>
|
|
23
|
+
</section>
|
|
24
|
+
}
|
|
25
|
+
</aside>
|
|
26
|
+
|
|
27
|
+
<main class="studio__main">
|
|
28
|
+
@if (draft(); as d) {
|
|
29
|
+
<header class="studio__header">
|
|
30
|
+
<h2>{{ d.display_name }}</h2>
|
|
31
|
+
@if (selected(); as agent) {
|
|
32
|
+
<p>{{ agent.purpose }} @if (agent.readiness_reason) { · {{ agent.readiness_reason }} }</p>
|
|
33
|
+
}
|
|
34
|
+
@if (d.drift; as drift) {
|
|
35
|
+
<p class="studio__drift" role="status">
|
|
36
|
+
{{ drift.causes.join(', ') }} changed since version {{ drift.active_version }} ({{ drift.changed_languages.join(', ') }}). Publishing adopts it.
|
|
37
|
+
</p>
|
|
38
|
+
}
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<nav class="studio__tabs" role="tablist" aria-label="Agent">
|
|
42
|
+
@for (t of tabs(); track t.id) {
|
|
43
|
+
<button type="button" role="tab" [class.active]="tab() === t.id" [attr.aria-selected]="tab() === t.id"
|
|
44
|
+
(click)="openTab(t.id)">{{ t.label }}</button>
|
|
45
|
+
}
|
|
46
|
+
</nav>
|
|
47
|
+
|
|
48
|
+
<div class="studio__panel" role="tabpanel">
|
|
49
|
+
@switch (tab()) {
|
|
50
|
+
@case ('overview') {
|
|
51
|
+
<mat-form-field>
|
|
52
|
+
<mat-label>Display name</mat-label>
|
|
53
|
+
<input matInput [value]="d.display_name" [disabled]="!can('EDIT')"
|
|
54
|
+
(input)="patchDraft({ display_name: $any($event.target).value })">
|
|
55
|
+
</mat-form-field>
|
|
56
|
+
<mat-slide-toggle [checked]="d.enabled" [disabled]="!can('EDIT') || busy()"
|
|
57
|
+
(change)="setEnabled($event.checked)">Enabled</mat-slide-toggle>
|
|
58
|
+
<mat-form-field>
|
|
59
|
+
<mat-label>Text model</mat-label>
|
|
60
|
+
<mat-select [value]="d.models.text_model" [disabled]="!can('EDIT')" (selectionChange)="patchModel('text_model', $event.value)">
|
|
61
|
+
@for (m of modelsOf('T'); track m.id) { <mat-option [value]="m.id">{{ m.display_name }} · {{ m.provider }}</mat-option> }
|
|
62
|
+
</mat-select>
|
|
63
|
+
</mat-form-field>
|
|
64
|
+
@if (modelsOf('I').length > 0) {
|
|
65
|
+
<mat-form-field>
|
|
66
|
+
<mat-label>Image model</mat-label>
|
|
67
|
+
<mat-select [value]="d.models.image_model" [disabled]="!can('EDIT')" (selectionChange)="patchModel('image_model', $event.value)">
|
|
68
|
+
<mat-option value="">None</mat-option>
|
|
69
|
+
@for (m of modelsOf('I'); track m.id) { <mat-option [value]="m.id">{{ m.display_name }} · {{ m.provider }}</mat-option> }
|
|
70
|
+
</mat-select>
|
|
71
|
+
</mat-form-field>
|
|
72
|
+
}
|
|
73
|
+
@if (modelsOf('V').length > 0) {
|
|
74
|
+
<mat-form-field>
|
|
75
|
+
<mat-label>Video model</mat-label>
|
|
76
|
+
<mat-select [value]="d.models.video_model" [disabled]="!can('EDIT')" (selectionChange)="patchModel('video_model', $event.value)">
|
|
77
|
+
<mat-option value="">None</mat-option>
|
|
78
|
+
@for (m of modelsOf('V'); track m.id) { <mat-option [value]="m.id">{{ m.display_name }} · {{ m.provider }}</mat-option> }
|
|
79
|
+
</mat-select>
|
|
80
|
+
</mat-form-field>
|
|
81
|
+
}
|
|
82
|
+
<p class="studio__key">Internal key <code>{{ d.agent_name }}</code> · type {{ d.agent_type }}</p>
|
|
83
|
+
<div class="studio__actions">
|
|
84
|
+
@if (!d.is_default && can('PUBLISH')) {
|
|
85
|
+
<button matButton type="button" [disabled]="busy()" (click)="makeDefault()">Make default {{ d.agent_type }}</button>
|
|
86
|
+
}
|
|
87
|
+
@if (can('ARCHIVE')) {
|
|
88
|
+
<button matButton type="button" [disabled]="busy()" (click)="setArchived(!selected()?.archived)">
|
|
89
|
+
{{ selected()?.archived ? 'Unarchive' : 'Archive' }}
|
|
90
|
+
</button>
|
|
91
|
+
}
|
|
92
|
+
</div>
|
|
93
|
+
@if (can('CREATE', 'agent_type:' + d.agent_type)) {
|
|
94
|
+
<div class="studio__create">
|
|
95
|
+
<mat-form-field>
|
|
96
|
+
<mat-label>New {{ d.agent_type }} agent, copied from this one</mat-label>
|
|
97
|
+
<input matInput #newName>
|
|
98
|
+
</mat-form-field>
|
|
99
|
+
<button matButton type="button" [disabled]="busy() || dirty()" (click)="createCopy(newName.value); newName.value = ''">Create</button>
|
|
100
|
+
</div>
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
@case ('prompts') {
|
|
104
|
+
@if (d.languages.length > 1) {
|
|
105
|
+
<mat-form-field>
|
|
106
|
+
<mat-label>Language</mat-label>
|
|
107
|
+
<mat-select [value]="currentLanguage()?.language_code" (selectionChange)="language.set($event.value)">
|
|
108
|
+
@for (l of d.languages; track l.language_code) { <mat-option [value]="l.language_code">{{ l.language_code }}</mat-option> }
|
|
109
|
+
</mat-select>
|
|
110
|
+
</mat-form-field>
|
|
111
|
+
}
|
|
112
|
+
@if (currentLanguage(); as lang) {
|
|
113
|
+
<sail-prompt-language-editor [language]="lang" [agentScopeId]="d.agent_scope_id ?? ''" [typeScopeId]="d.type_scope_id ?? ''"
|
|
114
|
+
[disabled]="!can('EDIT') || busy()" (languageChange)="updateLanguage($event)"
|
|
115
|
+
(validChange)="promptsValid.set($event)" (resetRequested)="reset($event)" />
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
@case ('settings') {
|
|
119
|
+
@if (settings(); as s) {
|
|
120
|
+
@if (s.problem) { <p class="studio__error" role="alert">{{ s.problem }}</p> }
|
|
121
|
+
<h3>In effect</h3>
|
|
122
|
+
<ul class="studio__settings">
|
|
123
|
+
@for (e of s.effective; track e.resource_kind + '/' + e.resource_id) {
|
|
124
|
+
<li>
|
|
125
|
+
<strong>{{ e.resource_kind }} / {{ e.resource_id }}</strong>
|
|
126
|
+
from {{ e.source.scope_kind }} ({{ e.source.merge_mode | sailCaption:'config_merge_mode' }}@if (e.source.sealed) {, sealed})
|
|
127
|
+
<pre>{{ e.value | json }}</pre>
|
|
128
|
+
</li>
|
|
129
|
+
}
|
|
130
|
+
</ul>
|
|
131
|
+
<h3>Editable bindings</h3>
|
|
132
|
+
<ul class="studio__settings">
|
|
133
|
+
@for (b of s.bindings; track b.scope_id + '/' + b.resource_kind + '/' + b.resource_id) {
|
|
134
|
+
@if (b.editable) {
|
|
135
|
+
<li>
|
|
136
|
+
<strong>{{ b.resource_kind }} / {{ b.resource_id }}</strong> at {{ b.scope_kind }}
|
|
137
|
+
({{ b.merge_mode | sailCaption:'config_merge_mode' }}@if (b.sealed) {, sealed})
|
|
138
|
+
<mat-form-field>
|
|
139
|
+
<mat-label>Value (JSON)</mat-label>
|
|
140
|
+
<textarea matInput rows="4" #value [value]="b.value | json" [disabled]="!can('EDIT')"></textarea>
|
|
141
|
+
</mat-form-field>
|
|
142
|
+
@if (can('EDIT')) {
|
|
143
|
+
<button matButton type="button" [disabled]="busy()" (click)="saveSetting(b, value.value)">Save</button>
|
|
144
|
+
<button matButton type="button" [disabled]="busy()" (click)="saveSetting(b, '', true)">Remove</button>
|
|
145
|
+
}
|
|
146
|
+
</li>
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
</ul>
|
|
150
|
+
@if (can('EDIT')) {
|
|
151
|
+
<fieldset class="studio__add-setting">
|
|
152
|
+
<legend>Add a binding</legend>
|
|
153
|
+
<mat-form-field>
|
|
154
|
+
<mat-label>Scope</mat-label>
|
|
155
|
+
<mat-select #scope [value]="s.agent_scope_id">
|
|
156
|
+
<mat-option [value]="s.agent_scope_id">This agent</mat-option>
|
|
157
|
+
<mat-option [value]="s.type_scope_id">Every {{ d.agent_type }} agent</mat-option>
|
|
158
|
+
</mat-select>
|
|
159
|
+
</mat-form-field>
|
|
160
|
+
<mat-form-field><mat-label>Resource kind</mat-label><input matInput #kind></mat-form-field>
|
|
161
|
+
<mat-form-field><mat-label>Resource id</mat-label><input matInput #rid></mat-form-field>
|
|
162
|
+
<mat-form-field>
|
|
163
|
+
<mat-label>Merge mode</mat-label>
|
|
164
|
+
<mat-select #merge value="R">
|
|
165
|
+
@for (mode of mergeModes(); track mode.code) { <mat-option [value]="mode.code">{{ mode.caption }}</mat-option> }
|
|
166
|
+
</mat-select>
|
|
167
|
+
</mat-form-field>
|
|
168
|
+
<mat-form-field><mat-label>Value (JSON)</mat-label><textarea matInput rows="3" #val></textarea></mat-form-field>
|
|
169
|
+
<button matButton type="button" [disabled]="busy()"
|
|
170
|
+
(click)="addSetting(scope.value, kind.value, rid.value, merge.value, val.value)">Add</button>
|
|
171
|
+
</fieldset>
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
@case ('test') {
|
|
176
|
+
@if (can('TEST')) {
|
|
177
|
+
@if (dirty()) { <p role="status">Unsaved changes are not tested; save the draft first.</p> }
|
|
178
|
+
<mat-form-field><mat-label>Task (optional)</mat-label><input matInput #task></mat-form-field>
|
|
179
|
+
<mat-form-field><mat-label>Sample input (optional)</mat-label><textarea matInput rows="3" #sample></textarea></mat-form-field>
|
|
180
|
+
<button matButton="elevated" type="button" [disabled]="busy()" (click)="runTest(task.value, sample.value)">Run test</button>
|
|
181
|
+
@if (testResult(); as r) {
|
|
182
|
+
<div class="studio__test-result" aria-live="polite">
|
|
183
|
+
<p>{{ r.model }} · {{ r.latency_ms }} ms · {{ r.input_tokens }} in / {{ r.output_tokens }} out · cost {{ r.credits }} · config {{ r.digest.slice(0, 12) }}</p>
|
|
184
|
+
<p>Sections: {{ r.sections.join(', ') }}</p>
|
|
185
|
+
<pre>{{ r.output }}</pre>
|
|
186
|
+
</div>
|
|
187
|
+
}
|
|
188
|
+
} @else {
|
|
189
|
+
<p>You are not authorized to run tests.</p>
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
@case ('history') {
|
|
193
|
+
@if (diff(); as df) {
|
|
194
|
+
<section class="studio__diff" aria-label="Version comparison">
|
|
195
|
+
<h3>v{{ df.previous }} → v{{ df.version }}</h3>
|
|
196
|
+
<button matButton type="button" (click)="diff.set(null)">Close</button>
|
|
197
|
+
@for (row of df.rows; track row.language_code + '#' + row.prompt_header_id) {
|
|
198
|
+
<div [class]="'studio__diff-row studio__diff-row--' + row.status">
|
|
199
|
+
<span>{{ row.caption }} · {{ row.language_code }} · {{ row.status }}</span>
|
|
200
|
+
@if (row.old_text) { <pre class="studio__diff-old">{{ row.old_text }}</pre> }
|
|
201
|
+
@if (row.new_text) { <pre class="studio__diff-new">{{ row.new_text }}</pre> }
|
|
202
|
+
</div>
|
|
203
|
+
} @empty {
|
|
204
|
+
<p>Prompt sections are identical; the versions differ only in configuration.</p>
|
|
205
|
+
}
|
|
206
|
+
</section>
|
|
207
|
+
}
|
|
208
|
+
<ul class="studio__releases">
|
|
209
|
+
@for (release of releases(); track release.version) {
|
|
210
|
+
<li>
|
|
211
|
+
<strong>v{{ release.version }}</strong> @if (release.active) { <span>Active</span> }
|
|
212
|
+
{{ release.change_summary || 'No change summary' }}
|
|
213
|
+
· {{ release.published_at | date: 'medium' }} · {{ release.languages.join(', ') }}
|
|
214
|
+
@if (release.activation; as a) { · scheduled {{ a.effective_at | date: 'medium' }} ({{ a.state | sailCaption:'release_activation_state' }}) }
|
|
215
|
+
@if (previousVersion(release.version) > 0) {
|
|
216
|
+
<button matButton type="button" [disabled]="busy()" (click)="compare(release.version)">Diff</button>
|
|
217
|
+
}
|
|
218
|
+
@if (!release.active && can('RESTORE')) {
|
|
219
|
+
<button matButton type="button" [disabled]="busy()" (click)="restore(release.version)">Restore</button>
|
|
220
|
+
}
|
|
221
|
+
</li>
|
|
222
|
+
} @empty {
|
|
223
|
+
<li>Nothing published yet.</li>
|
|
224
|
+
}
|
|
225
|
+
</ul>
|
|
226
|
+
@if (audit().length > 0) {
|
|
227
|
+
<h3>Audit</h3>
|
|
228
|
+
<ul class="studio__audit">
|
|
229
|
+
@for (event of audit(); track $index) {
|
|
230
|
+
<li>{{ event.event }} · {{ event.detail }} · {{ event.event_time | date: 'medium' }}</li>
|
|
231
|
+
}
|
|
232
|
+
</ul>
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
@default {
|
|
236
|
+
@if (productTab(); as t) {
|
|
237
|
+
@if (selected(); as agent) {
|
|
238
|
+
<ng-container [ngTemplateOutlet]="t.template" [ngTemplateOutletContext]="{ $implicit: agent }" />
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
@if (successMessage()) { <p class="studio__success" role="status">{{ successMessage() }}</p> }
|
|
246
|
+
@if (errorMessage()) { <p class="studio__error" role="alert">{{ errorMessage() }}</p> }
|
|
247
|
+
|
|
248
|
+
<footer class="studio__footer" [class.dirty]="dirty()">
|
|
249
|
+
@if (dirty()) {
|
|
250
|
+
<span>Unsaved changes</span>
|
|
251
|
+
<button matButton type="button" [disabled]="busy()" (click)="discard()">Discard</button>
|
|
252
|
+
}
|
|
253
|
+
@if (can('EDIT')) {
|
|
254
|
+
<button matButton="elevated" type="button" [disabled]="!dirty() || !promptsValid() || busy()" (click)="save()">Save draft</button>
|
|
255
|
+
}
|
|
256
|
+
@if (can('PUBLISH')) {
|
|
257
|
+
<mat-form-field>
|
|
258
|
+
<mat-label>Change summary</mat-label>
|
|
259
|
+
<input matInput [value]="changeSummary()" (input)="changeSummary.set($any($event.target).value)">
|
|
260
|
+
</mat-form-field>
|
|
261
|
+
<button matButton="elevated" type="button" [disabled]="dirty() || busy()" (click)="publish()">Publish</button>
|
|
262
|
+
}
|
|
263
|
+
</footer>
|
|
264
|
+
} @else {
|
|
265
|
+
@if (errorMessage()) { <p class="studio__error" role="alert">{{ errorMessage() }}</p> }
|
|
266
|
+
@if (!busy() && summaries().length === 0) { <p>No agents yet.</p> }
|
|
267
|
+
}
|
|
268
|
+
</main>
|
|
269
|
+
</div>
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, contentChildren, inject, input, signal,
|
|
4
|
+
} from '@angular/core';
|
|
5
|
+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
6
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
7
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
8
|
+
import { MatInputModule } from '@angular/material/input';
|
|
9
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
10
|
+
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|
11
|
+
import { Observable, forkJoin, map } from 'rxjs';
|
|
12
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
13
|
+
import { CaptionPipe } from '../../service/caption_pipe';
|
|
14
|
+
import { LabelOption, LabelService } from '../../service/label.service';
|
|
15
|
+
import {
|
|
16
|
+
AgentAuditEvent, AgentDraft, AgentLanguageDraft, AgentMergeMode, AgentModelSelection, AgentRelease, AgentSetting,
|
|
17
|
+
AgentSettings, AgentSummary, AgentTestResult, StudioFeature, StudioModel,
|
|
18
|
+
} from '../model/agent_studio';
|
|
19
|
+
import { AgentStudioService } from '../service/agent_studio.service';
|
|
20
|
+
import { ReleaseSectionDiff, diffReleaseSections } from '../util/release_diff';
|
|
21
|
+
import { studioErrorMessage } from '../util/studio_errors';
|
|
22
|
+
import { PromptLanguageEditorComponent, PromptResetTarget } from './prompt_language_editor';
|
|
23
|
+
import { StudioTabDirective } from './studio_tab';
|
|
24
|
+
|
|
25
|
+
const CORE_TABS = [
|
|
26
|
+
{ id: 'overview', label: 'Overview' },
|
|
27
|
+
{ id: 'prompts', label: 'Prompts' },
|
|
28
|
+
{ id: 'settings', label: 'Settings' },
|
|
29
|
+
{ id: 'test', label: 'Test' },
|
|
30
|
+
{ id: 'history', label: 'History' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Embeddable Agent Studio over Scout's core `agents` feature: a grouped agent rail and tabs
|
|
35
|
+
* for the draft, prompts, scoped settings, tests, and release history, plus product tabs
|
|
36
|
+
* declared with `sailStudioTab`. A control shows only when the feature manifest lists its
|
|
37
|
+
* action and the caller holds the grant; hiding is never the authorization, Scout decides.
|
|
38
|
+
* Selector: <sail-studio-page>.
|
|
39
|
+
*/
|
|
40
|
+
@Component({
|
|
41
|
+
selector: 'sail-studio-page',
|
|
42
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
43
|
+
imports: [
|
|
44
|
+
DatePipe, JsonPipe, NgTemplateOutlet, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
45
|
+
MatSlideToggleModule, CaptionPipe, PromptLanguageEditorComponent,
|
|
46
|
+
],
|
|
47
|
+
templateUrl: './studio_page.html',
|
|
48
|
+
})
|
|
49
|
+
export class StudioPageComponent implements OnInit {
|
|
50
|
+
private readonly studio = inject(AgentStudioService);
|
|
51
|
+
private readonly auth = inject(BaseAuthService);
|
|
52
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
53
|
+
private readonly labels = inject(LabelService);
|
|
54
|
+
|
|
55
|
+
/** Rail group of an agent; its type by default. */
|
|
56
|
+
readonly groupBy = input<(agent: AgentSummary) => string>((agent) => agent.agent_type);
|
|
57
|
+
private readonly productTabs = contentChildren(StudioTabDirective);
|
|
58
|
+
|
|
59
|
+
readonly features = signal<StudioFeature[]>([]);
|
|
60
|
+
readonly models = signal<StudioModel[]>([]);
|
|
61
|
+
readonly summaries = signal<AgentSummary[]>([]);
|
|
62
|
+
readonly showArchived = signal(false);
|
|
63
|
+
readonly selectedName = signal('');
|
|
64
|
+
readonly draft = signal<AgentDraft | null>(null);
|
|
65
|
+
private readonly baseline = signal('');
|
|
66
|
+
readonly settings = signal<AgentSettings | null>(null);
|
|
67
|
+
readonly releases = signal<AgentRelease[]>([]);
|
|
68
|
+
readonly audit = signal<AgentAuditEvent[]>([]);
|
|
69
|
+
readonly diff = signal<{ version: number; previous: number; rows: ReleaseSectionDiff[] } | null>(null);
|
|
70
|
+
readonly testResult = signal<AgentTestResult | null>(null);
|
|
71
|
+
readonly tab = signal('overview');
|
|
72
|
+
readonly language = signal('');
|
|
73
|
+
readonly promptsValid = signal(true);
|
|
74
|
+
readonly changeSummary = signal('');
|
|
75
|
+
readonly mergeModes = toSignal(this.labels.loadLabels('config_merge_mode').pipe(
|
|
76
|
+
map(() => this.labels.options('config_merge_mode'))), { initialValue: [] as LabelOption[] });
|
|
77
|
+
readonly errorMessage = signal('');
|
|
78
|
+
readonly successMessage = signal('');
|
|
79
|
+
private readonly pending = signal(0);
|
|
80
|
+
readonly busy = computed(() => this.pending() > 0);
|
|
81
|
+
|
|
82
|
+
readonly tabs = computed(() => [...CORE_TABS, ...this.productTabs().map((t) => ({ id: t.id(), label: t.label() }))]);
|
|
83
|
+
readonly productTab = computed(() => this.productTabs().find((t) => t.id() === this.tab()));
|
|
84
|
+
readonly selected = computed(() => this.summaries().find((a) => a.agent_name === this.selectedName()));
|
|
85
|
+
readonly dirty = computed(() => this.draft() !== null && JSON.stringify(this.draft()) !== this.baseline());
|
|
86
|
+
readonly currentLanguage = computed<AgentLanguageDraft | undefined>(() => {
|
|
87
|
+
const languages = this.draft()?.languages ?? [];
|
|
88
|
+
return languages.find((l) => l.language_code === this.language()) ?? languages[0];
|
|
89
|
+
});
|
|
90
|
+
readonly groups = computed(() => {
|
|
91
|
+
const groupOf = this.groupBy();
|
|
92
|
+
const groups = new Map<string, AgentSummary[]>();
|
|
93
|
+
for (const agent of this.summaries()) {
|
|
94
|
+
if (agent.archived && !this.showArchived()) continue;
|
|
95
|
+
const key = groupOf(agent);
|
|
96
|
+
groups.set(key, [...(groups.get(key) ?? []), agent]);
|
|
97
|
+
}
|
|
98
|
+
return [...groups].map(([name, agents]) => ({ name, agents }));
|
|
99
|
+
});
|
|
100
|
+
private readonly core = computed(() => this.features().find((f) => f.id === 'agents'));
|
|
101
|
+
|
|
102
|
+
ngOnInit(): void {
|
|
103
|
+
this.call(this.studio.features(), (features) => this.features.set(features));
|
|
104
|
+
this.call(this.studio.models(), (models) => this.models.set(models));
|
|
105
|
+
this.loadList();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** For a canDeactivate guard. */
|
|
109
|
+
hasUnsavedChanges(): boolean {
|
|
110
|
+
return this.dirty();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether the manifest serves `action` and the caller holds it at `value` (the selected agent by default). */
|
|
114
|
+
can(action: string, value = `agent:${this.selectedName()}`): boolean {
|
|
115
|
+
const core = this.core();
|
|
116
|
+
return !!core?.actions.includes(action) && this.auth.hasPermission(core.object || 'AGENT_STUDIO', action, value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
modelsOf(type: string): StudioModel[] {
|
|
120
|
+
return this.models().filter((m) => m.model_type === type);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
loadList(select?: string): void {
|
|
124
|
+
this.call(this.studio.listAgents(), (rows) => {
|
|
125
|
+
this.summaries.set(rows);
|
|
126
|
+
const target = select ?? (this.selectedName() || rows.find((a) => !a.archived)?.agent_name);
|
|
127
|
+
if (target && target !== this.selectedName()) this.select(target, true);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
select(agentName: string, force = false): void {
|
|
132
|
+
if (!force && agentName === this.selectedName()) return;
|
|
133
|
+
if (!force && this.dirty() && !window.confirm('Discard unsaved changes for this agent?')) return;
|
|
134
|
+
this.selectedName.set(agentName);
|
|
135
|
+
this.draft.set(null);
|
|
136
|
+
this.settings.set(null);
|
|
137
|
+
this.releases.set([]);
|
|
138
|
+
this.audit.set([]);
|
|
139
|
+
this.diff.set(null);
|
|
140
|
+
this.testResult.set(null);
|
|
141
|
+
this.errorMessage.set('');
|
|
142
|
+
this.successMessage.set('');
|
|
143
|
+
this.loadAgent();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
openTab(id: string): void {
|
|
147
|
+
this.tab.set(id);
|
|
148
|
+
if (id === 'history') this.loadHistory();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private loadAgent(): void {
|
|
152
|
+
const name = this.selectedName();
|
|
153
|
+
this.call(this.studio.getDraft(name), (draft) => this.applyDraft(draft), name);
|
|
154
|
+
this.call(this.studio.settings(name), (settings) => this.settings.set(settings), name);
|
|
155
|
+
if (this.tab() === 'history') this.loadHistory();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private applyDraft(draft: AgentDraft): void {
|
|
159
|
+
this.draft.set(draft);
|
|
160
|
+
this.baseline.set(JSON.stringify(draft));
|
|
161
|
+
this.promptsValid.set(true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Carries new revisions into the draft and its baseline without touching unsaved edits. */
|
|
165
|
+
private applyRevisions(agentRevision: number, typeRevision?: number, patch: Partial<AgentDraft> = {}): void {
|
|
166
|
+
const revisions: Partial<AgentDraft> = { ...patch, expected_agent_revision: agentRevision };
|
|
167
|
+
if (typeRevision !== undefined) revisions.expected_type_defaults_revision = typeRevision;
|
|
168
|
+
const d = this.draft();
|
|
169
|
+
if (!d) return;
|
|
170
|
+
this.draft.set({ ...d, ...revisions });
|
|
171
|
+
this.baseline.set(JSON.stringify({ ...(JSON.parse(this.baseline()) as AgentDraft), ...revisions }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
patchDraft(changes: Partial<AgentDraft>): void {
|
|
175
|
+
const d = this.draft();
|
|
176
|
+
if (d) this.draft.set({ ...d, ...changes });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
patchModel(field: keyof AgentModelSelection, value: string): void {
|
|
180
|
+
const d = this.draft();
|
|
181
|
+
if (d) this.patchDraft({ models: { ...d.models, [field]: value } });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
updateLanguage(language: AgentLanguageDraft): void {
|
|
185
|
+
const d = this.draft();
|
|
186
|
+
if (d) this.patchDraft({ languages: d.languages.map((l) => l.language_code === language.language_code ? language : l) });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
discard(): void {
|
|
190
|
+
if (window.confirm('Discard unsaved changes?')) this.draft.set(JSON.parse(this.baseline()) as AgentDraft);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
save(): void {
|
|
194
|
+
const d = this.draft();
|
|
195
|
+
if (!d) return;
|
|
196
|
+
this.act(this.studio.saveDraft(d), (saved) => {
|
|
197
|
+
this.applyDraft(saved);
|
|
198
|
+
this.successMessage.set('Draft saved.');
|
|
199
|
+
this.loadList();
|
|
200
|
+
}, d.agent_name);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setEnabled(enabled: boolean): void {
|
|
204
|
+
const d = this.draft();
|
|
205
|
+
if (!d) return;
|
|
206
|
+
this.act(this.studio.setEnabled({ agent_name: d.agent_name, enabled, expected_agent_revision: d.expected_agent_revision }), (state) => {
|
|
207
|
+
this.applyRevisions(state.expected_agent_revision, undefined, { enabled: state.enabled });
|
|
208
|
+
this.loadList();
|
|
209
|
+
}, d.agent_name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
makeDefault(): void {
|
|
213
|
+
const d = this.draft();
|
|
214
|
+
if (!d || !window.confirm(`Make "${d.display_name}" the default ${d.agent_type} agent?`)) return;
|
|
215
|
+
this.act(this.studio.setDefault({ agent_name: d.agent_name, expected_type_defaults_revision: d.expected_type_defaults_revision }), (rows) => {
|
|
216
|
+
this.summaries.set(rows);
|
|
217
|
+
this.loadAgent();
|
|
218
|
+
}, d.agent_name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setArchived(archived: boolean): void {
|
|
222
|
+
const d = this.draft();
|
|
223
|
+
if (!d || (archived && !window.confirm(`Archive "${d.display_name}"?`))) return;
|
|
224
|
+
const request = { agent_name: d.agent_name, expected_agent_revision: d.expected_agent_revision };
|
|
225
|
+
this.act(archived ? this.studio.archive(request) : this.studio.unarchive(request), (summary) => {
|
|
226
|
+
this.applyRevisions(summary.agent_revision);
|
|
227
|
+
this.loadList();
|
|
228
|
+
}, d.agent_name);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Creates an agent of the selected agent's type, copied from its saved draft. */
|
|
232
|
+
createCopy(displayName: string): void {
|
|
233
|
+
const d = this.draft();
|
|
234
|
+
if (!d || !displayName.trim()) return;
|
|
235
|
+
this.act(this.studio.create({
|
|
236
|
+
agent_type: d.agent_type, display_name: displayName.trim(), copy_from: d.agent_name, idempotency_key: crypto.randomUUID(),
|
|
237
|
+
}), (created) => this.loadList(created.agent_name));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
reset(target: PromptResetTarget): void {
|
|
241
|
+
const d = this.draft();
|
|
242
|
+
if (!d || !window.confirm('Reset these prompt sections? Publish to make it live.')) return;
|
|
243
|
+
this.act(this.studio.reset({
|
|
244
|
+
agent_name: d.agent_name, ...target,
|
|
245
|
+
expected_agent_revision: d.expected_agent_revision, expected_type_defaults_revision: d.expected_type_defaults_revision,
|
|
246
|
+
}), (draft) => this.applyDraft(draft), d.agent_name);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
saveSetting(binding: Pick<AgentSetting, 'scope_id' | 'resource_kind' | 'resource_id' | 'merge_mode' | 'sealed'>, valueText: string, remove = false): void {
|
|
250
|
+
const s = this.settings();
|
|
251
|
+
if (!s) return;
|
|
252
|
+
let value: unknown;
|
|
253
|
+
if (!remove) {
|
|
254
|
+
try {
|
|
255
|
+
value = JSON.parse(valueText);
|
|
256
|
+
} catch {
|
|
257
|
+
this.errorMessage.set('The value must be valid JSON.');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.act(this.studio.saveSetting({
|
|
262
|
+
agent_name: s.agent_name, scope_id: binding.scope_id, resource_kind: binding.resource_kind.trim(),
|
|
263
|
+
resource_id: binding.resource_id.trim(), merge_mode: binding.merge_mode, sealed: binding.sealed, value, remove,
|
|
264
|
+
expected_agent_revision: s.expected_agent_revision, expected_type_defaults_revision: s.expected_type_defaults_revision,
|
|
265
|
+
}), (saved) => {
|
|
266
|
+
this.settings.set(saved);
|
|
267
|
+
this.applyRevisions(saved.expected_agent_revision, saved.expected_type_defaults_revision);
|
|
268
|
+
}, s.agent_name);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
addSetting(scopeId: string, kind: string, id: string, mergeMode: AgentMergeMode, valueText: string): void {
|
|
272
|
+
this.saveSetting({ scope_id: scopeId, resource_kind: kind, resource_id: id, merge_mode: mergeMode, sealed: false }, valueText);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
runTest(task: string, inputData: string): void {
|
|
276
|
+
const name = this.selectedName();
|
|
277
|
+
this.testResult.set(null);
|
|
278
|
+
this.act(this.studio.testDraft({
|
|
279
|
+
agent_name: name, language_code: this.currentLanguage()?.language_code ?? '', task: task.trim(), input_data: inputData.trim(),
|
|
280
|
+
}), (result) => this.testResult.set(result), name);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
publish(): void {
|
|
284
|
+
const d = this.draft();
|
|
285
|
+
if (!d) return;
|
|
286
|
+
this.act(this.studio.publish({
|
|
287
|
+
agent_name: d.agent_name, change_summary: this.changeSummary().trim(),
|
|
288
|
+
expected_agent_revision: d.expected_agent_revision, expected_type_defaults_revision: d.expected_type_defaults_revision,
|
|
289
|
+
}), (release) => {
|
|
290
|
+
this.successMessage.set(`Version ${release.version} published${release.active ? ' and active' : ''}.`);
|
|
291
|
+
this.changeSummary.set('');
|
|
292
|
+
this.loadList();
|
|
293
|
+
this.loadAgent();
|
|
294
|
+
}, d.agent_name);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
restore(version: number): void {
|
|
298
|
+
if (!window.confirm(`Restore version ${version}? It is published again as a new version.`)) return;
|
|
299
|
+
this.act(this.studio.restore({ agent_name: this.selectedName(), version }), (release) => {
|
|
300
|
+
this.successMessage.set(`Version ${release.version} published from version ${version}.`);
|
|
301
|
+
this.loadList();
|
|
302
|
+
this.loadAgent();
|
|
303
|
+
}, this.selectedName());
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
loadHistory(): void {
|
|
307
|
+
const name = this.selectedName();
|
|
308
|
+
this.diff.set(null);
|
|
309
|
+
this.call(forkJoin([this.studio.history(name), this.studio.auditLog(name)]), ([releases, audit]) => {
|
|
310
|
+
this.releases.set(releases);
|
|
311
|
+
this.audit.set(audit);
|
|
312
|
+
}, name);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
previousVersion(version: number): number {
|
|
316
|
+
return this.releases().reduce((best, r) => r.version < version && r.version > best ? r.version : best, 0);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
compare(version: number): void {
|
|
320
|
+
const name = this.selectedName();
|
|
321
|
+
const previous = this.previousVersion(version);
|
|
322
|
+
this.act(forkJoin([this.studio.releaseSections(name, previous), this.studio.releaseSections(name, version)]),
|
|
323
|
+
([prior, current]) => this.diff.set({ version, previous, rows: diffReleaseSections(prior, current) }), name);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Runs a Studio call; a reply for an agent no longer selected is dropped. */
|
|
327
|
+
private call<T>(request: Observable<T>, done: (value: T) => void, agentName?: string): void {
|
|
328
|
+
const current = () => agentName === undefined || agentName === this.selectedName();
|
|
329
|
+
this.pending.update((n) => n + 1);
|
|
330
|
+
request.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
331
|
+
next: (value) => { if (current()) done(value); },
|
|
332
|
+
error: (err: unknown) => {
|
|
333
|
+
this.pending.update((n) => n - 1);
|
|
334
|
+
if (current()) this.errorMessage.set(studioErrorMessage(err));
|
|
335
|
+
},
|
|
336
|
+
complete: () => this.pending.update((n) => n - 1),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A user action: clears the previous outcome first. */
|
|
341
|
+
private act<T>(request: Observable<T>, done: (value: T) => void, agentName?: string): void {
|
|
342
|
+
this.errorMessage.set('');
|
|
343
|
+
this.successMessage.set('');
|
|
344
|
+
this.call(request, done, agentName);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Directive, TemplateRef, inject, input } from '@angular/core';
|
|
2
|
+
import { AgentSummary } from '../model/agent_studio';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A product tab of <sail-studio-page>:
|
|
6
|
+
* `<ng-template sailStudioTab="governance" label="Governance" let-agent>…</ng-template>`.
|
|
7
|
+
* The template's context is the selected agent's summary.
|
|
8
|
+
*/
|
|
9
|
+
@Directive({ selector: 'ng-template[sailStudioTab]' })
|
|
10
|
+
export class StudioTabDirective {
|
|
11
|
+
readonly id = input.required<string>({ alias: 'sailStudioTab' });
|
|
12
|
+
readonly label = input.required<string>();
|
|
13
|
+
readonly template = inject<TemplateRef<{ $implicit: AgentSummary }>>(TemplateRef);
|
|
14
|
+
}
|
|
@@ -89,6 +89,15 @@
|
|
|
89
89
|
}
|
|
90
90
|
</li>
|
|
91
91
|
}
|
|
92
|
+
@case ('citations') {
|
|
93
|
+
<li class="turn-citations">
|
|
94
|
+
<ol>
|
|
95
|
+
@for (citation of item.citations; track citation.position) {
|
|
96
|
+
<li><a [href]="citation.url" rel="noopener noreferrer nofollow" target="_blank">{{ citation.title || citation.domain || citation.url }}</a></li>
|
|
97
|
+
}
|
|
98
|
+
</ol>
|
|
99
|
+
</li>
|
|
100
|
+
}
|
|
92
101
|
@case ('extension') {
|
|
93
102
|
@if (extensionTemplate(); as template) {
|
|
94
103
|
<li class="turn-extension">
|
package/src/agent/index.ts
CHANGED
|
@@ -3,15 +3,21 @@ export * from './model/turn_event';
|
|
|
3
3
|
export * from './model/reply_frame';
|
|
4
4
|
export * from './model/decision';
|
|
5
5
|
export * from './model/conversation';
|
|
6
|
+
export * from './model/approval';
|
|
6
7
|
export * from './service/agent_studio.service';
|
|
8
|
+
export * from './service/approval.service';
|
|
7
9
|
export * from './service/turn_stream';
|
|
8
10
|
export * from './service/conversation.service';
|
|
9
11
|
export * from './util/studio_errors';
|
|
10
12
|
export * from './util/turn_timeline';
|
|
11
13
|
export * from './util/sse';
|
|
14
|
+
export * from './util/release_diff';
|
|
12
15
|
export * from './component/prompt_section_editor';
|
|
13
16
|
export * from './component/prompt_language_editor';
|
|
14
17
|
export * from './component/release_sections';
|
|
15
18
|
export * from './component/turn_event_list';
|
|
16
19
|
export * from './component/turn_stream_view';
|
|
17
20
|
export * from './component/decision_timeline';
|
|
21
|
+
export * from './component/studio_tab';
|
|
22
|
+
export * from './component/studio_page';
|
|
23
|
+
export * from './component/approval_inbox';
|