Skip to content

Commit 57af0b5

Browse files
committed
refactor: expose chat scenario client
1 parent 5530d36 commit 57af0b5

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;
@@ -60,6 +61,7 @@ public class OpenCodeClient implements AutoCloseable {
6061

6162
private final OpenCodeClientConfig config;
6263
private final OpenCodeHttpClient httpClient;
64+
private final OpenCodeChatClient chatClient;
6365
private final OpenCodeSseClient sseClient;
6466
private final OpenCodeCli cli;
6567
private final ExecutorService streamExecutor;
@@ -117,14 +119,15 @@ public OpenCodeClient(OpenCodeHttpClientConfig httpConfig, OpenCodeCliConfig cli
117119

118120
// HTTP 子系统初始化
119121
if (httpEnabled) {
120-
this.httpClient = new OpenCodeHttpClient(httpConfig, objectMapper, httpClient);
121-
this.sseClient = new OpenCodeSseClient(httpConfig, objectMapper,
122-
this.httpClient.getOkHttpClient());
122+
this.chatClient = new OpenCodeChatClient(httpConfig, objectMapper, httpClient);
123+
this.httpClient = this.chatClient;
124+
this.sseClient = this.chatClient.events();
123125
} else {
124126
this.httpClient = null;
127+
this.chatClient = null;
125128
this.sseClient = null;
126129
}
127-
this.streamExecutor = createStreamExecutor(httpConfig);
130+
this.streamExecutor = this.chatClient == null ? createStreamExecutor(httpConfig) : null;
128131

129132
// CLI 子系统初始化
130133
if (cliEnabled) {
@@ -160,9 +163,10 @@ public OpenCodeClient(OpenCodeClientConfig config,
160163
OpenCodeCli cli) {
161164
this.config = Objects.requireNonNull(config, "config");
162165
this.httpClient = httpClient;
166+
this.chatClient = httpClient instanceof OpenCodeChatClient ? (OpenCodeChatClient) httpClient : null;
163167
this.sseClient = sseClient;
164168
this.cli = cli;
165-
this.streamExecutor = createStreamExecutor(config.getHttp());
169+
this.streamExecutor = this.chatClient == null ? createStreamExecutor(config.getHttp()) : null;
166170
}
167171

168172
private static ExecutorService createStreamExecutor(OpenCodeHttpClientConfig config) {
@@ -378,6 +382,9 @@ public ChatStreamingResponse chatCompletionStream(ChatRequest request, String se
378382
public ChatStreamingResponse chatCompletionStream(ChatRequest request, String sessionKey,
379383
OpenCodeRequestContext context,
380384
Consumer<String> deltaConsumer) {
385+
if (chatClient != null) {
386+
return chatClient.chatCompletionStream(request, sessionKey, context, deltaConsumer);
387+
}
381388
String sessionId = httpClient.ensureSession(sessionKey, context);
382389
PromptRequest promptRequest = ChatMessageMapper.toPromptRequest(request);
383390

@@ -520,6 +527,13 @@ public HealthStatus health() {
520527
// SSE 事件流
521528
// ============================================================
522529

530+
/** 获取统一的 OpenCode 聊天场景客户端。 */
531+
public OpenCodeChatClient chat() {
532+
return chatClient;
533+
}
534+
535+
/** @deprecated 业务聊天请使用 {@link #chat()},这里只保留原始事件订阅兼容入口。 */
536+
@Deprecated
523537
public OpenCodeSseClient sse() {
524538
return sseClient;
525539
}
@@ -970,7 +984,7 @@ public io.github.easy4j.opencode.cli.OpenCodeCliResult cliPr(int number) {
970984

971985
@Override
972986
public void close() {
973-
streamExecutor.shutdownNow();
987+
if (streamExecutor != null) streamExecutor.shutdownNow();
974988
if (httpClient != null) httpClient.close();
975989
if (sseClient != null) sseClient.close();
976990
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,19 @@ public class OpenCodeHttpClientConfig {
3939
/**
4040
* OpenCode Server 根地址,例如 {@code http://localhost:4096}。
4141
*/
42-
private String serverUrl = "http://localhost:4096";
42+
private String baseUrl = "http://localhost:4096";
43+
44+
/** @deprecated 使用 {@link #getBaseUrl()}。 */
45+
@Deprecated
46+
public String getServerUrl() {
47+
return baseUrl;
48+
}
49+
50+
/** @deprecated 使用 {@link #setBaseUrl(String)}。 */
51+
@Deprecated
52+
public void setServerUrl(String value) {
53+
this.baseUrl = value;
54+
}
4355

4456
/**
4557
* 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)