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 }} + {