feat(chat): add configurable streaming output
This commit is contained in:
@@ -50,6 +50,7 @@ const runtimeForm = reactive({
|
|||||||
presencePenalty: null as number | null,
|
presencePenalty: null as number | null,
|
||||||
frequencyPenalty: null as number | null,
|
frequencyPenalty: null as number | null,
|
||||||
maxToken: 8192 as number | null,
|
maxToken: 8192 as number | null,
|
||||||
|
streamEnabled: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
|
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
|
||||||
@@ -104,6 +105,7 @@ function applyRuntimeConfig(value: AgentRuntimeConfig) {
|
|||||||
presencePenalty: value.presencePenalty,
|
presencePenalty: value.presencePenalty,
|
||||||
frequencyPenalty: value.frequencyPenalty,
|
frequencyPenalty: value.frequencyPenalty,
|
||||||
maxToken: value.maxToken,
|
maxToken: value.maxToken,
|
||||||
|
streamEnabled: value.streamEnabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +134,7 @@ async function saveRuntimeConfig() {
|
|||||||
presencePenalty: runtimeForm.presencePenalty,
|
presencePenalty: runtimeForm.presencePenalty,
|
||||||
frequencyPenalty: runtimeForm.frequencyPenalty,
|
frequencyPenalty: runtimeForm.frequencyPenalty,
|
||||||
maxToken: runtimeForm.maxToken,
|
maxToken: runtimeForm.maxToken,
|
||||||
|
streamEnabled: runtimeForm.streamEnabled,
|
||||||
});
|
});
|
||||||
applyRuntimeConfig(saved);
|
applyRuntimeConfig(saved);
|
||||||
const model = models.value.find((item) => item.id === saved.modelId);
|
const model = models.value.find((item) => item.id === saved.modelId);
|
||||||
@@ -368,6 +371,21 @@ function errorMessage(error: unknown, fallback: string) {
|
|||||||
v-model:max-token="runtimeForm.maxToken"
|
v-model:max-token="runtimeForm.maxToken"
|
||||||
:disabled="!runtimeConfig?.modelId"
|
:disabled="!runtimeConfig?.modelId"
|
||||||
/>
|
/>
|
||||||
|
<el-form-item class="agent-stream-setting">
|
||||||
|
<div class="agent-stream-setting-copy">
|
||||||
|
<strong>用户端流式输出</strong>
|
||||||
|
<span>开启后回答正文会边生成边展示;关闭后等待模型完成,再一次性显示完整回答。</span>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
v-model="runtimeForm.streamEnabled"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
active-text="开启"
|
||||||
|
inactive-text="关闭"
|
||||||
|
inline-prompt
|
||||||
|
:disabled="!runtimeConfig?.modelId"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
</section>
|
</section>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|||||||
@@ -1017,6 +1017,46 @@ textarea {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-stream-setting {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #dce7e3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-stream-setting > .el-form-item__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-stream-setting-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-stream-setting-copy strong {
|
||||||
|
color: #263832;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-stream-setting-copy span {
|
||||||
|
color: #667a73;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.agent-stream-setting > .el-form-item__content {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.agent-parameter-label {
|
.agent-parameter-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export interface AgentGenerationConfig {
|
|||||||
presencePenalty: number | null;
|
presencePenalty: number | null;
|
||||||
frequencyPenalty: number | null;
|
frequencyPenalty: number | null;
|
||||||
maxToken: number;
|
maxToken: number;
|
||||||
|
streamEnabled: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentRuntimeConfig extends AgentGenerationConfig {
|
export interface AgentRuntimeConfig extends AgentGenerationConfig {
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ def save_agent_runtime_config(
|
|||||||
model.presence_penalty = payload.presencePenalty
|
model.presence_penalty = payload.presencePenalty
|
||||||
model.frequency_penalty = payload.frequencyPenalty
|
model.frequency_penalty = payload.frequencyPenalty
|
||||||
model.max_token = payload.maxToken
|
model.max_token = payload.maxToken
|
||||||
|
model.stream_enabled = payload.streamEnabled
|
||||||
db.add(model)
|
db.add(model)
|
||||||
OperationLogService.write(
|
OperationLogService.write(
|
||||||
db,
|
db,
|
||||||
@@ -436,6 +437,7 @@ def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
|
|||||||
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
|
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
|
||||||
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
|
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
|
||||||
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
|
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
|
||||||
|
"streamEnabled": model.stream_enabled if model is not None else 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,15 @@ def completions(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
return StreamingResponse(_chat_stream(payload, db, current_user), media_type="text/event-stream")
|
return StreamingResponse(
|
||||||
|
_chat_stream(payload, db, current_user),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
@router.post("/stop")
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class AgentRuntimeConfigSaveRequest(BaseModel):
|
|||||||
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
maxToken: int = Field(default=8192, ge=256, le=100000)
|
maxToken: int = Field(default=8192, ge=256, le=100000)
|
||||||
|
streamEnabled: int = Field(default=1, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
class ModelSaveRequest(BaseModel):
|
class ModelSaveRequest(BaseModel):
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class ModelStreamService:
|
|||||||
model_id=model.id if model is not None else None,
|
model_id=model.id if model is not None else None,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
input_token=_rough_token_count(rag_result.prompt),
|
input_token=_rough_token_count(rag_result.prompt),
|
||||||
chunks=_chunk_text(_mock_answer(rag_result)),
|
chunks=_display_chunks(model, _mock_answer(rag_result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
|
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
|
||||||
@@ -68,7 +68,7 @@ class ModelStreamService:
|
|||||||
model_id=model.id if model is not None else None,
|
model_id=model.id if model is not None else None,
|
||||||
model_name=model.model_name if model is not None else "no-hit",
|
model_name=model.model_name if model is not None else "no-hit",
|
||||||
input_token=_rough_token_count(rag_result.prompt),
|
input_token=_rough_token_count(rag_result.prompt),
|
||||||
chunks=_chunk_text(NO_HIT_ANSWER),
|
chunks=_display_chunks(model, NO_HIT_ANSWER),
|
||||||
)
|
)
|
||||||
|
|
||||||
if model is None:
|
if model is None:
|
||||||
@@ -94,7 +94,7 @@ class ModelStreamService:
|
|||||||
model_id=model.id if model is not None else None,
|
model_id=model.id if model is not None else None,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
input_token=_rough_token_count(rag_result.prompt),
|
input_token=_rough_token_count(rag_result.prompt),
|
||||||
chunks=_async_chunk_text(_mock_answer(rag_result)),
|
chunks=_async_display_chunks(model, _mock_answer(rag_result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
|
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
|
||||||
@@ -102,7 +102,7 @@ class ModelStreamService:
|
|||||||
model_id=model.id if model is not None else None,
|
model_id=model.id if model is not None else None,
|
||||||
model_name=model.model_name if model is not None else "no-hit",
|
model_name=model.model_name if model is not None else "no-hit",
|
||||||
input_token=_rough_token_count(rag_result.prompt),
|
input_token=_rough_token_count(rag_result.prompt),
|
||||||
chunks=_async_chunk_text(NO_HIT_ANSWER),
|
chunks=_async_display_chunks(model, NO_HIT_ANSWER),
|
||||||
)
|
)
|
||||||
|
|
||||||
if model is None:
|
if model is None:
|
||||||
@@ -130,12 +130,12 @@ def _get_enabled_model(db: Session) -> ModelConfig | None:
|
|||||||
def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
|
def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
|
||||||
api_type = model.api_type or "openai_compatible"
|
api_type = model.api_type or "openai_compatible"
|
||||||
if model.stream_enabled != 1:
|
if model.stream_enabled != 1:
|
||||||
return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge))
|
return iter((_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge),))
|
||||||
if api_type == "anthropic_messages":
|
if api_type == "anthropic_messages":
|
||||||
return _stream_anthropic_messages(model, rag_result)
|
return _stream_anthropic_messages(model, rag_result)
|
||||||
if api_type == "openai_compatible":
|
if api_type == "openai_compatible":
|
||||||
return _stream_openai_compatible_model(model, rag_result)
|
return _stream_openai_compatible_model(model, rag_result)
|
||||||
return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge))
|
return iter((_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge),))
|
||||||
|
|
||||||
|
|
||||||
async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
|
async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
|
||||||
@@ -144,8 +144,7 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu
|
|||||||
answer = await asyncio.to_thread(
|
answer = await asyncio.to_thread(
|
||||||
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
|
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
|
||||||
)
|
)
|
||||||
async for chunk in _async_chunk_text(answer):
|
yield answer
|
||||||
yield chunk
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if api_type == "anthropic_messages":
|
if api_type == "anthropic_messages":
|
||||||
@@ -161,8 +160,7 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu
|
|||||||
answer = await asyncio.to_thread(
|
answer = await asyncio.to_thread(
|
||||||
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
|
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
|
||||||
)
|
)
|
||||||
async for chunk in _async_chunk_text(answer):
|
yield answer
|
||||||
yield chunk
|
|
||||||
|
|
||||||
|
|
||||||
def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
|
def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
|
||||||
@@ -479,6 +477,19 @@ def _chunk_text(text: str, *, chunk_size: int = 12) -> Iterator[str]:
|
|||||||
async def _async_chunk_text(text: str, *, chunk_size: int = 12) -> AsyncIterator[str]:
|
async def _async_chunk_text(text: str, *, chunk_size: int = 12) -> AsyncIterator[str]:
|
||||||
for index in range(0, len(text), chunk_size):
|
for index in range(0, len(text), chunk_size):
|
||||||
yield text[index : index + chunk_size]
|
yield text[index : index + chunk_size]
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _display_chunks(model: ModelConfig | None, text: str) -> Iterator[str]:
|
||||||
|
return _chunk_text(text) if model is None or model.stream_enabled == 1 else iter((text,))
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_display_chunks(model: ModelConfig | None, text: str) -> AsyncIterator[str]:
|
||||||
|
if model is not None and model.stream_enabled != 1:
|
||||||
|
yield text
|
||||||
|
return
|
||||||
|
async for chunk in _async_chunk_text(text):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
def _string_or_empty(value: Any) -> str:
|
def _string_or_empty(value: Any) -> str:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import asyncio
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -9,7 +11,7 @@ from app.models import Base
|
|||||||
from app.models.admin import Admin
|
from app.models.admin import Admin
|
||||||
from app.models.ai_config import ModelConfig
|
from app.models.ai_config import ModelConfig
|
||||||
from app.schemas.admin import AgentRuntimeConfigSaveRequest
|
from app.schemas.admin import AgentRuntimeConfigSaveRequest
|
||||||
from app.services.model_stream_service import _openai_stream_payload
|
from app.services.model_stream_service import _openai_stream_payload, _stream_configured_model_async
|
||||||
from app.services.model_service import ModelClientService, _max_output_tokens
|
from app.services.model_service import ModelClientService, _max_output_tokens
|
||||||
from app.services.rag_service import RagResult
|
from app.services.rag_service import RagResult
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ def test_runtime_config_defaults_to_long_answer_safe_max_tokens():
|
|||||||
assert result["modelId"] == model.id
|
assert result["modelId"] == model.id
|
||||||
assert result["modelName"] == "正式模型"
|
assert result["modelName"] == "正式模型"
|
||||||
assert result["maxToken"] == 8192
|
assert result["maxToken"] == 8192
|
||||||
|
assert result["streamEnabled"] == 1
|
||||||
assert _max_output_tokens(model) == 8192
|
assert _max_output_tokens(model) == 8192
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ def test_saved_runtime_config_is_persisted_on_enabled_model():
|
|||||||
presencePenalty=0.2,
|
presencePenalty=0.2,
|
||||||
frequencyPenalty=0.4,
|
frequencyPenalty=0.4,
|
||||||
maxToken=12000,
|
maxToken=12000,
|
||||||
|
streamEnabled=0,
|
||||||
),
|
),
|
||||||
db=db,
|
db=db,
|
||||||
current_admin=admin,
|
current_admin=admin,
|
||||||
@@ -89,6 +93,8 @@ def test_saved_runtime_config_is_persisted_on_enabled_model():
|
|||||||
assert model.top_k == 40
|
assert model.top_k == 40
|
||||||
assert model.presence_penalty == Decimal("0.20")
|
assert model.presence_penalty == Decimal("0.20")
|
||||||
assert model.frequency_penalty == Decimal("0.40")
|
assert model.frequency_penalty == Decimal("0.40")
|
||||||
|
assert model.stream_enabled == 0
|
||||||
|
assert result["streamEnabled"] == 0
|
||||||
|
|
||||||
payload = _openai_stream_payload(
|
payload = _openai_stream_payload(
|
||||||
model,
|
model,
|
||||||
@@ -101,6 +107,27 @@ def test_saved_runtime_config_is_persisted_on_enabled_model():
|
|||||||
assert payload["frequency_penalty"] == 0.4
|
assert payload["frequency_penalty"] == 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_stream_returns_one_complete_chunk():
|
||||||
|
model = _model()
|
||||||
|
model.stream_enabled = 0
|
||||||
|
answer = "完整回答" * 20
|
||||||
|
rag_result = RagResult(
|
||||||
|
question="测试",
|
||||||
|
knowledge_scopes=[],
|
||||||
|
chunks=[],
|
||||||
|
prompt="测试",
|
||||||
|
allow_general_knowledge=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
return [item async for item in _stream_configured_model_async(model, rag_result)]
|
||||||
|
|
||||||
|
with patch("app.services.model_stream_service._call_configured_model", return_value=answer):
|
||||||
|
chunks = asyncio.run(collect())
|
||||||
|
|
||||||
|
assert chunks == [answer]
|
||||||
|
|
||||||
|
|
||||||
def test_agent_debug_does_not_truncate_long_answer(monkeypatch):
|
def test_agent_debug_does_not_truncate_long_answer(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.model_service._call_configured_model",
|
"app.services.model_service._call_configured_model",
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ def test_queued_chat_reports_position_and_completes(monkeypatch):
|
|||||||
assert released is True
|
assert released is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_streaming_response_disables_proxy_buffering():
|
||||||
|
payload = type("Payload", (), {"sessionId": 1, "message": "问题"})()
|
||||||
|
response = chat.completions(payload, object(), object())
|
||||||
|
|
||||||
|
assert response.headers["cache-control"] == "no-cache, no-transform"
|
||||||
|
assert response.headers["x-accel-buffering"] == "no"
|
||||||
|
assert response.media_type == "text/event-stream"
|
||||||
|
|
||||||
|
|
||||||
def test_sensitive_system_config_never_returns_plaintext():
|
def test_sensitive_system_config_never_returns_plaintext():
|
||||||
from app.api.admin_settings import _config_dict
|
from app.api.admin_settings import _config_dict
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,47 @@ const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
|
|||||||
|
|
||||||
const answer = computed(() => {
|
const answer = computed(() => {
|
||||||
if (props.role === "user") return props.content;
|
if (props.role === "user") return props.content;
|
||||||
// 部分模型会产生嵌套 think 标签,使用贪婪匹配移除完整思考区,再清理流式残留标签。
|
return stripStreamingReasoning(props.content).replace(/^\s+/, "");
|
||||||
let content = props.content.replace(/<think>[\s\S]*<\/think>/gi, "");
|
|
||||||
content = content.replace(/<think>[\s\S]*$/i, "");
|
|
||||||
content = content.replace(/<\/?think>/gi, "");
|
|
||||||
return content.trim();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderedContent = computed(() =>
|
const hasAnswer = computed(() => answer.value.trim().length > 0);
|
||||||
props.role === "assistant" ? markdown.render(answer.value) : answer.value,
|
|
||||||
);
|
const renderedContent = computed(() => {
|
||||||
|
if (props.role !== "assistant") return answer.value;
|
||||||
|
return hasAnswer.value ? markdown.render(answer.value) : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function stripStreamingReasoning(content: string) {
|
||||||
|
const lower = content.toLowerCase();
|
||||||
|
let visible = "";
|
||||||
|
let cursor = 0;
|
||||||
|
let reasoningDepth = 0;
|
||||||
|
while (cursor < content.length) {
|
||||||
|
const tagStart = content.indexOf("<", cursor);
|
||||||
|
if (tagStart === -1) {
|
||||||
|
if (reasoningDepth === 0) visible += content.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (reasoningDepth === 0) visible += content.slice(cursor, tagStart);
|
||||||
|
const tail = lower.slice(tagStart);
|
||||||
|
const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
|
||||||
|
if (openTag) {
|
||||||
|
reasoningDepth += 1;
|
||||||
|
cursor = tagStart + openTag[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const closeTag = tail.match(/^<\/think\s*>/);
|
||||||
|
if (closeTag) {
|
||||||
|
reasoningDepth = Math.max(0, reasoningDepth - 1);
|
||||||
|
cursor = tagStart + closeTag[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
|
||||||
|
if (reasoningDepth === 0) visible += "<";
|
||||||
|
cursor = tagStart + 1;
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
const displayTime = computed(() => {
|
const displayTime = computed(() => {
|
||||||
// 消息服务返回的是 UTC 无时区字符串,补齐时区后再按用户本地时间展示。
|
// 消息服务返回的是 UTC 无时区字符串,补齐时区后再按用户本地时间展示。
|
||||||
|
|||||||
@@ -107,12 +107,12 @@ export async function streamChat(
|
|||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const parts = buffer.split("\n\n");
|
const parts = buffer.split(/\r?\n\r?\n/);
|
||||||
buffer = parts.pop() ?? "";
|
buffer = parts.pop() ?? "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const line = part.trim();
|
const dataLines = part.split(/\r?\n/).filter((line) => line.startsWith("data:"));
|
||||||
if (!line.startsWith("data:")) continue;
|
if (!dataLines.length) continue;
|
||||||
const payload = line.slice(5).trim();
|
const payload = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim();
|
||||||
if (payload === "[DONE]") return;
|
if (payload === "[DONE]") return;
|
||||||
const parsed = JSON.parse(payload) as { type?: string; content?: string; message?: string };
|
const parsed = JSON.parse(payload) as { type?: string; content?: string; message?: string };
|
||||||
if (parsed.type === "queued" || parsed.type === "generating") {
|
if (parsed.type === "queued" || parsed.type === "generating") {
|
||||||
|
|||||||
Reference in New Issue
Block a user