Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/main/java/run/halo/aipagebuilder/ai/ChatApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ public Run(String runId, String status, String revision, String error, PageDetai
}
}

@Schema(name = "PageChatActiveRun", requiredProperties = {"pageName", "sessionId", "runId", "status", "startedAt"})
public record ActiveRun(String pageName, String sessionId, String runId, String status,
Instant startedAt, Question pendingQuestion) { }

@Schema(name = "PageChatActiveRunList", requiredProperties = {"items"})
public record ActiveRunList(List<ActiveRun> items) { }

@Schema(name = "PageChatRecovery", requiredProperties = {"sourceRunId", "checkpointVersion", "canResume", "updatedAt"})
public record Recovery(String sourceRunId, Long checkpointVersion, boolean canResume,
String blockedReason, Instant updatedAt) { }
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/run/halo/aipagebuilder/ai/PageChatEndpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ public RouterFunction<ServerResponse> endpoint() {
builder -> named(builder, "runPageChat", String.class)
.description("Halo UIMessage SSE; page commit is emitted as data-page-committed before finish")
.requestBody(requestBodyBuilder().required(true).implementation(ChatApi.Request.class)))
.GET("/pages/chat/runs", request -> json(chats.activeRuns()),
builder -> builder.operationId("listActivePageChatRuns").tag("AiPageBuilderChat")
.response(responseBuilder().responseCode("200")
.implementation(ChatApi.ActiveRunList.class)))
.POST("/pages/{name}/chat/runs", request -> body(request, ChatApi.Request.class)
.flatMap(input -> actor().flatMap(actor -> chats.startBackground(
request.pathVariable("name"), input, actor)))
.then(ServerResponse.noContent().build()),
builder -> builder.operationId("startPageChatRun").tag("AiPageBuilderChat")
.parameter(parameterBuilder().name("name").in(ParameterIn.PATH).required(true)
.implementation(String.class))
.description("Starts a run without streaming; results are available from the session and run endpoints")
.requestBody(requestBodyBuilder().required(true)
.implementation(ChatApi.Request.class))
.response(responseBuilder().responseCode("204")))
.GET("/pages/{name}/chat/runs/{runId}", request -> json(chats.result(
request.pathVariable("name"), request.pathVariable("runId"))),
builder -> run(builder, "getPageChatRun"))
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/run/halo/aipagebuilder/ai/PageChatService.java
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ private Mono<Flux<Map<String, Object>>> reserve(String name, ChatApi.Request req
}
return pages.reserveRun(name, request.version(), () -> client.update(session).then())
.then(Mono.defer(() -> {
run.markAdmitted();
run.prepared.tryEmitEmpty();
if (run.memory != null && checkpoint == null) run.memory.prompt(request.prompt());
run.accepted.tryEmitValue(run.events.asFlux());
Expand Down Expand Up @@ -297,6 +298,20 @@ private Mono<Flux<Map<String, Object>>> reserve(String name, ChatApi.Request req
});
}

/** Runs owned by this process, for the cross-page list. */
public Mono<ChatApi.ActiveRunList> activeRuns() {
return Mono.fromSupplier(() -> new ChatApi.ActiveRunList(runs.snapshot().entrySet().stream()
.filter(entry -> entry.getValue().admitted())
.map(entry -> entry.getValue().summary(entry.getKey()))
.sorted(java.util.Comparator.comparing(ChatApi.ActiveRun::startedAt))
.toList()));
}

/** Accepts a run without keeping an observer attached; execution is independent of SSE. */
public Mono<Void> startBackground(String name, ChatApi.Request request, String actor) {
return start(name, request, actor).then();
}

public void start() {
runs.start();
}
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/run/halo/aipagebuilder/ai/PageRun.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ String wireName() {
private Mono<Void> checkpointWrites = Mono.empty();
private boolean answering;
boolean reserving;
private boolean admitted;
private volatile boolean saveStarted;
private volatile boolean terminalPersisted;
private volatile String revision;
Expand Down Expand Up @@ -412,6 +413,21 @@ Mono<Void> finish(PageDetail page) {
}).then();
}

/** A run can only be stopped after its durable reservation exists. */
synchronized void markAdmitted() {
admitted = true;
}

synchronized boolean admitted() {
return admitted;
}

/** Snapshot for the cross-page run list; the caller must not retain run internals. */
synchronized ChatApi.ActiveRun summary(String pageName) {
return new ChatApi.ActiveRun(pageName, sessionId, request.runId(), status(), createdAt,
state == State.WAITING_FOR_USER && !questions.isEmpty() ? questions.getLast() : null);
}

synchronized ChatApi.Turn turn() {
return new ChatApi.Turn(request.runId(), actor, createdAt, request.prompt(), text.toString(),
copy.summaries(), status(), revision, error, display.snapshot(status(), copy.memoryOutcomes()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class PageRunCoordinator {
synchronized void start() { stopping = false; }
synchronized PageRun get(String name) { return active.get(name); }
synchronized boolean contains(String name) { return active.containsKey(name); }
synchronized Map<String, PageRun> snapshot() { return Map.copyOf(active); }

synchronized void reserve(String name, PageRun run) {
if (stopping) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "插件正在停止,请稍后重试");
Expand Down
17 changes: 17 additions & 0 deletions src/test/java/run/halo/aipagebuilder/ai/PageChatEndpointTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ void routesSessionCrudAndHistoryCursor() {
.get().uri("/pages/page/sessions").exchange().expectStatus().isForbidden();
}

@Test
void listsActiveRunsAndStartsBackgroundRuns() {
when(service.activeRuns()).thenReturn(Mono.just(new ChatApi.ActiveRunList(List.of(
new ChatApi.ActiveRun("page", "page", "run", "running",
java.time.Instant.now(), null)))));
when(service.startBackground(eq("page"), any(), eq("admin"))).thenReturn(Mono.empty());

admin().get().uri("/pages/chat/runs").exchange().expectStatus().isOk()
.expectBody().jsonPath("$.items[0].pageName").isEqualTo("page");
admin().post().uri("/pages/page/chat/runs")
.bodyValue(Map.of("runId", "run", "version", 1, "modelName", "model", "prompt", "Hi"))
.exchange().expectStatus().isNoContent();

WebTestClient.bindToRouterFunction(endpoint.endpoint()).build()
.get().uri("/pages/chat/runs").exchange().expectStatus().isForbidden();
}

@Test
void answersRequireAdminAndBindPageRunQuestionAndActor() {
when(service.answer(eq("page"), eq("run"), eq("question"), any(), eq("admin")))
Expand Down
36 changes: 36 additions & 0 deletions src/test/java/run/halo/aipagebuilder/ai/PageChatServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,42 @@ void pluginStopInterruptsAndPreservesCheckpointInsteadOfCancelling() {
error -> assertThat(error.getStatusCode().value()).isEqualTo(503));
}

@Test
void activeRunsListAdmittedWorkAndBackgroundStartNeedsNoStreamSubscriber() {
when(gateway.chat(anyString(), any(), anyString(), any())).thenReturn(Flux.never());
service.startBackground("page", request(), "admin").block();

assertThat(service.activeRuns().block().items()).singleElement().satisfies(active -> {
assertThat(active.pageName()).isEqualTo("page");
assertThat(active.sessionId()).isEqualTo("page");
assertThat(active.runId()).isEqualTo(runId);
assertThat(active.status()).isEqualTo("running");
assertThat(active.pendingQuestion()).isNull();
assertThat(active.startedAt()).isNotNull();
});

service.stop();
assertThat(service.activeRuns().block().items()).isEmpty();
}

@Test
@org.junit.jupiter.api.Timeout(30)
void activeRunsHideRunsWithoutADurableReservation() {
when(client.listBy(eq(PageChatTurnRecord.class), any(), any())).thenAnswer(i -> {
PageRequest request = i.getArgument(2);
return request.getPageSize() > 1
? Mono.never()
: Mono.just(new ListResult<>(1, 1, 0, List.of()));
});

var starting = service.start("page", request(), "admin").toFuture();
assertThat(service.activeRuns().block().items()).isEmpty();

// Abandoning the start releases the placeholder without waiting for STOP_TIMEOUT.
starting.cancel(true);
assertThat(starting.isCancelled()).isTrue();
}

@Test
@org.junit.jupiter.api.Timeout(30)
void forcedStopRejectsRequestStillPreparingHistory() {
Expand Down
2 changes: 2 additions & 0 deletions ui/src/api/generated/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ models/json-patch-inner.ts
models/move-operation.ts
models/mutation-request.ts
models/page-annotation.ts
models/page-chat-active-run-list.ts
models/page-chat-active-run.ts
models/page-chat-answer-item.ts
models/page-chat-answer-request.ts
models/page-chat-checkpoint-detail.ts
Expand Down
Loading
Loading