@nauticana/sail 1.1.33 → 1.1.35
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 +36 -23
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +110 -0
- package/src/agent/component/approval_inbox.ts +129 -0
- package/src/agent/component/assistant_activity.html +28 -0
- package/src/agent/component/assistant_activity.ts +40 -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 +303 -0
- package/src/agent/component/studio_page.ts +403 -0
- package/src/agent/component/studio_tab.ts +14 -0
- package/src/agent/component/turn_event_list.html +9 -0
- package/src/agent/component/turn_thread.html +27 -0
- package/src/agent/component/turn_thread.ts +83 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/model/agent_studio.ts +185 -9
- package/src/agent/model/approval.ts +78 -0
- package/src/agent/model/conversation.ts +40 -1
- package/src/agent/model/decision.ts +14 -9
- package/src/agent/model/turn_event.ts +10 -0
- package/src/agent/service/agent_studio.service.ts +65 -1
- package/src/agent/service/approval.service.ts +38 -0
- package/src/agent/service/conversation.service.ts +10 -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/agency/agency_earnings_summary.html +3 -3
- package/src/component/agency/agency_earnings_summary.ts +2 -2
- package/src/component/agency/agency_payout_history.html +1 -1
- package/src/component/agency/agency_payout_history.ts +2 -3
- package/src/component/agency/commission_ledger.html +1 -1
- package/src/component/agency/commission_ledger.ts +2 -3
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/billing/price_selector.ts +5 -5
- 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/component/payout/bank_info_form.html +15 -6
- package/src/component/payout/bank_info_form.ts +28 -72
- package/src/component/security/api_keys.html +78 -0
- package/src/component/security/api_keys.ts +90 -0
- package/src/component/security/connected_apps.html +25 -0
- package/src/component/security/connected_apps.ts +40 -0
- package/src/index.ts +8 -0
- package/src/model/access.ts +28 -0
- package/src/model/appdata.ts +41 -67
- package/src/model/auth.ts +17 -2
- package/src/model/geo.ts +12 -0
- package/src/service/access.service.ts +57 -0
- package/src/service/auth.service.ts +10 -4
- package/src/service/caption_pipe.ts +32 -0
- package/src/service/currency.service.ts +51 -0
- package/src/service/label.service.ts +5 -4
- package/src/service/money_pipe.ts +27 -0
- package/src/service/rest_service.ts +1 -1
- package/src/service/rest_url.ts +4 -0
- package/src/util/money.ts +18 -34
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
AGENT_READINESS_CAPTIONS, AgentAuditEvent, AgentDraft, AgentGuardrails, AgentLanguageDraft, AgentMergeMode,
|
|
17
|
+
AgentModelSelection, AgentRelease, AgentSetting, AgentSettings, AgentSummary, AgentTestResult, GuardrailRule,
|
|
18
|
+
SafetyEvent, StudioFeature, StudioModel,
|
|
19
|
+
} from '../model/agent_studio';
|
|
20
|
+
import { AgentStudioService } from '../service/agent_studio.service';
|
|
21
|
+
import { ReleaseSectionDiff, diffReleaseSections } from '../util/release_diff';
|
|
22
|
+
import { studioErrorMessage } from '../util/studio_errors';
|
|
23
|
+
import { PromptLanguageEditorComponent, PromptResetTarget } from './prompt_language_editor';
|
|
24
|
+
import { StudioTabDirective } from './studio_tab';
|
|
25
|
+
|
|
26
|
+
const CORE_TABS = [
|
|
27
|
+
{ id: 'overview', label: 'Overview' },
|
|
28
|
+
{ id: 'prompts', label: 'Prompts' },
|
|
29
|
+
{ id: 'settings', label: 'Settings' },
|
|
30
|
+
{ id: 'test', label: 'Test' },
|
|
31
|
+
{ id: 'history', label: 'History' },
|
|
32
|
+
];
|
|
33
|
+
const GUARDRAILS_TAB = { id: 'guardrails', label: 'Guardrails' };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Embeddable Agent Studio over Scout's core `agents` feature: a grouped agent rail and tabs
|
|
37
|
+
* for the draft, prompts, scoped settings, tests, and release history, a Guardrails tab when the
|
|
38
|
+
* manifest lists that feature, plus product tabs declared with `sailStudioTab`. A control shows only when the feature manifest lists its
|
|
39
|
+
* action and the caller holds the grant; hiding is never the authorization, Scout decides.
|
|
40
|
+
* Selector: <sail-studio-page>.
|
|
41
|
+
*/
|
|
42
|
+
@Component({
|
|
43
|
+
selector: 'sail-studio-page',
|
|
44
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
45
|
+
imports: [
|
|
46
|
+
DatePipe, JsonPipe, NgTemplateOutlet, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
47
|
+
MatSlideToggleModule, CaptionPipe, PromptLanguageEditorComponent,
|
|
48
|
+
],
|
|
49
|
+
templateUrl: './studio_page.html',
|
|
50
|
+
})
|
|
51
|
+
export class StudioPageComponent implements OnInit {
|
|
52
|
+
private readonly studio = inject(AgentStudioService);
|
|
53
|
+
private readonly auth = inject(BaseAuthService);
|
|
54
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
55
|
+
private readonly labels = inject(LabelService);
|
|
56
|
+
|
|
57
|
+
/** Rail group of an agent; its type by default. */
|
|
58
|
+
readonly groupBy = input<(agent: AgentSummary) => string>((agent) => agent.agent_type);
|
|
59
|
+
private readonly productTabs = contentChildren(StudioTabDirective);
|
|
60
|
+
|
|
61
|
+
readonly features = signal<StudioFeature[]>([]);
|
|
62
|
+
readonly models = signal<StudioModel[]>([]);
|
|
63
|
+
readonly summaries = signal<AgentSummary[]>([]);
|
|
64
|
+
readonly showArchived = signal(false);
|
|
65
|
+
readonly selectedName = signal('');
|
|
66
|
+
readonly draft = signal<AgentDraft | null>(null);
|
|
67
|
+
private readonly baseline = signal('');
|
|
68
|
+
readonly settings = signal<AgentSettings | null>(null);
|
|
69
|
+
readonly releases = signal<AgentRelease[]>([]);
|
|
70
|
+
readonly audit = signal<AgentAuditEvent[]>([]);
|
|
71
|
+
readonly guardrails = signal<AgentGuardrails | null>(null);
|
|
72
|
+
readonly safetyEvents = signal<SafetyEvent[]>([]);
|
|
73
|
+
readonly safetyExhausted = signal(true);
|
|
74
|
+
readonly diff = signal<{ version: number; previous: number; rows: ReleaseSectionDiff[] } | null>(null);
|
|
75
|
+
readonly testResult = signal<AgentTestResult | null>(null);
|
|
76
|
+
readonly tab = signal('overview');
|
|
77
|
+
readonly language = signal('');
|
|
78
|
+
readonly promptsValid = signal(true);
|
|
79
|
+
readonly changeSummary = signal('');
|
|
80
|
+
readonly mergeModes = toSignal(this.labels.loadLabels('config_merge_mode').pipe(
|
|
81
|
+
map(() => this.labels.options('config_merge_mode'))), { initialValue: [] as LabelOption[] });
|
|
82
|
+
readonly errorMessage = signal('');
|
|
83
|
+
readonly successMessage = signal('');
|
|
84
|
+
private readonly pending = signal(0);
|
|
85
|
+
readonly busy = computed(() => this.pending() > 0);
|
|
86
|
+
|
|
87
|
+
readonly tabs = computed(() => [
|
|
88
|
+
...CORE_TABS, ...(this.feature('guardrails') ? [GUARDRAILS_TAB] : []),
|
|
89
|
+
...this.productTabs().map((t) => ({ id: t.id(), label: t.label() })),
|
|
90
|
+
]);
|
|
91
|
+
readonly productTab = computed(() => this.productTabs().find((t) => t.id() === this.tab()));
|
|
92
|
+
readonly selected = computed(() => this.summaries().find((a) => a.agent_name === this.selectedName()));
|
|
93
|
+
readonly dirty = computed(() => this.draft() !== null && JSON.stringify(this.draft()) !== this.baseline());
|
|
94
|
+
readonly currentLanguage = computed<AgentLanguageDraft | undefined>(() => {
|
|
95
|
+
const languages = this.draft()?.languages ?? [];
|
|
96
|
+
return languages.find((l) => l.language_code === this.language()) ?? languages[0];
|
|
97
|
+
});
|
|
98
|
+
readonly groups = computed(() => {
|
|
99
|
+
const groupOf = this.groupBy();
|
|
100
|
+
const groups = new Map<string, AgentSummary[]>();
|
|
101
|
+
for (const agent of this.summaries()) {
|
|
102
|
+
if (agent.archived && !this.showArchived()) continue;
|
|
103
|
+
const key = groupOf(agent);
|
|
104
|
+
groups.set(key, [...(groups.get(key) ?? []), agent]);
|
|
105
|
+
}
|
|
106
|
+
return [...groups].map(([name, agents]) => ({ name, agents }));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
ngOnInit(): void {
|
|
110
|
+
this.call(this.studio.features(), (features) => this.features.set(features));
|
|
111
|
+
this.call(this.studio.models(), (models) => this.models.set(models));
|
|
112
|
+
this.loadList();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** For a canDeactivate guard. */
|
|
116
|
+
hasUnsavedChanges(): boolean {
|
|
117
|
+
return this.dirty();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether `feature` serves `action` and the caller holds it at `value` (the selected agent by default). */
|
|
121
|
+
can(action: string, value = `agent:${this.selectedName()}`, feature = 'agents'): boolean {
|
|
122
|
+
const f = this.feature(feature);
|
|
123
|
+
return !!f?.actions.includes(action) && this.auth.hasPermission(f.object || 'AGENT_STUDIO', action, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private feature(id: string): StudioFeature | undefined {
|
|
127
|
+
return this.features().find((f) => f.id === id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
readinessCaption(code: string): string {
|
|
131
|
+
return AGENT_READINESS_CAPTIONS[code] ?? code;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
modelsOf(type: string): StudioModel[] {
|
|
135
|
+
return this.models().filter((m) => m.model_type === type);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
loadList(select?: string): void {
|
|
139
|
+
this.call(this.studio.listAgents(), (rows) => {
|
|
140
|
+
this.summaries.set(rows);
|
|
141
|
+
const target = select ?? (this.selectedName() || rows.find((a) => !a.archived)?.agent_name);
|
|
142
|
+
if (target && target !== this.selectedName()) this.select(target, true);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
select(agentName: string, force = false): void {
|
|
147
|
+
if (!force && agentName === this.selectedName()) return;
|
|
148
|
+
if (!force && this.dirty() && !window.confirm('Discard unsaved changes for this agent?')) return;
|
|
149
|
+
this.selectedName.set(agentName);
|
|
150
|
+
this.draft.set(null);
|
|
151
|
+
this.settings.set(null);
|
|
152
|
+
this.releases.set([]);
|
|
153
|
+
this.audit.set([]);
|
|
154
|
+
this.guardrails.set(null);
|
|
155
|
+
this.safetyEvents.set([]);
|
|
156
|
+
this.diff.set(null);
|
|
157
|
+
this.testResult.set(null);
|
|
158
|
+
this.errorMessage.set('');
|
|
159
|
+
this.successMessage.set('');
|
|
160
|
+
this.loadAgent();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
openTab(id: string): void {
|
|
164
|
+
this.tab.set(id);
|
|
165
|
+
if (id === 'history') this.loadHistory();
|
|
166
|
+
if (id === 'guardrails') this.loadGuardrails();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private loadAgent(): void {
|
|
170
|
+
const name = this.selectedName();
|
|
171
|
+
this.call(this.studio.getDraft(name), (draft) => this.applyDraft(draft), name);
|
|
172
|
+
this.call(this.studio.settings(name), (settings) => this.settings.set(settings), name);
|
|
173
|
+
if (this.tab() === 'history') this.loadHistory();
|
|
174
|
+
if (this.tab() === 'guardrails') this.loadGuardrails();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private applyDraft(draft: AgentDraft): void {
|
|
178
|
+
this.draft.set(draft);
|
|
179
|
+
this.baseline.set(JSON.stringify(draft));
|
|
180
|
+
this.promptsValid.set(true);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Carries new revisions into the draft and its baseline without touching unsaved edits. */
|
|
184
|
+
private applyRevisions(agentRevision: number, typeRevision?: number, patch: Partial<AgentDraft> = {}): void {
|
|
185
|
+
const revisions: Partial<AgentDraft> = { ...patch, expected_agent_revision: agentRevision };
|
|
186
|
+
if (typeRevision !== undefined) revisions.expected_type_defaults_revision = typeRevision;
|
|
187
|
+
const d = this.draft();
|
|
188
|
+
if (!d) return;
|
|
189
|
+
this.draft.set({ ...d, ...revisions });
|
|
190
|
+
this.baseline.set(JSON.stringify({ ...(JSON.parse(this.baseline()) as AgentDraft), ...revisions }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
patchDraft(changes: Partial<AgentDraft>): void {
|
|
194
|
+
const d = this.draft();
|
|
195
|
+
if (d) this.draft.set({ ...d, ...changes });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
patchModel(field: keyof AgentModelSelection, value: string): void {
|
|
199
|
+
const d = this.draft();
|
|
200
|
+
if (d) this.patchDraft({ models: { ...d.models, [field]: value } });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
updateLanguage(language: AgentLanguageDraft): void {
|
|
204
|
+
const d = this.draft();
|
|
205
|
+
if (d) this.patchDraft({ languages: d.languages.map((l) => l.language_code === language.language_code ? language : l) });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
discard(): void {
|
|
209
|
+
if (window.confirm('Discard unsaved changes?')) this.draft.set(JSON.parse(this.baseline()) as AgentDraft);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
save(): void {
|
|
213
|
+
const d = this.draft();
|
|
214
|
+
if (!d) return;
|
|
215
|
+
this.act(this.studio.saveDraft(d), (saved) => {
|
|
216
|
+
this.applyDraft(saved);
|
|
217
|
+
this.successMessage.set('Draft saved.');
|
|
218
|
+
this.loadList();
|
|
219
|
+
}, d.agent_name);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setEnabled(enabled: boolean): void {
|
|
223
|
+
const d = this.draft();
|
|
224
|
+
if (!d) return;
|
|
225
|
+
this.act(this.studio.setEnabled({ agent_name: d.agent_name, enabled, expected_agent_revision: d.expected_agent_revision }), (state) => {
|
|
226
|
+
this.applyRevisions(state.expected_agent_revision, undefined, { enabled: state.enabled });
|
|
227
|
+
this.loadList();
|
|
228
|
+
}, d.agent_name);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
makeDefault(): void {
|
|
232
|
+
const d = this.draft();
|
|
233
|
+
if (!d || !window.confirm(`Make "${d.display_name}" the default ${d.agent_type} agent?`)) return;
|
|
234
|
+
this.act(this.studio.setDefault({ agent_name: d.agent_name, expected_type_defaults_revision: d.expected_type_defaults_revision }), (rows) => {
|
|
235
|
+
this.summaries.set(rows);
|
|
236
|
+
this.loadAgent();
|
|
237
|
+
}, d.agent_name);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
setArchived(archived: boolean): void {
|
|
241
|
+
const d = this.draft();
|
|
242
|
+
if (!d || (archived && !window.confirm(`Archive "${d.display_name}"?`))) return;
|
|
243
|
+
const request = { agent_name: d.agent_name, expected_agent_revision: d.expected_agent_revision };
|
|
244
|
+
this.act(archived ? this.studio.archive(request) : this.studio.unarchive(request), (summary) => {
|
|
245
|
+
this.applyRevisions(summary.agent_revision);
|
|
246
|
+
this.loadList();
|
|
247
|
+
}, d.agent_name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Creates an agent of the selected agent's type, copied from its saved draft. */
|
|
251
|
+
createCopy(displayName: string): void {
|
|
252
|
+
const d = this.draft();
|
|
253
|
+
if (!d || !displayName.trim()) return;
|
|
254
|
+
this.act(this.studio.create({
|
|
255
|
+
agent_type: d.agent_type, display_name: displayName.trim(), copy_from: d.agent_name, idempotency_key: crypto.randomUUID(),
|
|
256
|
+
}), (created) => this.loadList(created.agent_name));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
reset(target: PromptResetTarget): void {
|
|
260
|
+
const d = this.draft();
|
|
261
|
+
if (!d || !window.confirm('Reset these prompt sections? Publish to make it live.')) return;
|
|
262
|
+
this.act(this.studio.reset({
|
|
263
|
+
agent_name: d.agent_name, ...target,
|
|
264
|
+
expected_agent_revision: d.expected_agent_revision, expected_type_defaults_revision: d.expected_type_defaults_revision,
|
|
265
|
+
}), (draft) => this.applyDraft(draft), d.agent_name);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
saveSetting(binding: Pick<AgentSetting, 'scope_id' | 'resource_kind' | 'resource_id' | 'merge_mode' | 'sealed'>, valueText: string, remove = false): void {
|
|
269
|
+
const s = this.settings();
|
|
270
|
+
if (!s) return;
|
|
271
|
+
let value: unknown;
|
|
272
|
+
if (!remove) {
|
|
273
|
+
try {
|
|
274
|
+
value = JSON.parse(valueText);
|
|
275
|
+
} catch {
|
|
276
|
+
this.errorMessage.set('The value must be valid JSON.');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.act(this.studio.saveSetting({
|
|
281
|
+
agent_name: s.agent_name, scope_id: binding.scope_id, resource_kind: binding.resource_kind.trim(),
|
|
282
|
+
resource_id: binding.resource_id.trim(), merge_mode: binding.merge_mode, sealed: binding.sealed, value, remove,
|
|
283
|
+
expected_agent_revision: s.expected_agent_revision, expected_type_defaults_revision: s.expected_type_defaults_revision,
|
|
284
|
+
}), (saved) => {
|
|
285
|
+
this.settings.set(saved);
|
|
286
|
+
this.applyRevisions(saved.expected_agent_revision, saved.expected_type_defaults_revision);
|
|
287
|
+
}, s.agent_name);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
addSetting(scopeId: string, kind: string, id: string, mergeMode: AgentMergeMode, valueText: string): void {
|
|
291
|
+
this.saveSetting({ scope_id: scopeId, resource_kind: kind, resource_id: id, merge_mode: mergeMode, sealed: false }, valueText);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
runTest(task: string, inputData: string): void {
|
|
295
|
+
const name = this.selectedName();
|
|
296
|
+
this.testResult.set(null);
|
|
297
|
+
this.act(this.studio.testDraft({
|
|
298
|
+
agent_name: name, language_code: this.currentLanguage()?.language_code ?? '', task: task.trim(), input_data: inputData.trim(),
|
|
299
|
+
}), (result) => this.testResult.set(result), name);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
publish(): void {
|
|
303
|
+
const d = this.draft();
|
|
304
|
+
if (!d) return;
|
|
305
|
+
this.act(this.studio.publish({
|
|
306
|
+
agent_name: d.agent_name, change_summary: this.changeSummary().trim(),
|
|
307
|
+
expected_agent_revision: d.expected_agent_revision, expected_type_defaults_revision: d.expected_type_defaults_revision,
|
|
308
|
+
}), (release) => {
|
|
309
|
+
this.successMessage.set(`Version ${release.version} published${release.active ? ' and active' : ''}.`);
|
|
310
|
+
this.changeSummary.set('');
|
|
311
|
+
this.loadList();
|
|
312
|
+
this.loadAgent();
|
|
313
|
+
}, d.agent_name);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
restore(version: number): void {
|
|
317
|
+
if (!window.confirm(`Restore version ${version}? It is published again as a new version.`)) return;
|
|
318
|
+
this.act(this.studio.restore({ agent_name: this.selectedName(), version }), (release) => {
|
|
319
|
+
this.successMessage.set(`Version ${release.version} published from version ${version}.`);
|
|
320
|
+
this.loadList();
|
|
321
|
+
this.loadAgent();
|
|
322
|
+
}, this.selectedName());
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
loadHistory(): void {
|
|
326
|
+
const name = this.selectedName();
|
|
327
|
+
this.diff.set(null);
|
|
328
|
+
this.call(forkJoin([this.studio.history(name), this.studio.auditLog(name)]), ([releases, audit]) => {
|
|
329
|
+
this.releases.set(releases);
|
|
330
|
+
this.audit.set(audit);
|
|
331
|
+
}, name);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
loadGuardrails(): void {
|
|
335
|
+
const name = this.selectedName();
|
|
336
|
+
this.safetyEvents.set([]);
|
|
337
|
+
this.call(this.studio.guardrails(name), (g) => this.guardrails.set(g), name);
|
|
338
|
+
this.loadSafetyEvents();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
loadSafetyEvents(): void {
|
|
342
|
+
const name = this.selectedName();
|
|
343
|
+
const before = this.safetyEvents().at(-1)?.id ?? 0;
|
|
344
|
+
this.call(this.studio.safetyEvents(name, before), (events) => {
|
|
345
|
+
this.safetyEvents.update((rows) => [...rows, ...events]);
|
|
346
|
+
this.safetyExhausted.set(events.length < 50);
|
|
347
|
+
}, name);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Replaces the agent's own rules with the JSON array typed in. */
|
|
351
|
+
saveGuardrails(rulesText: string): void {
|
|
352
|
+
const g = this.guardrails();
|
|
353
|
+
if (!g) return;
|
|
354
|
+
let rules: GuardrailRule[];
|
|
355
|
+
try {
|
|
356
|
+
rules = JSON.parse(rulesText.trim() || '[]') as GuardrailRule[];
|
|
357
|
+
} catch {
|
|
358
|
+
this.errorMessage.set('The rules must be a JSON array.');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!Array.isArray(rules)) {
|
|
362
|
+
this.errorMessage.set('The rules must be a JSON array.');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.act(this.studio.saveGuardrails({ agent_name: g.agent_name, rules, expected_agent_revision: g.expected_agent_revision }), (saved) => {
|
|
366
|
+
this.guardrails.set(saved);
|
|
367
|
+
this.applyRevisions(saved.expected_agent_revision);
|
|
368
|
+
this.successMessage.set('Guardrails saved.');
|
|
369
|
+
}, g.agent_name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
previousVersion(version: number): number {
|
|
373
|
+
return this.releases().reduce((best, r) => r.version < version && r.version > best ? r.version : best, 0);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
compare(version: number): void {
|
|
377
|
+
const name = this.selectedName();
|
|
378
|
+
const previous = this.previousVersion(version);
|
|
379
|
+
this.act(forkJoin([this.studio.releaseSections(name, previous), this.studio.releaseSections(name, version)]),
|
|
380
|
+
([prior, current]) => this.diff.set({ version, previous, rows: diffReleaseSections(prior, current) }), name);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Runs a Studio call; a reply for an agent no longer selected is dropped. */
|
|
384
|
+
private call<T>(request: Observable<T>, done: (value: T) => void, agentName?: string): void {
|
|
385
|
+
const current = () => agentName === undefined || agentName === this.selectedName();
|
|
386
|
+
this.pending.update((n) => n + 1);
|
|
387
|
+
request.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
388
|
+
next: (value) => { if (current()) done(value); },
|
|
389
|
+
error: (err: unknown) => {
|
|
390
|
+
this.pending.update((n) => n - 1);
|
|
391
|
+
if (current()) this.errorMessage.set(studioErrorMessage(err));
|
|
392
|
+
},
|
|
393
|
+
complete: () => this.pending.update((n) => n - 1),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A user action: clears the previous outcome first. */
|
|
398
|
+
private act<T>(request: Observable<T>, done: (value: T) => void, agentName?: string): void {
|
|
399
|
+
this.errorMessage.set('');
|
|
400
|
+
this.successMessage.set('');
|
|
401
|
+
this.call(request, done, agentName);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -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">
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<div class="turn-thread">
|
|
2
|
+
@for (turn of turns(); track turn.request_id) {
|
|
3
|
+
<article [class]="'turn-thread__turn turn-thread__turn--' + turn.status">
|
|
4
|
+
<header class="turn-thread__head">
|
|
5
|
+
<span class="turn-thread__title">{{ label(turn.task_kind) }} · {{ turn.input_summary }}</span>
|
|
6
|
+
<span class="turn-thread__status">{{ turn.status | sailCaption:'turn_status' }}</span>
|
|
7
|
+
</header>
|
|
8
|
+
<p class="turn-thread__meta">
|
|
9
|
+
{{ turn.created_at | date: 'medium' }}
|
|
10
|
+
@if (turn.cost_minor !== undefined && turn.currency && turn.exponent !== undefined) { · {{ turn.cost_minor | sailMoney:turn.currency:turn.exponent }} }
|
|
11
|
+
@if (turn.release_version) { · {{ turn.agent_name }} v{{ turn.release_version }} }
|
|
12
|
+
</p>
|
|
13
|
+
@if (turn.status === 'F' && turn.error_text) {
|
|
14
|
+
<p class="turn-thread__error" role="alert">{{ turn.error_text }}</p>
|
|
15
|
+
}
|
|
16
|
+
@if (resultTemplate(turn.task_kind); as template) {
|
|
17
|
+
<ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: turn }" />
|
|
18
|
+
}
|
|
19
|
+
</article>
|
|
20
|
+
} @empty {
|
|
21
|
+
@if (!loading()) { <p class="turn-thread__empty">{{ emptyMessage() }}</p> }
|
|
22
|
+
}
|
|
23
|
+
@if (nextBefore()) {
|
|
24
|
+
<button matButton type="button" [disabled]="loading()" (click)="more()">Load more</button>
|
|
25
|
+
}
|
|
26
|
+
@if (errorMessage()) { <p class="turn-thread__error" role="alert">{{ errorMessage() }}</p> }
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DatePipe, NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy, Component, DestroyRef, Directive, OnInit, TemplateRef, contentChildren, inject, input, signal,
|
|
4
|
+
} from '@angular/core';
|
|
5
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
6
|
+
import { BaseAsync } from '../../component/abstract/base_async';
|
|
7
|
+
import { CaptionPipe } from '../../service/caption_pipe';
|
|
8
|
+
import { MoneyPipe } from '../../service/money_pipe';
|
|
9
|
+
import { ConversationTurn, TERMINAL_TURN_STATUSES } from '../model/conversation';
|
|
10
|
+
import { ConversationService } from '../service/conversation.service';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The result card of one task kind: `<ng-template sailTurnResult="kind" let-turn>…</ng-template>`.
|
|
14
|
+
* The template's context is the turn.
|
|
15
|
+
*/
|
|
16
|
+
@Directive({ selector: 'ng-template[sailTurnResult]' })
|
|
17
|
+
export class TurnResultDirective {
|
|
18
|
+
readonly kind = input.required<string>({ alias: 'sailTurnResult' });
|
|
19
|
+
readonly template = inject<TemplateRef<{ $implicit: ConversationTurn }>>(TemplateRef);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The signed-in person's turns from `GET {base}turns`, newest first, with status captions, cost
|
|
24
|
+
* and release version, paged by `next_before` and reloaded every `pollMs` while a turn is
|
|
25
|
+
* nonterminal. Selector: <sail-turn-thread>.
|
|
26
|
+
*/
|
|
27
|
+
@Component({
|
|
28
|
+
selector: 'sail-turn-thread',
|
|
29
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
30
|
+
imports: [DatePipe, NgTemplateOutlet, MatButtonModule, CaptionPipe, MoneyPipe],
|
|
31
|
+
templateUrl: './turn_thread.html',
|
|
32
|
+
})
|
|
33
|
+
export class TurnThreadComponent extends BaseAsync implements OnInit {
|
|
34
|
+
private readonly conversation = inject(ConversationService);
|
|
35
|
+
|
|
36
|
+
/** Only turns that served this work item; all of the person's turns when empty. */
|
|
37
|
+
readonly workRef = input('');
|
|
38
|
+
/** `task_kind` → label; the kind itself otherwise. */
|
|
39
|
+
readonly kindLabels = input<Record<string, string>>({});
|
|
40
|
+
readonly pollMs = input(10000);
|
|
41
|
+
readonly emptyMessage = input('Nothing yet.');
|
|
42
|
+
|
|
43
|
+
readonly turns = signal<ConversationTurn[]>([]);
|
|
44
|
+
readonly nextBefore = signal('');
|
|
45
|
+
private readonly results = contentChildren(TurnResultDirective);
|
|
46
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
|
|
48
|
+
constructor() {
|
|
49
|
+
super();
|
|
50
|
+
inject(DestroyRef).onDestroy(() => clearTimeout(this.timer));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ngOnInit(): void {
|
|
54
|
+
this.load();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reloads the first page; the pages below it are dropped. */
|
|
58
|
+
load(): void {
|
|
59
|
+
clearTimeout(this.timer);
|
|
60
|
+
this.run(this.conversation.turns(this.workRef()), (page) => {
|
|
61
|
+
this.turns.set(page.turns);
|
|
62
|
+
this.nextBefore.set(page.next_before ?? '');
|
|
63
|
+
if (page.turns.some((t) => !TERMINAL_TURN_STATUSES.has(t.status))) {
|
|
64
|
+
this.timer = setTimeout(() => this.load(), Math.max(1000, this.pollMs()));
|
|
65
|
+
}
|
|
66
|
+
}, 'Failed to load the turns.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
more(): void {
|
|
70
|
+
this.run(this.conversation.turns(this.workRef(), this.nextBefore()), (page) => {
|
|
71
|
+
this.turns.update((rows) => [...rows, ...page.turns]);
|
|
72
|
+
this.nextBefore.set(page.next_before ?? '');
|
|
73
|
+
}, 'Failed to load more turns.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
label(kind: string): string {
|
|
77
|
+
return this.kindLabels()[kind] ?? kind;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
resultTemplate(kind: string): TemplateRef<{ $implicit: ConversationTurn }> | undefined {
|
|
81
|
+
return this.results().find((r) => r.kind() === kind)?.template;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/agent/index.ts
CHANGED
|
@@ -3,15 +3,23 @@ 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';
|
|
24
|
+
export * from './component/assistant_activity';
|
|
25
|
+
export * from './component/turn_thread';
|