diff --git a/src/main/java/run/halo/aipagebuilder/ai/ChatApi.java b/src/main/java/run/halo/aipagebuilder/ai/ChatApi.java
index 9cb0f7f..0e717d1 100644
--- a/src/main/java/run/halo/aipagebuilder/ai/ChatApi.java
+++ b/src/main/java/run/halo/aipagebuilder/ai/ChatApi.java
@@ -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 items) { }
+
@Schema(name = "PageChatRecovery", requiredProperties = {"sourceRunId", "checkpointVersion", "canResume", "updatedAt"})
public record Recovery(String sourceRunId, Long checkpointVersion, boolean canResume,
String blockedReason, Instant updatedAt) { }
diff --git a/src/main/java/run/halo/aipagebuilder/ai/PageChatEndpoint.java b/src/main/java/run/halo/aipagebuilder/ai/PageChatEndpoint.java
index c76e5a3..ee8d076 100644
--- a/src/main/java/run/halo/aipagebuilder/ai/PageChatEndpoint.java
+++ b/src/main/java/run/halo/aipagebuilder/ai/PageChatEndpoint.java
@@ -84,6 +84,21 @@ public RouterFunction 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"))
diff --git a/src/main/java/run/halo/aipagebuilder/ai/PageChatService.java b/src/main/java/run/halo/aipagebuilder/ai/PageChatService.java
index 64eb3d9..300cbbf 100644
--- a/src/main/java/run/halo/aipagebuilder/ai/PageChatService.java
+++ b/src/main/java/run/halo/aipagebuilder/ai/PageChatService.java
@@ -268,6 +268,7 @@ private Mono>> 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());
@@ -297,6 +298,20 @@ private Mono>> reserve(String name, ChatApi.Request req
});
}
+ /** Runs owned by this process, for the cross-page list. */
+ public Mono 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 startBackground(String name, ChatApi.Request request, String actor) {
+ return start(name, request, actor).then();
+ }
+
public void start() {
runs.start();
}
diff --git a/src/main/java/run/halo/aipagebuilder/ai/PageRun.java b/src/main/java/run/halo/aipagebuilder/ai/PageRun.java
index f5a85d9..f8de4ce 100644
--- a/src/main/java/run/halo/aipagebuilder/ai/PageRun.java
+++ b/src/main/java/run/halo/aipagebuilder/ai/PageRun.java
@@ -78,6 +78,7 @@ String wireName() {
private Mono checkpointWrites = Mono.empty();
private boolean answering;
boolean reserving;
+ private boolean admitted;
private volatile boolean saveStarted;
private volatile boolean terminalPersisted;
private volatile String revision;
@@ -412,6 +413,21 @@ Mono 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()),
diff --git a/src/main/java/run/halo/aipagebuilder/ai/PageRunCoordinator.java b/src/main/java/run/halo/aipagebuilder/ai/PageRunCoordinator.java
index 72ff61f..b46b4cb 100644
--- a/src/main/java/run/halo/aipagebuilder/ai/PageRunCoordinator.java
+++ b/src/main/java/run/halo/aipagebuilder/ai/PageRunCoordinator.java
@@ -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 snapshot() { return Map.copyOf(active); }
synchronized void reserve(String name, PageRun run) {
if (stopping) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "插件正在停止,请稍后重试");
diff --git a/src/test/java/run/halo/aipagebuilder/ai/PageChatEndpointTest.java b/src/test/java/run/halo/aipagebuilder/ai/PageChatEndpointTest.java
index ff1b1c6..2d8b2ef 100644
--- a/src/test/java/run/halo/aipagebuilder/ai/PageChatEndpointTest.java
+++ b/src/test/java/run/halo/aipagebuilder/ai/PageChatEndpointTest.java
@@ -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")))
diff --git a/src/test/java/run/halo/aipagebuilder/ai/PageChatServiceTest.java b/src/test/java/run/halo/aipagebuilder/ai/PageChatServiceTest.java
index d01607b..27ceb36 100644
--- a/src/test/java/run/halo/aipagebuilder/ai/PageChatServiceTest.java
+++ b/src/test/java/run/halo/aipagebuilder/ai/PageChatServiceTest.java
@@ -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() {
diff --git a/ui/src/api/generated/.openapi-generator/FILES b/ui/src/api/generated/.openapi-generator/FILES
index 1d11007..c2a03dc 100644
--- a/ui/src/api/generated/.openapi-generator/FILES
+++ b/ui/src/api/generated/.openapi-generator/FILES
@@ -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
diff --git a/ui/src/api/generated/api/ai-page-builder-chat-api.ts b/ui/src/api/generated/api/ai-page-builder-chat-api.ts
index 47df316..240064d 100644
--- a/ui/src/api/generated/api/ai-page-builder-chat-api.ts
+++ b/ui/src/api/generated/api/ai-page-builder-chat-api.ts
@@ -24,6 +24,8 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
// @ts-ignore
import type { CreatePageChatSessionRequest } from '../models';
// @ts-ignore
+import type { PageChatActiveRunList } from '../models';
+// @ts-ignore
import type { PageChatAnswerRequest } from '../models';
// @ts-ignore
import type { PageChatCheckpointDetail } from '../models';
@@ -424,6 +426,43 @@ export const AiPageBuilderChatApiAxiosParamCreator = function (configuration?: C
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ listActivePageChatRuns: async (options: RawAxiosRequestConfig = {}): Promise => {
+ const localVarPath = `/apis/console.api.aipagebuilder.halo.run/v1alpha1/pages/chat/runs`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication basicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration)
+
+ // authentication bearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -560,6 +599,53 @@ export const AiPageBuilderChatApiAxiosParamCreator = function (configuration?: C
+ localVarHeaderParameter['Content-Type'] = 'application/json';
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+ localVarRequestOptions.data = serializeDataIfNeeded(pageChatRequest, localVarRequestOptions, configuration)
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ * Starts a run without streaming; results are available from the session and run endpoints
+ * @param {string} name
+ * @param {PageChatRequest} pageChatRequest
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ startPageChatRun: async (name: string, pageChatRequest: PageChatRequest, options: RawAxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'name' is not null or undefined
+ assertParamExists('startPageChatRun', 'name', name)
+ // verify required parameter 'pageChatRequest' is not null or undefined
+ assertParamExists('startPageChatRun', 'pageChatRequest', pageChatRequest)
+ const localVarPath = `/apis/console.api.aipagebuilder.halo.run/v1alpha1/pages/{name}/chat/runs`
+ .replace(`{${"name"}}`, encodeURIComponent(String(name)));
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication basicAuth required
+ // http basic authentication required
+ setBasicAuthToObject(localVarRequestOptions, configuration)
+
+ // authentication bearerAuth required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -783,6 +869,17 @@ export const AiPageBuilderChatApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['AiPageBuilderChatApi.getThemeContext']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async listActivePageChatRuns(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.listActivePageChatRuns(options);
+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
+ const localVarOperationServerBasePath = operationServerMap['AiPageBuilderChatApi.listActivePageChatRuns']?.[localVarOperationServerIndex]?.url;
+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
+ },
/**
*
* @param {string} name
@@ -822,6 +919,19 @@ export const AiPageBuilderChatApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['AiPageBuilderChatApi.runPageChat']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
+ /**
+ * Starts a run without streaming; results are available from the session and run endpoints
+ * @param {string} name
+ * @param {PageChatRequest} pageChatRequest
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async startPageChatRun(name: string, pageChatRequest: PageChatRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.startPageChatRun(name, pageChatRequest, options);
+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
+ const localVarOperationServerBasePath = operationServerMap['AiPageBuilderChatApi.startPageChatRun']?.[localVarOperationServerIndex]?.url;
+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
+ },
/**
*
* @param {string} name
@@ -929,6 +1039,14 @@ export const AiPageBuilderChatApiFactory = function (configuration?: Configurati
getThemeContext(options?: RawAxiosRequestConfig): AxiosPromise {
return localVarFp.getThemeContext(options).then((request) => request(axios, basePath));
},
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ listActivePageChatRuns(options?: RawAxiosRequestConfig): AxiosPromise {
+ return localVarFp.listActivePageChatRuns(options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {AiPageBuilderChatApiListPageChatSessionsRequest} requestParameters Request parameters.
@@ -956,6 +1074,15 @@ export const AiPageBuilderChatApiFactory = function (configuration?: Configurati
runPageChat(requestParameters: AiPageBuilderChatApiRunPageChatRequest, options?: RawAxiosRequestConfig): AxiosPromise {
return localVarFp.runPageChat(requestParameters.name, requestParameters.pageChatRequest, options).then((request) => request(axios, basePath));
},
+ /**
+ * Starts a run without streaming; results are available from the session and run endpoints
+ * @param {AiPageBuilderChatApiStartPageChatRunRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ startPageChatRun(requestParameters: AiPageBuilderChatApiStartPageChatRunRequest, options?: RawAxiosRequestConfig): AxiosPromise {
+ return localVarFp.startPageChatRun(requestParameters.name, requestParameters.pageChatRequest, options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {AiPageBuilderChatApiSteerPageChatRunRequest} requestParameters Request parameters.
@@ -1222,6 +1349,27 @@ export interface AiPageBuilderChatApiRunPageChatRequest {
readonly pageChatRequest: PageChatRequest
}
+/**
+ * Request parameters for startPageChatRun operation in AiPageBuilderChatApi.
+ * @export
+ * @interface AiPageBuilderChatApiStartPageChatRunRequest
+ */
+export interface AiPageBuilderChatApiStartPageChatRunRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof AiPageBuilderChatApiStartPageChatRun
+ */
+ readonly name: string
+
+ /**
+ *
+ * @type {PageChatRequest}
+ * @memberof AiPageBuilderChatApiStartPageChatRun
+ */
+ readonly pageChatRequest: PageChatRequest
+}
+
/**
* Request parameters for steerPageChatRun operation in AiPageBuilderChatApi.
* @export
@@ -1358,6 +1506,16 @@ export class AiPageBuilderChatApi extends BaseAPI {
return AiPageBuilderChatApiFp(this.configuration).getThemeContext(options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ *
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof AiPageBuilderChatApi
+ */
+ public listActivePageChatRuns(options?: RawAxiosRequestConfig) {
+ return AiPageBuilderChatApiFp(this.configuration).listActivePageChatRuns(options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {AiPageBuilderChatApiListPageChatSessionsRequest} requestParameters Request parameters.
@@ -1391,6 +1549,17 @@ export class AiPageBuilderChatApi extends BaseAPI {
return AiPageBuilderChatApiFp(this.configuration).runPageChat(requestParameters.name, requestParameters.pageChatRequest, options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ * Starts a run without streaming; results are available from the session and run endpoints
+ * @param {AiPageBuilderChatApiStartPageChatRunRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof AiPageBuilderChatApi
+ */
+ public startPageChatRun(requestParameters: AiPageBuilderChatApiStartPageChatRunRequest, options?: RawAxiosRequestConfig) {
+ return AiPageBuilderChatApiFp(this.configuration).startPageChatRun(requestParameters.name, requestParameters.pageChatRequest, options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {AiPageBuilderChatApiSteerPageChatRunRequest} requestParameters Request parameters.
diff --git a/ui/src/api/generated/base.ts b/ui/src/api/generated/base.ts
index c54b167..0f87277 100644
--- a/ui/src/api/generated/base.ts
+++ b/ui/src/api/generated/base.ts
@@ -19,7 +19,7 @@ import type { Configuration } from './configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
-export const BASE_PATH = "http://localhost:32176".replace(/\/+$/, "");
+export const BASE_PATH = "http://localhost:64383".replace(/\/+$/, "");
/**
*
diff --git a/ui/src/api/generated/models/index.ts b/ui/src/api/generated/models/index.ts
index 6dd13cf..11d68c2 100644
--- a/ui/src/api/generated/models/index.ts
+++ b/ui/src/api/generated/models/index.ts
@@ -10,6 +10,8 @@ export * from './json-patch-inner';
export * from './move-operation';
export * from './mutation-request';
export * from './page-annotation';
+export * from './page-chat-active-run';
+export * from './page-chat-active-run-list';
export * from './page-chat-answer-item';
export * from './page-chat-answer-request';
export * from './page-chat-checkpoint-detail';
diff --git a/ui/src/api/generated/models/page-chat-active-run-list.ts b/ui/src/api/generated/models/page-chat-active-run-list.ts
new file mode 100644
index 0000000..3a5d5d1
--- /dev/null
+++ b/ui/src/api/generated/models/page-chat-active-run-list.ts
@@ -0,0 +1,33 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.26.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+// May contain unused imports in some cases
+// @ts-ignore
+import type { PageChatActiveRun } from './page-chat-active-run';
+
+/**
+ *
+ * @export
+ * @interface PageChatActiveRunList
+ */
+export interface PageChatActiveRunList {
+ /**
+ *
+ * @type {Array}
+ * @memberof PageChatActiveRunList
+ */
+ 'items': Array;
+}
+
diff --git a/ui/src/api/generated/models/page-chat-active-run.ts b/ui/src/api/generated/models/page-chat-active-run.ts
new file mode 100644
index 0000000..bdbf734
--- /dev/null
+++ b/ui/src/api/generated/models/page-chat-active-run.ts
@@ -0,0 +1,63 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Halo
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 2.26.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+// May contain unused imports in some cases
+// @ts-ignore
+import type { PageChatQuestion } from './page-chat-question';
+
+/**
+ *
+ * @export
+ * @interface PageChatActiveRun
+ */
+export interface PageChatActiveRun {
+ /**
+ *
+ * @type {string}
+ * @memberof PageChatActiveRun
+ */
+ 'pageName': string;
+ /**
+ *
+ * @type {PageChatQuestion}
+ * @memberof PageChatActiveRun
+ */
+ 'pendingQuestion'?: PageChatQuestion;
+ /**
+ *
+ * @type {string}
+ * @memberof PageChatActiveRun
+ */
+ 'runId': string;
+ /**
+ *
+ * @type {string}
+ * @memberof PageChatActiveRun
+ */
+ 'sessionId': string;
+ /**
+ *
+ * @type {string}
+ * @memberof PageChatActiveRun
+ */
+ 'startedAt': string;
+ /**
+ *
+ * @type {string}
+ * @memberof PageChatActiveRun
+ */
+ 'status': string;
+}
+
diff --git a/ui/src/components/AiPanel.vue b/ui/src/components/AiPanel.vue
index 41b06cf..3bdabe3 100644
--- a/ui/src/components/AiPanel.vue
+++ b/ui/src/components/AiPanel.vue
@@ -28,6 +28,9 @@ const props = defineProps<{
const emit = defineEmits<{
committed: [page: PageDetail]
running: [value: boolean]
+ active: [value: boolean]
+ admitted: [value: boolean]
+ confirmed: [value: boolean]
preview: [value: StreamingPreview | undefined]
completed: []
removeAnnotation: [id: string]
@@ -67,6 +70,8 @@ const {
error,
notice,
running,
+ admitted,
+ confirmed,
activeRun,
waitingForUser,
pendingQuestion,
@@ -109,6 +114,15 @@ watch(preview, (value) => emit('preview', value), { immediate: true })
watch([running, loading], ([value, fetching]) => emit('running', value || fetching), {
immediate: true,
})
+// Distinguishes a real run from the initial conversation load: only a run keeps going
+// after the editor closes.
+watch(
+ () => !!activeRun.value,
+ (value) => emit('active', value),
+ { immediate: true },
+)
+watch(admitted, (value) => emit('admitted', value), { immediate: true })
+watch(confirmed, (value) => emit('confirmed', value), { immediate: true })
// Streaming emits many deltas per paint; coalescing the layout read and the scroll
// into one animation frame keeps long conversations responsive.
let scrollFrame = 0
diff --git a/ui/src/components/PageCard.vue b/ui/src/components/PageCard.vue
index 1c5b490..f10c24a 100644
--- a/ui/src/components/PageCard.vue
+++ b/ui/src/components/PageCard.vue
@@ -9,13 +9,14 @@ import {
} from '@halo-dev/components'
import { utils } from '@halo-dev/ui-shared'
import IconMore from '~icons/mingcute/more-2-line'
-import type { PageSummary, ThemeRef } from '../api/generated'
+import type { PageChatActiveRun, PageSummary, ThemeRef } from '../api/generated'
import PagePreviewCard from './PagePreviewCard.vue'
import PublishPagePopper from './PublishPagePopper.vue'
const props = defineProps<{
item: PageSummary
currentTheme?: ThemeRef | null
+ run?: PageChatActiveRun
disabled?: boolean
}>()
const emit = defineEmits<{
@@ -23,6 +24,7 @@ const emit = defineEmits<{
preview: [item: PageSummary]
visit: [item: PageSummary]
settings: [item: PageSummary]
+ stop: [item: PageSummary]
update: [item: PageSummary]
unpublish: [item: PageSummary]
clone: [item: PageSummary]
@@ -37,6 +39,22 @@ const status = computed(() =>
: { state: 'success' as const, text: '已发布' }
: { state: 'default' as const, text: '未发布' },
)
+const generating = computed(() => !!props.run)
+const runStatus = computed(() => {
+ switch (props.run?.status) {
+ case 'waiting_for_user':
+ return { text: '等待回答', class: 'bg-amber-50 text-amber-700' }
+ case 'committing':
+ return { text: '保存中', class: 'bg-blue-50 text-blue-700' }
+ default:
+ return { text: 'AI 生成中', class: 'bg-blue-50 text-blue-700' }
+ }
+})
+const runDetail = computed(() =>
+ props.run?.status === 'waiting_for_user'
+ ? props.run.pendingQuestion?.questions?.[0]?.title || '助手正在等待你的回答'
+ : '助手正在后台生成,完成后会自动保存为草稿',
+)
const updatedAt = computed(() => utils.date.timeAgo(props.item.updatedAt) ?? '')
const updatedAtFull = computed(() => utils.date.format(props.item.updatedAt))
const themeWarning = computed(() => {
@@ -81,8 +99,19 @@ const themeWarning = computed(() => {
{{ item.slug ? '/' + item.slug : '未设置访问路径' }}
-
+
+
+ {{ runStatus.text }}
+
{
-
+
+ 停止生成
+
+
设置
{
:title="item.title"
:slug="item.slug"
:version="item.version"
- :disabled="disabled"
+ :disabled="disabled || generating"
placement="left-start"
>
发布
-
+
更新发布
@@ -143,7 +189,7 @@ const themeWarning = computed(() => {
@@ -152,7 +198,7 @@ const themeWarning = computed(() => {
删除页面
diff --git a/ui/src/components/__tests__/page-card.spec.ts b/ui/src/components/__tests__/page-card.spec.ts
index e97d1d1..13bdae3 100644
--- a/ui/src/components/__tests__/page-card.spec.ts
+++ b/ui/src/components/__tests__/page-card.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
import PageCard from '../PageCard.vue'
-import type { PageSummary } from '../../api/generated'
+import type { PageChatActiveRun, PageSummary } from '../../api/generated'
vi.mock('@halo-dev/components', async () => {
const { defineComponent, h } = await import('vue')
@@ -19,7 +19,7 @@ vi.mock('@halo-dev/components', async () => {
VDropdownItem: {
props: ['disabled'],
emits: ['click'],
- template: '
',
+ template: '
',
},
VStatusDot: defineComponent({
props: ['state', 'text'],
@@ -67,9 +67,10 @@ function mountCard(
item: PageSummary,
disabled = false,
currentTheme?: PageSummary['publishedTheme'],
+ run?: PageChatActiveRun,
) {
return mount(PageCard, {
- props: { item, currentTheme, disabled },
+ props: { item, currentTheme, disabled, run },
global: {
stubs: { RouterLink: true, PagePreviewCard: true, PublishPagePopper: SlotStub },
directives: { 'close-popper': {}, tooltip: {} },
@@ -96,6 +97,70 @@ describe('page card', () => {
expect(wrapper.text()).toContain('取消发布')
})
+ it('shows an active run and disables destructive actions while generating', () => {
+ const run: PageChatActiveRun = {
+ pageName: 'page',
+ sessionId: 'page',
+ runId: 'run',
+ status: 'running',
+ startedAt: '2026-10-26T00:00:00Z',
+ }
+ const wrapper = mountCard(summary({ publishedRevision: 'r2' }), false, undefined, run)
+ expect(wrapper.text()).toContain('AI 生成中')
+ const item = (label: string) =>
+ wrapper.findAll('[data-disabled]').find((element) => element.text() === label)!
+ expect(item('设置').attributes('data-disabled')).toBe('true')
+ expect(item('更新发布').attributes('data-disabled')).toBe('true')
+ expect(item('取消发布').attributes('data-disabled')).toBe('true')
+ expect(item('删除页面').attributes('data-disabled')).toBe('true')
+ expect(item('克隆页面').attributes('data-disabled')).toBe('false')
+ })
+
+ it('shows a waiting run instead of a generating one', () => {
+ const wrapper = mountCard(summary(), false, undefined, {
+ pageName: 'page',
+ sessionId: 'page',
+ runId: 'run',
+ status: 'waiting_for_user',
+ startedAt: '2026-10-26T00:00:00Z',
+ pendingQuestion: {
+ id: 'question',
+ createdAt: '2026-10-26T00:00:00Z',
+ questions: [{ id: 'style', title: '想要什么风格?', options: [], allowCustomAnswer: true }],
+ },
+ })
+ expect(wrapper.text()).toContain('等待回答')
+ expect(wrapper.text()).not.toContain('AI 生成中')
+ })
+
+ it('stops an active run from the action menu', async () => {
+ const item = summary()
+ const wrapper = mountCard(item, false, undefined, {
+ pageName: 'page',
+ sessionId: 'page',
+ runId: 'run',
+ status: 'running',
+ startedAt: '2026-10-26T00:00:00Z',
+ })
+ const stop = wrapper.findAll('div').find((node) => node.text() === '停止生成')!
+ expect(stop.attributes('data-disabled')).toBe('false')
+ await stop.trigger('click')
+ expect(wrapper.emitted('stop')).toEqual([[item]])
+ expect(mountCard(summary()).text()).not.toContain('停止生成')
+ })
+
+ it('does not offer stop while the run is saving', () => {
+ const wrapper = mountCard(summary(), false, undefined, {
+ pageName: 'page',
+ sessionId: 'page',
+ runId: 'run',
+ status: 'committing',
+ startedAt: '2026-10-26T00:00:00Z',
+ })
+ expect(wrapper.text()).toContain('保存中')
+ expect(wrapper.text()).not.toContain('停止生成')
+ })
+
it('shows the relative update time on the card', () => {
const wrapper = mountCard(summary({ updatedAt: '2026-10-16T10:15:09Z' }))
expect(wrapper.text()).toContain('timeAgo:2026-10-16T10:15:09Z')
diff --git a/ui/src/components/create/CreatePagePanel.vue b/ui/src/components/create/CreatePagePanel.vue
index 5e2087b..80b39bf 100644
--- a/ui/src/components/create/CreatePagePanel.vue
+++ b/ui/src/components/create/CreatePagePanel.vue
@@ -1,12 +1,18 @@