Skip to content

Commit 6e8e529

Browse files
committed
refactor: expose chat scenario client
1 parent 53831d4 commit 6e8e529

4 files changed

Lines changed: 249 additions & 7 deletions

File tree

src/main/java/io/github/easy4j/opencode/OpenCodeClient.java

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.github.easy4j.opencode.cli.OpenCodeCliExecutor;
88
import io.github.easy4j.opencode.cli.availability.OpenCodeCliAvailabilityReport;
99
import io.github.easy4j.opencode.api.OpenCodeHttpClient;
10+
import io.github.easy4j.opencode.api.OpenCodeChatClient;
1011
import io.github.easy4j.opencode.api.OpenCodeRequestContext;
1112
import io.github.easy4j.opencode.api.OpenCodeSseClient;
1213
import lombok.extern.slf4j.Slf4j;
@@ -53,6 +54,7 @@ public class OpenCodeClient implements AutoCloseable {
5354

5455
private final OpenCodeClientConfig config;
5556
private final OpenCodeHttpClient httpClient;
57+
private final OpenCodeChatClient chatClient;
5658
private final OpenCodeSseClient sseClient;
5759
private final OpenCodeCli cli;
5860
private final ExecutorService streamExecutor;
@@ -110,14 +112,15 @@ public OpenCodeClient(OpenCodeHttpClientConfig httpConfig, OpenCodeCliConfig cli
110112

111113
// HTTP 子系统初始化
112114
if (httpEnabled) {
113-
this.httpClient = new OpenCodeHttpClient(httpConfig, objectMapper, httpClient);
114-
this.sseClient = new OpenCodeSseClient(httpConfig, objectMapper,
115-
this.httpClient.getOkHttpClient());
115+
this.chatClient = new OpenCodeChatClient(httpConfig, objectMapper, httpClient);
116+
this.httpClient = this.chatClient;
117+
this.sseClient = this.chatClient.events();
116118
} else {
117119
this.httpClient = null;
120+
this.chatClient = null;
118121
this.sseClient = null;
119122
}
120-
this.streamExecutor = createStreamExecutor(httpConfig);
123+
this.streamExecutor = this.chatClient == null ? createStreamExecutor(httpConfig) : null;
121124

122125
// CLI 子系统初始化
123126
if (cliEnabled) {
@@ -153,9 +156,10 @@ public OpenCodeClient(OpenCodeClientConfig config,
153156
OpenCodeCli cli) {
154157
this.config = Objects.requireNonNull(config, "config");
155158
this.httpClient = httpClient;
159+
this.chatClient = httpClient instanceof OpenCodeChatClient ? (OpenCodeChatClient) httpClient : null;
156160
this.sseClient = sseClient;
157161
this.cli = cli;
158-
this.streamExecutor = createStreamExecutor(config.getHttp());
162+
this.streamExecutor = this.chatClient == null ? createStreamExecutor(config.getHttp()) : null;
159163
}
160164

161165
private static ExecutorService createStreamExecutor(OpenCodeHttpClientConfig config) {
@@ -371,6 +375,9 @@ public ChatStreamingResponse chatCompletionStream(ChatRequest request, String se
371375
public ChatStreamingResponse chatCompletionStream(ChatRequest request, String sessionKey,
372376
OpenCodeRequestContext context,
373377
Consumer<String> deltaConsumer) {
378+
if (chatClient != null) {
379+
return chatClient.chatCompletionStream(request, sessionKey, context, deltaConsumer);
380+
}
374381
String sessionId = httpClient.ensureSession(sessionKey, context);
375382
PromptRequest promptRequest = ChatMessageMapper.toPromptRequest(request);
376383

@@ -513,6 +520,13 @@ public HealthStatus health() {
513520
// SSE 事件流
514521
// ============================================================
515522

523+
/** 获取统一的 OpenCode 聊天场景客户端。 */
524+
public OpenCodeChatClient chat() {
525+
return chatClient;
526+
}
527+
528+
/** @deprecated 业务聊天请使用 {@link #chat()},这里只保留原始事件订阅兼容入口。 */
529+
@Deprecated
516530
public OpenCodeSseClient sse() {
517531
return sseClient;
518532
}
@@ -963,7 +977,7 @@ public io.github.easy4j.opencode.cli.OpenCodeCliResult cliPr(int number) {
963977

964978
@Override
965979
public void close() {
966-
streamExecutor.shutdownNow();
980+
if (streamExecutor != null) streamExecutor.shutdownNow();
967981
if (httpClient != null) httpClient.close();
968982
if (sseClient != null) sseClient.close();
969983
}

src/main/java/io/github/easy4j/opencode/OpenCodeHttpClientConfig.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,19 @@ public class OpenCodeHttpClientConfig {
3434
/**
3535
* OpenCode Server 根地址,例如 {@code http://localhost:4096}。
3636
*/
37-
private String serverUrl = "http://localhost:4096";
37+
private String baseUrl = "http://localhost:4096";
38+
39+
/** @deprecated 使用 {@link #getBaseUrl()}。 */
40+
@Deprecated
41+
public String getServerUrl() {
42+
return baseUrl;
43+
}
44+
45+
/** @deprecated 使用 {@link #setBaseUrl(String)}。 */
46+
@Deprecated
47+
public void setServerUrl(String value) {
48+
this.baseUrl = value;
49+
}
3850

3951
/**
4052
* HTTP Basic Auth 用户名(对应 {@code OPENCODE_SERVER_USERNAME},默认 {@code opencode})。
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package io.github.easy4j.opencode.api;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import io.github.easy4j.opencode.HttpCallCancellation;
5+
import io.github.easy4j.opencode.OpenCodeHttpClientConfig;
6+
import io.github.easy4j.opencode.api.mapper.ChatMessageMapper;
7+
import io.github.easy4j.opencode.api.model.ChatRequest;
8+
import io.github.easy4j.opencode.api.model.ChatResponse;
9+
import io.github.easy4j.opencode.api.model.ChatStreamingResponse;
10+
import io.github.easy4j.opencode.api.model.Event;
11+
import io.github.easy4j.opencode.api.model.PromptRequest;
12+
import io.github.easy4j.opencode.api.model.PromptResult;
13+
import okhttp3.OkHttpClient;
14+
15+
import java.util.Map;
16+
import java.util.Objects;
17+
import java.util.concurrent.BlockingQueue;
18+
import java.util.concurrent.ExecutorService;
19+
import java.util.concurrent.LinkedBlockingQueue;
20+
import java.util.concurrent.RejectedExecutionException;
21+
import java.util.concurrent.ThreadPoolExecutor;
22+
import java.util.concurrent.TimeUnit;
23+
import java.util.concurrent.atomic.AtomicInteger;
24+
import java.util.function.Consumer;
25+
26+
/**
27+
* OpenCode 聊天场景客户端,统一提供完整响应与流式响应。
28+
*
29+
* <p>普通 Prompt/Chat 调用继承自 {@link OpenCodeHttpClient};事件订阅是流式聊天的
30+
* 内部实现细节,业务调用方通过本类即可完成两种响应模式。</p>
31+
*/
32+
public class OpenCodeChatClient extends OpenCodeHttpClient {
33+
34+
private final OpenCodeHttpClientConfig config;
35+
private final OpenCodeSseClient eventClient;
36+
private final ExecutorService streamExecutor;
37+
38+
public OpenCodeChatClient(OpenCodeHttpClientConfig config) {
39+
this(config, new ObjectMapper(), null);
40+
}
41+
42+
public OpenCodeChatClient(OpenCodeHttpClientConfig config, ObjectMapper objectMapper,
43+
OkHttpClient httpClient) {
44+
super(config, objectMapper, httpClient);
45+
this.config = Objects.requireNonNull(config, "config");
46+
this.eventClient = new OpenCodeSseClient(config, objectMapper, getOkHttpClient());
47+
this.streamExecutor = createStreamExecutor(config);
48+
}
49+
50+
private static ExecutorService createStreamExecutor(OpenCodeHttpClientConfig config) {
51+
int corePoolSize = Math.max(1, config.getStreamCorePoolSize());
52+
int maxPoolSize = Math.max(corePoolSize, config.getStreamMaxPoolSize());
53+
AtomicInteger threadIndex = new AtomicInteger();
54+
return new ThreadPoolExecutor(corePoolSize, maxPoolSize,
55+
Math.max(1L, config.getStreamKeepAliveMillis()), TimeUnit.MILLISECONDS,
56+
new LinkedBlockingQueue<>(Math.max(1, config.getStreamQueueCapacity())), runnable -> {
57+
Thread thread = new Thread(runnable,
58+
"opencode-stream-consumer-" + threadIndex.incrementAndGet());
59+
thread.setDaemon(true);
60+
return thread;
61+
}, new ThreadPoolExecutor.AbortPolicy());
62+
}
63+
64+
public ChatResponse chatCompletion(String sessionId, ChatRequest request) {
65+
PromptResult result = prompt(sessionId, ChatMessageMapper.toPromptRequest(request));
66+
return ChatMessageMapper.toChatResponse(result);
67+
}
68+
69+
public ChatResponse chatCompletionWithSession(ChatRequest request, String sessionKey) {
70+
PromptResult result = chatCompletionWithSession(ChatMessageMapper.toPromptRequest(request), sessionKey);
71+
return ChatMessageMapper.toChatResponse(result);
72+
}
73+
74+
public ChatResponse chatCompletionWithSession(ChatRequest request, String sessionKey,
75+
HttpCallCancellation cancellation) {
76+
PromptResult result = chatCompletionWithSession(
77+
ChatMessageMapper.toPromptRequest(request), sessionKey, cancellation);
78+
return ChatMessageMapper.toChatResponse(result);
79+
}
80+
81+
public ChatStreamingResponse chatCompletionStream(ChatRequest request, String sessionKey) {
82+
return chatCompletionStream(request, sessionKey, null, null);
83+
}
84+
85+
public ChatStreamingResponse chatCompletionStream(ChatRequest request, String sessionKey,
86+
OpenCodeRequestContext context) {
87+
return chatCompletionStream(request, sessionKey, context, null);
88+
}
89+
90+
/** 在事件订阅启动前绑定增量回调,避免丢失首批分片。 */
91+
public ChatStreamingResponse chatCompletionStream(ChatRequest request, String sessionKey,
92+
OpenCodeRequestContext context,
93+
Consumer<String> deltaConsumer) {
94+
String sessionId = ensureSession(sessionKey, context);
95+
PromptRequest promptRequest = ChatMessageMapper.toPromptRequest(request);
96+
ChatStreamingResponse stream = new ChatStreamingResponse().onDelta(deltaConsumer);
97+
OpenCodeSseClient.QueueSubscription subscription = eventClient.subscribeQueueSubscription(context);
98+
BlockingQueue<Event> queue = subscription.getQueue();
99+
100+
try {
101+
streamExecutor.submit(() -> consumeEvents(sessionId, queue, subscription, stream));
102+
} catch (RejectedExecutionException error) {
103+
subscription.close();
104+
stream.fail(new IllegalStateException("OpenCode stream executor is full", error));
105+
return stream;
106+
}
107+
108+
try {
109+
if (!promptAsync(sessionId, promptRequest, context)) {
110+
subscription.close();
111+
stream.fail(new IllegalStateException("OpenCode async prompt was rejected"));
112+
}
113+
} catch (RuntimeException error) {
114+
subscription.close();
115+
stream.fail(error);
116+
}
117+
return stream;
118+
}
119+
120+
private void consumeEvents(String sessionId, BlockingQueue<Event> queue,
121+
OpenCodeSseClient.QueueSubscription subscription,
122+
ChatStreamingResponse stream) {
123+
try {
124+
long timeoutMillis = Math.max(1L, config.getReadTimeoutMillis());
125+
long deadline = System.currentTimeMillis() + timeoutMillis;
126+
while (!stream.isDone() && System.currentTimeMillis() < deadline) {
127+
Event event = queue.poll(3, TimeUnit.SECONDS);
128+
if (Objects.isNull(event) || !matchesSession(event, sessionId)) {
129+
continue;
130+
}
131+
String type = event.getType();
132+
if (Objects.isNull(type)) {
133+
continue;
134+
}
135+
if (type.contains("text.delta") || type.contains("message.part.updated")) {
136+
stream.acceptDelta(extractDeltaText(event));
137+
}
138+
if (type.contains("session.status") || type.contains("session.idle")) {
139+
String status = Objects.toString(event.getProperties().get("status"), null);
140+
if (Objects.equals("idle", status) || type.contains("idle")) {
141+
stream.finish();
142+
return;
143+
}
144+
}
145+
if (type.contains("session.error")) {
146+
stream.fail(new IllegalStateException(
147+
Objects.toString(event.getProperties().get("error"), "unknown error")));
148+
return;
149+
}
150+
}
151+
if (!stream.isDone()) {
152+
stream.fail(new IllegalStateException("Stream timed out for session: " + sessionId));
153+
}
154+
} catch (InterruptedException error) {
155+
Thread.currentThread().interrupt();
156+
stream.fail(error);
157+
} catch (RuntimeException error) {
158+
stream.fail(error);
159+
} finally {
160+
subscription.close();
161+
}
162+
}
163+
164+
private boolean matchesSession(Event event, String sessionId) {
165+
return Objects.nonNull(event.getProperties())
166+
&& Objects.equals(sessionId, Objects.toString(event.getProperties().get("sessionID"), null));
167+
}
168+
169+
private String extractDeltaText(Event event) {
170+
if (Objects.isNull(event.getProperties())) {
171+
return null;
172+
}
173+
Object part = event.getProperties().get("part");
174+
if (part instanceof Map) {
175+
Object text = ((Map<?, ?>) part).get("text");
176+
if (Objects.nonNull(text)) {
177+
return text.toString();
178+
}
179+
}
180+
return Objects.toString(event.getProperties().get("delta"), null);
181+
}
182+
183+
/** 原始事件客户端,仅供非聊天事件等高级场景使用。 */
184+
public OpenCodeSseClient events() {
185+
return eventClient;
186+
}
187+
188+
@Override
189+
public void close() {
190+
streamExecutor.shutdownNow();
191+
eventClient.close();
192+
super.close();
193+
}
194+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.github.easy4j.opencode;
2+
3+
import io.github.easy4j.opencode.api.OpenCodeChatClient;
4+
import io.github.easy4j.opencode.api.OpenCodeHttpClient;
5+
import org.junit.jupiter.api.Test;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
9+
class OpenCodeChatClientArchitectureTest {
10+
11+
@Test
12+
void shouldExposeUnifiedConfigAndChatScenarioClient() {
13+
OpenCodeHttpClientConfig config = new OpenCodeHttpClientConfig();
14+
assertEquals(HttpResponseMode.BLOCKING, config.getMode());
15+
config.setServerUrl("http://legacy-opencode");
16+
assertEquals("http://legacy-opencode", config.getBaseUrl());
17+
18+
try (OpenCodeChatClient client = new OpenCodeChatClient(config)) {
19+
assertEquals(OpenCodeHttpClient.class, client.getClass().getSuperclass());
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)