Files
connectai/src/agent/llm/streamChatOnce.ts
T
koriweb 47b3b9f93a v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진
- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드
  검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화),
  lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화
- v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론
  토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지)
- v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑,
  Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식)
- v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계
- v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임)
- v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트
- v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출,
  coreChat 지원 — 워크플로우·QA도 구독 모델 가능)
- v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시)
- v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답),
  파트별 실패 격리, 합성 타임아웃 120→300초

검증: tsc 무오류 + jest 888 통과 + esbuild 정상

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:02:15 +09:00

141 lines
6.0 KiB
TypeScript

import * as vscode from 'vscode';
import { LiveReasoningFilter } from '../../lib/contextBuilders/liveReasoningFilter';
import { logError, summarizeText } from '../../utils';
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
export interface StreamChatOnceDeps {
options: AgentExecutorOptions;
getWebview: () => vscode.Webview | undefined;
isStaleRun: (runId: number) => boolean;
createStreamingRequest: (params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
/** Dynamic output-token cap computed from the remaining context budget. */
maxTokens?: number;
/** Model context window in tokens (used for Ollama's num_ctx). */
contextLength?: number;
}) => Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }>;
}
export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
runId: number;
useLmStudioSdk: boolean;
engine: 'lmstudio' | 'ollama';
ollamaUrl: string;
modelName: string;
messages: ChatMessage[];
temperature: number;
maxTokens: number;
contextLength: number;
contextOverflowPolicy: 'stopAtLimit' | 'truncateMiddle' | 'rollingWindow';
signal: AbortSignal;
postLiveDeltas: boolean;
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
let accumulated = '';
let finishStopReason: string | undefined;
// 라이브 표시 시 추론 구간(<think>/Harmony thought)은 토큰 단위로 차단 — agent.ts 본 스트림과 동일.
const liveFilter = new LiveReasoningFilter();
const post = (token: string) => {
if (params.postLiveDeltas && token) {
const visible = liveFilter.push(token);
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
}
};
if (params.useLmStudioSdk) {
try {
const stream = deps.options.lmStudioStreamer!.stream({
modelName: params.modelName,
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
temperature: params.temperature,
maxTokens: params.maxTokens,
contextOverflowPolicy: params.contextOverflowPolicy,
...lmStudioSamplingFromConfig(),
...lmStudioRespondExtrasFromConfig(),
signal: params.signal,
});
for await (const { token, stopReason } of stream) {
if (deps.isStaleRun(params.runId)) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
if (token) {
accumulated += token;
post(token);
}
if (stopReason) finishStopReason = stopReason;
}
} catch (err: any) {
if (err?.name === 'AbortError' || params.signal.aborted) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
const msg = err?.message ?? String(err);
if (/context\s*length|contextlengthreached|exceed|too\s*long/i.test(msg)) {
finishStopReason = 'contextLengthReached';
}
logError('streamChatOnce SDK path failed.', { engine: params.engine, error: msg });
throw err;
}
return { text: accumulated, stopReason: finishStopReason, aborted: false };
}
const request = await deps.createStreamingRequest({
baseUrl: params.ollamaUrl,
modelName: params.modelName,
reqMessages: params.messages,
temperature: params.temperature,
maxTokens: params.maxTokens,
contextLength: params.contextLength,
});
const reader = request.response.body?.getReader();
if (!reader) throw new Error('Response body is not readable.');
const decoder = new TextDecoder();
let buffer = '';
const consumeJsonLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') return;
try {
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
const json = JSON.parse(raw);
const token = params.engine === 'lmstudio'
? json.choices?.[0]?.delta?.content || ''
: json.message?.content || json.response || '';
if (token) {
accumulated += token;
post(token);
}
const fr = params.engine === 'lmstudio'
? json.choices?.[0]?.finish_reason
: (json.done_reason ?? (json.done === true ? 'stop' : undefined));
if (fr) finishStopReason = fr;
} catch (e: any) {
logError('streamChatOnce: failed to parse chunk.', { engine: params.engine, chunk: summarizeText(trimmed, 200), error: e?.message ?? String(e) });
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (deps.isStaleRun(params.runId)) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) consumeJsonLine(line);
}
if (buffer.trim()) consumeJsonLine(buffer);
} catch (err: any) {
if (err?.name === 'AbortError') {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
logError('streamChatOnce REST path failed.', { engine: params.engine, error: err?.message ?? String(err) });
throw err;
} finally {
try { reader.releaseLock(); } catch { /* already released on abort */ }
}
return { text: accumulated, stopReason: finishStopReason, aborted: false };
}