Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,26 @@ <h2 mat-dialog-title>{{ data.title }}</h2>

<!-- Advanced section (admin only, dev mode) -->
<div *ngIf="data.isAdmin" class="advanced-section">
<button mat-button (click)="toggleAdvanced()" class="advanced-toggle">
<mat-icon>{{ showAdvanced() ? 'expand_less' : 'expand_more' }}</mat-icon>
Advanced
</button>
<div class="advanced-header">
<button mat-button (click)="toggleAdvanced()" class="advanced-toggle">
<mat-icon>{{ showAdvanced() ? 'expand_less' : 'expand_more' }}</mat-icon>
Advanced
</button>
<button *ngIf="showAdvanced()" mat-button (click)="resetToDefault()"
class="reset-button" [disabled]="loadingPrompt()">
<mat-icon>restart_alt</mat-icon>
Reset to Default
</button>
</div>
<div *ngIf="showAdvanced()" class="advanced-content">
<div class="prompt-label">System Prompt (changes apply to this session only)</div>
Comment thread
UDtorrey marked this conversation as resolved.
<mat-progress-bar *ngIf="loadingPrompt()" mode="indeterminate" class="prompt-loading"></mat-progress-bar>
<textarea
[(ngModel)]="editableSystemPrompt" name="systemPrompt"
[ngModel]="editableSystemPrompt" (ngModelChange)="onSystemPromptChange($event)"
name="systemPrompt"
class="system-prompt-editor"
placeholder="System prompt (editable in dev mode)"
rows="6"></textarea>
placeholder="Loading default system prompt..."
rows="12"></textarea>
</div>
</div>

Expand Down Expand Up @@ -64,7 +74,7 @@ <h2 mat-dialog-title>{{ data.title }}</h2>
placeholder="Ask a follow-up question..."
[disabled]="streaming()"
class="follow-up-input" />
<button mat-icon-button (click)="sendFollowUp()" [disabled]="streaming() || !followUpText">
<button mat-icon-button (click)="sendFollowUp()" [disabled]="streaming() || !followUpText.trim()">
<mat-icon>send</mat-icon>
</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,36 @@
.advanced-section {
margin-bottom: 12px;

.advanced-header {
display: flex;
align-items: center;
gap: 8px;
}

.advanced-toggle {
font-size: 12px;
color: var(--ud-text-secondary);
}

.reset-button {
font-size: 12px;
color: var(--ud-text-secondary);
}

.advanced-content {
margin-top: 8px;
}

.prompt-label {
font-size: 11px;
color: var(--ud-text-secondary);
margin-bottom: 4px;
}

.prompt-loading {
margin-bottom: 4px;
}

.system-prompt-editor {
width: 100%;
font-family: monospace;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export class AiAnalysisModalComponent implements OnInit {
followUpText = '';
showAdvanced = signal(false);
editableSystemPrompt = '';
defaultSystemPrompt = '';
loadingPrompt = signal(false);

streaming = computed(() => this.aiService.streaming());
streamedText = computed(() => this.aiService.streamedText());
Expand Down Expand Up @@ -144,10 +146,48 @@ export class AiAnalysisModalComponent implements OnInit {
}
}

toggleAdvanced() {
async toggleAdvanced() {
this.showAdvanced.update(v => !v);
if (this.showAdvanced() && !this.editableSystemPrompt) {
this.editableSystemPrompt = this.data.systemPrompt ?? '';
const storageKey = `ai-prompt-${this.data.page}-${this.data.promptType}`;
const saved = localStorage.getItem(storageKey);
if (saved) {
this.editableSystemPrompt = saved;
}
Comment on lines +152 to +156

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct localStorage access can throw (e.g., blocked storage / privacy mode), which would break the modal when toggling Advanced or editing the prompt. Since this is optional UX, it should fail closed.

Wrap the getItem/setItem/removeItem calls in a try/catch (or behind a small helper) so prompt editing still works even if persistence is unavailable.

Copilot uses AI. Check for mistakes.
await this.fetchDefaultPrompt();
if (!saved && this.defaultSystemPrompt) {
this.editableSystemPrompt = this.defaultSystemPrompt;
}
}
}

async resetToDefault() {
await this.fetchDefaultPrompt();
this.editableSystemPrompt = this.defaultSystemPrompt;
const storageKey = `ai-prompt-${this.data.page}-${this.data.promptType}`;
localStorage.removeItem(storageKey);
}

private async fetchDefaultPrompt() {
if (this.defaultSystemPrompt) return;
this.loadingPrompt.set(true);
try {
this.defaultSystemPrompt = await this.aiService.getDefaultPrompt(
this.data.page, this.data.promptType);
} catch {
// Fall back silently — editor still works with manual input
} finally {
this.loadingPrompt.set(false);
}
}

onSystemPromptChange(value: string) {
this.editableSystemPrompt = value;
const storageKey = `ai-prompt-${this.data.page}-${this.data.promptType}`;
if (value && value !== this.defaultSystemPrompt) {
localStorage.setItem(storageKey, value);
} else {
localStorage.removeItem(storageKey);
}
}
}
23 changes: 23 additions & 0 deletions console-webapp/src/app/registry-dash/ai/ai-analysis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ export class AiAnalysisService {
streamedText = signal('');
error = signal<string | null>(null);

async getDefaultPrompt(page: string, promptType: string): Promise<string> {
const response = await fetch(
`/console-api/registry-dash/ai/analyze?page=${encodeURIComponent(page)}&promptType=${encodeURIComponent(promptType)}`,
{ credentials: 'same-origin' },
);
if (!response.ok) {
throw new Error(`Failed to fetch default prompt: ${response.status}`);
}
const data = await response.json();
return data.systemPrompt;
}

async analyze(request: AiAnalyzeRequest): Promise<void> {
this.streaming.set(true);
this.streamedText.set('');
Expand All @@ -46,6 +58,17 @@ export class AiAnalysisService {
return;
}
if (response.status === 502) {
try {
const body = await response.text();
const match = body.match(/data: (.+)/);
if (match) {
const parsed = JSON.parse(match[1]);
if (parsed.error) {
this.error.set(`API error: ${parsed.error}`);
return;
}
}
} catch { /* fall through */ }
Comment on lines 60 to +71

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new 502 handling parses only the first data: line via regex and only when response.status === 502. If the backend writes an ErrorChunk after headers have already been committed (status may remain 200), the streaming loop below will currently ignore it because it only handles parsed.text.

Consider making the streaming loop also handle parsed.error chunks (and setting this.error) so error details are surfaced consistently regardless of HTTP status/commit timing.

Copilot uses AI. Check for mistakes.
this.error.set('Analysis temporarily unavailable. Please try again.');
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ public void streamMessage(
throw new AnthropicRateLimitException("Anthropic API rate limited: " + response.code());
}
if (!response.isSuccessful()) {
throw new IOException("Anthropic API error: " + response.code());
String errorBody = response.body() != null
? response.body().string() : "no response body";
throw new IOException(
"Anthropic API error: " + response.code() + " - " + errorBody);
Comment on lines 104 to +108

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response.body().string() reads the entire error response into memory and the resulting string is propagated into the thrown exception message. If Anthropic returns a large error payload (or HTML), this can unnecessarily inflate memory usage and downstream responses.

Consider capping how much of the error body is read (e.g., via peekBody(maxBytes) or a manual limit) and/or only including a short, sanitized excerpt in the exception.

Copilot uses AI. Check for mistakes.
}

try (BufferedReader reader = new BufferedReader(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@
import google.registry.ui.server.console.ConsoleEppPasswordAction.EppPasswordData;
import google.registry.ui.server.console.ConsoleOteAction.OteCreateData;
import google.registry.ui.server.console.ConsoleRegistryLockAction.ConsoleRegistryLockPostInput;
import google.registry.ui.server.console.registrydash.ExploreQueryDescriptor;
import google.registry.ui.server.console.registrydash.RegistryDashAdminAction;
import google.registry.ui.server.console.ConsoleUsersAction.UserData;
import google.registry.ui.server.console.PasswordResetRequestAction.PasswordResetRequestData;
import google.registry.ui.server.console.registrydash.ExploreQueryDescriptor;
import google.registry.ui.server.console.registrydash.RegistryDashAdminAction;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import org.joda.time.DateTime;
Expand Down Expand Up @@ -461,4 +461,16 @@ public static Optional<JsonElement> provideAiAnalyzePayload(
@OptionalJsonPayload Optional<JsonElement> payload) {
return payload;
}

@Provides
@Parameter("aiPage")
public static Optional<String> provideAiPage(HttpServletRequest req) {
return extractOptionalParameter(req, "page");
}

@Provides
@Parameter("aiPromptType")
public static Optional<String> provideAiPromptType(HttpServletRequest req) {
return extractOptionalParameter(req, "promptType");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
@Action(
service = Service.CONSOLE,
path = RegistryDashAiAction.PATH,
method = Action.Method.POST,
method = {Action.Method.GET, Action.Method.POST},
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class RegistryDashAiAction extends ConsoleApiAction {

Expand All @@ -53,6 +53,8 @@ public class RegistryDashAiAction extends ConsoleApiAction {
private static final Gson PLAIN_GSON = new Gson();

private final Optional<JsonElement> payload;
private final Optional<String> aiPage;
private final Optional<String> aiPromptType;
private final AnthropicClient anthropicClient;
private final AiRateLimiter rateLimiter;
private final Gson gson;
Expand All @@ -61,15 +63,40 @@ public class RegistryDashAiAction extends ConsoleApiAction {
public RegistryDashAiAction(
ConsoleApiParams consoleApiParams,
@Parameter("aiAnalyzePayload") Optional<JsonElement> payload,
@Parameter("aiPage") Optional<String> aiPage,
@Parameter("aiPromptType") Optional<String> aiPromptType,
AnthropicClient anthropicClient,
AiRateLimiter rateLimiter) {
super(consoleApiParams);
this.payload = payload;
this.aiPage = aiPage;
this.aiPromptType = aiPromptType;
this.anthropicClient = anthropicClient;
this.rateLimiter = rateLimiter;
this.gson = consoleApiParams.gson();
}

@Override
protected void getHandler(User user) {
boolean isProduction = RegistryEnvironment.get() == RegistryEnvironment.PRODUCTION;
boolean isAdmin = user.getUserRoles().getGlobalRole() == GlobalRole.FTE;

if (isProduction || !isAdmin) {
consoleApiParams.response().setStatus(SC_FORBIDDEN);
return;
Comment thread
UDtorrey marked this conversation as resolved.
}

if (aiPage.isEmpty() || aiPromptType.isEmpty()) {
setFailedResponse("page and promptType query parameters are required", SC_BAD_REQUEST);
return;
}

String prompt = getDefaultSystemPrompt(aiPage.get(), aiPromptType.get(), null, null);
JsonObject result = new JsonObject();
result.addProperty("systemPrompt", prompt);
consoleApiParams.response().setPayload(PLAIN_GSON.toJson(result));
}

@Override
protected void postHandler(User user) {
if (!user.getUserRoles().hasGlobalPermission(ConsolePermission.VIEW_DASHBOARD_OVERVIEW)) {
Expand Down Expand Up @@ -131,15 +158,25 @@ protected void postHandler(User user) {
consoleApiParams.response().setHeader("Retry-After", "30");
} catch (IOException e) {
logger.atWarning().withCause(e).log("Anthropic API error");
consoleApiParams.response().setStatus(502);
try {
PrintWriter writer = consoleApiParams.response().getWriter();
consoleApiParams.response().setHeader(
"Content-Type", "text/event-stream");
consoleApiParams.response().setStatus(502);
String detail = e.getMessage() != null ? e.getMessage() : "";
writer.write("data: " + PLAIN_GSON.toJson(
new ErrorChunk(detail)) + "\n\n");
writer.flush();
Comment on lines +166 to +169

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 502 error detail being returned to the browser is derived directly from IOException.getMessage(), which now includes the full upstream Anthropic error body. This can be very large (memory/response-size risk) and may expose upstream/internal details to end users.

Recommend truncating/sanitizing the detail before sending it to the client (e.g., cap length and strip newlines), while logging the full message server-side for debugging.

Copilot uses AI. Check for mistakes.
} catch (IOException ignored) {
consoleApiParams.response().setStatus(502);
}
}
}

private String buildSystemPrompt(AiAnalyzeRequest request, User user) {
boolean isProduction = RegistryEnvironment.get() == RegistryEnvironment.PRODUCTION;
boolean isAdmin = user.getUserRoles().getGlobalRole() == GlobalRole.FTE;

if (!isProduction && isAdmin
if (isAdmin
&& request.systemPrompt != null && !request.systemPrompt.isEmpty()) {
return request.systemPrompt;
}
Expand Down Expand Up @@ -188,12 +225,18 @@ private String getDefaultSystemPrompt(
}
}

sb.append("\n## Data\n```json\n").append(gson.toJson(chartData)).append("\n```\n");
sb.append("\n## Data\n```json\n");
sb.append(chartData != null
? gson.toJson(chartData)
: "[chart data will be injected at request time]");
sb.append("\n```\n");
sb.append("\nProvide your analysis in clear markdown. Use specific numbers from the data. ");
sb.append("Keep your response concise and actionable.");

return sb.toString();
}

private record TextChunk(String text) {}

private record ErrorChunk(String error) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ void testSuccess_streamsResponse() throws Exception {
}).when(anthropicClient).streamMessage(any(), any(), any(), any());

RegistryDashAiAction action = new RegistryDashAiAction(
params, Optional.of(json), anthropicClient, rateLimiter);
params, Optional.of(json), Optional.empty(), Optional.empty(),
anthropicClient, rateLimiter);
action.run();

assertThat(response.getStatus()).isEqualTo(200);
Expand All @@ -97,7 +98,8 @@ void testSuccess_streamsResponse() throws Exception {
@Test
void testBadRequest_missingPayload() {
RegistryDashAiAction action = new RegistryDashAiAction(
params, Optional.empty(), anthropicClient, rateLimiter);
params, Optional.empty(), Optional.empty(), Optional.empty(),
anthropicClient, rateLimiter);
action.run();

assertThat(response.getStatus()).isEqualTo(400);
Expand All @@ -110,7 +112,37 @@ void testBadRequest_invalidPage() {
JsonElement json = JsonParser.parseString(payload);

RegistryDashAiAction action = new RegistryDashAiAction(
params, Optional.of(json), anthropicClient, rateLimiter);
params, Optional.of(json), Optional.empty(), Optional.empty(),
anthropicClient, rateLimiter);
action.run();

assertThat(response.getStatus()).isEqualTo(400);
}

@Test
void testGetDefaultPrompt_returnsSystemPrompt() {
when(params.request().getMethod()).thenReturn("GET");

RegistryDashAiAction action = new RegistryDashAiAction(
params, Optional.empty(),
Optional.of("domain-activity"), Optional.of("summarize_trends"),
anthropicClient, rateLimiter);
action.run();

assertThat(response.getStatus()).isEqualTo(200);
String payload = response.getPayload();
assertThat(payload).contains("systemPrompt");
assertThat(payload).contains("domain-activity");
}
Comment on lines +122 to +136

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no test coverage for the new 502/ErrorChunk behavior when anthropicClient.streamMessage(...) throws an IOException. Adding a unit test that forces an IOException and asserts the 502 status + returned payload format would help prevent regressions (and validate the frontend parsing contract).

Copilot uses AI. Check for mistakes.

@Test
void testGetDefaultPrompt_missingParams() {
when(params.request().getMethod()).thenReturn("GET");

RegistryDashAiAction action = new RegistryDashAiAction(
params, Optional.empty(),
Optional.empty(), Optional.empty(),
anthropicClient, rateLimiter);
action.run();

assertThat(response.getStatus()).isEqualTo(400);
Expand All @@ -126,7 +158,8 @@ void testRateLimitExceeded() {
JsonElement json = JsonParser.parseString(payload);

RegistryDashAiAction action = new RegistryDashAiAction(
params, Optional.of(json), anthropicClient, strictLimiter);
params, Optional.of(json), Optional.empty(), Optional.empty(),
anthropicClient, strictLimiter);
action.run();

assertThat(response.getStatus()).isEqualTo(429);
Expand Down