mirror of
https://github.com/golutra/golutra.git
synced 2026-09-01 15:33:55 +08:00
feat(message): 引入消息处理管线(标准化/策略/节流/可靠性/派发)并接入语义 worker
This commit is contained in:
Binary file not shown.
@@ -1,5 +1,59 @@
|
||||
# golutra
|
||||
|
||||
**使用赛博监工系统,指挥你的 AI 牛马。**
|
||||
**Cyber Overseer System: Command your AI workforce.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.golutra.com/" target="_blank" rel="noopener noreferrer">
|
||||
<img width="100" src="./src-tauri/icons/icon.png" alt="golutra logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/golutra/golutra/releases"><img src="https://img.shields.io/github/v/release/golutra/golutra?label=release" alt="release"></a>
|
||||
<a href="https://www.golutra.com/"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS-2f7af8" alt="platform"></a>
|
||||
<a href="https://mariadb.com/bsl11/"><img src="https://img.shields.io/badge/license-BSL%201.1-ff9f1a" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#english">English</a> ·
|
||||
<a href="#中文">中文</a> ·
|
||||
<a href="https://www.golutra.com/">Website</a> ·
|
||||
<a href="https://github.com/golutra/golutra/releases">Releases</a> ·
|
||||
<a href="https://youtu.be/KpAgetjYfoY">Video (EN)</a> ·
|
||||
<a href="https://www.bilibili.com/video/BV1qcfhBFEpP/?spm_id_from=333.1387.homepage.video_card.click">视频 (中文)</a>
|
||||
</p>
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center"><img src="./assets/readme/icons/claude-code.png" alt="Claude Code" height="48" /></td>
|
||||
<td align="center"><img src="./assets/readme/icons/gemini.png" alt="Gemini CLI" height="48" /></td>
|
||||
<td align="center"><img src="./assets/readme/icons/codex.png" alt="Codex CLI" height="48" /></td>
|
||||
<td align="center"><img src="./assets/readme/icons/opencode.svg" alt="OpenCode" width="36" /></td>
|
||||
<td align="center"><img src="./assets/readme/icons/qwen.png" alt="Qwen Code" height="48" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Claude Code</td>
|
||||
<td align="center">Gemini CLI</td>
|
||||
<td align="center">Codex CLI</td>
|
||||
<td align="center">OpenCode</td>
|
||||
<td align="center">Qwen Code</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
Keep your CLI. Orchestrate your AI workforce. <br />
|
||||
保留你熟悉的 CLI,编排你的 AI 员工。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/readme/golutra-concept-hero.png" alt="golutra concept art" width="960" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Tagline
|
||||
@@ -71,7 +125,7 @@ This repository is for source code storage and releases.
|
||||
|
||||
Business Email: [golutra@hotmail.com](mailto:golutra%40hotmail.com)
|
||||
Official Website: [https://www.golutra.com/](https://www.golutra.com/)
|
||||
Video: <https://youtu.be/DKKracLotg8>
|
||||
Video: <https://youtu.be/KpAgetjYfoY>
|
||||
Discord: [https://discord.gg/QyNVu56mpY](https://discord.gg/QyNVu56mpY)
|
||||
Security Policy: See [SECURITY.md](SECURITY.md)
|
||||
|
||||
@@ -81,13 +135,8 @@ This software is independently developed and maintained by [seekskyworld](https:
|
||||
|
||||
### Open Source Status
|
||||
|
||||
This project is now open source, and related code will be organized and opened in phases.
|
||||
|
||||
To protect my privacy, any parts involving server keys, account configuration, or other sensitive information will be sanitized and refactored before being gradually published to the repository.
|
||||
|
||||
I’m currently refactoring the architecture and separating modules that contain server keys, configs, and test data, which require careful review.
|
||||
I will open the full source as promised, but it needs time — this is two months of work, with many late nights spent on the architecture and details.
|
||||
So far the project has made no revenue; it’s driven purely by passion. Please allow a bit more time to open it properly.
|
||||
The source code is now open. Any parts involving server keys, account configuration, test data, or other sensitive information will be sanitized and refactored before being gradually published to the repository.
|
||||
This is two months of work, with many late nights spent on the architecture and details. It’s all to make the experience better; suggestions and bugs can be submitted on GitHub.
|
||||
|
||||
### What’s Next
|
||||
|
||||
@@ -146,6 +195,8 @@ Why this happens:
|
||||
|
||||
If the app still cannot be opened, please contact the publisher for support.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 标语
|
||||
@@ -226,11 +277,7 @@ golutra 是新一代多智能体工作空间,把你现有的 CLI 工具升级
|
||||
|
||||
### 开源状态
|
||||
|
||||
项目现已开源,并将持续分阶段整理与开放相关代码。
|
||||
为保障自己的隐私,涉及服务器密钥、账号配置、测试数据及其他敏感信息的部分,会先完成脱敏与重构,再逐步同步到仓库。
|
||||
我目前正在拆分架构与整理代码,这些模块需要逐一核对与隔离。
|
||||
我会按承诺完整开源,但这确实需要时间——这是我两个月的心血,很多个夜晚都在打磨架构与细节。
|
||||
到目前为止项目没有任何收入,全凭热爱推进。请给我一点时间,把它更稳妥、更完整地开放出来。
|
||||
项目源码已经开源,涉及服务器密钥、账号配置、测试数据及其他敏感信息的部分,会先完成脱敏与重构,再逐步同步到仓库,这是我两个月的心血,很多个夜晚都在打磨架构与细节。都是为了体验能更好,有相关建议和 bug 可以在 GitHub 提交。
|
||||
|
||||
### 后续发展
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Security Policy
|
||||
|
||||
If you discover a security vulnerability, please report it privately and do not open a public issue.
|
||||
If it’s urgent and hasn’t been noticed on GitHub, you can report it by email.
|
||||
|
||||
## Reporting
|
||||
- Email: golutra@hotmail.com
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 664 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0_1401_86274)">
|
||||
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86274)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86274">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,4 @@
|
||||
//! 消息服务层入口:承载聊天存储、消息写入与事件广播。
|
||||
|
||||
pub(crate) mod chat_db;
|
||||
pub(crate) mod pipeline;
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 分发解析阶段:确定投递目标与渠道。
|
||||
|
||||
use super::types::{DispatchPlan, MessageEnvelope};
|
||||
|
||||
pub(crate) fn plan_terminal(_envelope: &MessageEnvelope) -> Result<DispatchPlan, String> {
|
||||
// [TODO/message-service, 2026-01-26] 生成投递计划(目标、渠道、去向)。
|
||||
Ok(DispatchPlan {
|
||||
should_deliver: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! 消息服务流水线:按分层顺序执行业务投递链路。
|
||||
|
||||
mod dispatch;
|
||||
mod normalize;
|
||||
mod policy;
|
||||
mod reliability;
|
||||
mod throttle;
|
||||
mod types;
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::terminal_engine::models::TerminalMessagePayload;
|
||||
|
||||
use dispatch::plan_terminal;
|
||||
use normalize::normalize_terminal;
|
||||
use policy::evaluate_terminal;
|
||||
use reliability::{deliver_terminal_final, deliver_terminal_stream};
|
||||
use throttle::apply_terminal;
|
||||
|
||||
pub(crate) fn process_terminal_stream(
|
||||
app: &AppHandle,
|
||||
payload: TerminalMessagePayload,
|
||||
) -> Result<(), String> {
|
||||
let envelope = normalize_terminal(payload)?;
|
||||
let plan = plan_terminal(&envelope)?;
|
||||
let policy = evaluate_terminal(&envelope, &plan)?;
|
||||
let throttle = apply_terminal(&envelope, &plan, &policy)?;
|
||||
deliver_terminal_stream(app, &envelope, &plan, &policy, &throttle)
|
||||
}
|
||||
|
||||
pub(crate) fn process_terminal_final(
|
||||
app: &AppHandle,
|
||||
payload: TerminalMessagePayload,
|
||||
) -> Result<(), String> {
|
||||
let envelope = normalize_terminal(payload)?;
|
||||
let plan = plan_terminal(&envelope)?;
|
||||
let policy = evaluate_terminal(&envelope, &plan)?;
|
||||
let throttle = apply_terminal(&envelope, &plan, &policy)?;
|
||||
deliver_terminal_final(app, &envelope, &plan, &policy, &throttle)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 归一化阶段:统一消息格式与提取元信息。
|
||||
|
||||
use crate::terminal_engine::models::TerminalMessagePayload;
|
||||
|
||||
use super::types::MessageEnvelope;
|
||||
|
||||
pub(crate) fn normalize_terminal(payload: TerminalMessagePayload) -> Result<MessageEnvelope, String> {
|
||||
// [TODO/message-service, 2026-01-26] 补齐去重键、元信息抽取与格式标准化。
|
||||
Ok(MessageEnvelope { payload })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! 权限与策略阶段:DND、优先级与例外规则。
|
||||
|
||||
use super::types::{DispatchPlan, MessageEnvelope, PolicyDecision};
|
||||
|
||||
pub(crate) fn evaluate_terminal(
|
||||
_envelope: &MessageEnvelope,
|
||||
_plan: &DispatchPlan,
|
||||
) -> Result<PolicyDecision, String> {
|
||||
// [TODO/message-service, 2026-01-26] 权限校验、DND 与 @ 提及范围规则。
|
||||
Ok(PolicyDecision {
|
||||
allowed: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! 投递可靠性阶段:队列化、重试与失败补偿。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::message_service::chat_db::{chat_append_terminal_message, ChatDbManager};
|
||||
|
||||
use super::types::{DispatchPlan, MessageEnvelope, PolicyDecision, ThrottleDecision};
|
||||
|
||||
pub(crate) fn deliver_terminal_stream(
|
||||
app: &AppHandle,
|
||||
envelope: &MessageEnvelope,
|
||||
plan: &DispatchPlan,
|
||||
policy: &PolicyDecision,
|
||||
throttle: &ThrottleDecision,
|
||||
) -> Result<(), String> {
|
||||
if !plan.should_deliver || !policy.allowed || !throttle.allowed {
|
||||
return Ok(());
|
||||
}
|
||||
// [TODO/message-service, 2026-01-26] 引入可靠队列与重试策略后再切换到真实投递。
|
||||
let _ = app.emit("terminal-message-stream", envelope.payload.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn deliver_terminal_final(
|
||||
app: &AppHandle,
|
||||
envelope: &MessageEnvelope,
|
||||
plan: &DispatchPlan,
|
||||
policy: &PolicyDecision,
|
||||
throttle: &ThrottleDecision,
|
||||
) -> Result<(), String> {
|
||||
if !plan.should_deliver || !policy.allowed || !throttle.allowed {
|
||||
return Ok(());
|
||||
}
|
||||
let payload = &envelope.payload;
|
||||
let (Some(workspace_id), Some(conversation_id), Some(member_id), Some(viewer_id)) = (
|
||||
payload.workspace_id.as_ref(),
|
||||
payload.conversation_id.as_ref(),
|
||||
payload.member_id.as_ref(),
|
||||
payload.sender_id.as_ref(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let chat_state = app.state::<ChatDbManager>();
|
||||
// [TODO/message-service, 2026-01-26] 接入重试/死信队列,避免持久化失败后丢消息。
|
||||
chat_append_terminal_message(
|
||||
app,
|
||||
chat_state.inner(),
|
||||
workspace_id,
|
||||
conversation_id,
|
||||
member_id,
|
||||
payload.content.clone(),
|
||||
viewer_id,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! 节流与配额阶段:统一限速与削峰逻辑。
|
||||
|
||||
use super::types::{DispatchPlan, MessageEnvelope, PolicyDecision, ThrottleDecision};
|
||||
|
||||
pub(crate) fn apply_terminal(
|
||||
_envelope: &MessageEnvelope,
|
||||
_plan: &DispatchPlan,
|
||||
_policy: &PolicyDecision,
|
||||
) -> Result<ThrottleDecision, String> {
|
||||
// [TODO/message-service, 2026-01-26] 会话/成员/频道维度限流与配额。
|
||||
Ok(ThrottleDecision {
|
||||
allowed: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 消息流水线类型:承载规范化与投递决策数据。
|
||||
|
||||
use crate::terminal_engine::models::TerminalMessagePayload;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MessageEnvelope {
|
||||
pub(crate) payload: TerminalMessagePayload,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DispatchPlan {
|
||||
pub(crate) should_deliver: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PolicyDecision {
|
||||
pub(crate) allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ThrottleDecision {
|
||||
pub(crate) allowed: bool,
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! 终端语义子线程:用于异步处理快照、过滤与聊天写回。
|
||||
|
||||
use std::{
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::super::filters::{FilterContext, FilterDecision, FilterSource};
|
||||
use super::super::semantic::{
|
||||
build_semantic_payload, extract_command_from_input, extract_input_lines, SemanticState,
|
||||
TerminalChatContext,
|
||||
};
|
||||
use super::snapshot_service;
|
||||
use crate::message_service::pipeline;
|
||||
use crate::platform::{diagnostics_log_backend_event, DiagnosticsState};
|
||||
use crate::now_millis;
|
||||
|
||||
const STREAM_EMIT_INTERVAL_MS: u64 = 160; // 流式更新节流,避免高频事件影响 UI。
|
||||
const STREAM_MESSAGE_TYPE: &str = "info"; // [TODO/terminal, 2026-01-26] 统一流式消息类型的业务口径。
|
||||
const STREAM_SOURCE: &str = "pty"; // [TODO/terminal, 2026-01-26] 明确流式来源标识与前端约定。
|
||||
|
||||
// 语义通道事件:用于从 IO 线程向语义线程传递输出与上下文。
|
||||
pub(super) enum SemanticEvent {
|
||||
Output(Vec<u8>),
|
||||
UserInput {
|
||||
data: String,
|
||||
context: TerminalChatContext,
|
||||
},
|
||||
Resize { rows: u16, cols: u16 },
|
||||
Flush { message_type: &'static str, source: &'static str },
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
pub(super) fn spawn_semantic_worker(
|
||||
app: AppHandle,
|
||||
session_id: String,
|
||||
member_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
terminal_type: String,
|
||||
) -> mpsc::Sender<SemanticEvent> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
// 独立线程处理语义快照,避免阻塞 PTY 读写主路径。
|
||||
let mut state = SemanticState::new(session_id, member_id, workspace_id, rows, cols, terminal_type);
|
||||
let mut last_stream_emit_at = Instant::now();
|
||||
let mut last_stream_content: Option<String> = None;
|
||||
while let Ok(event) = rx.recv() {
|
||||
match event {
|
||||
SemanticEvent::Output(bytes) => {
|
||||
state.emulator.apply_output(&bytes);
|
||||
maybe_emit_stream(
|
||||
&app,
|
||||
&mut state,
|
||||
&mut last_stream_emit_at,
|
||||
&mut last_stream_content,
|
||||
);
|
||||
}
|
||||
SemanticEvent::UserInput { data, context } => {
|
||||
if !state.chat_block_pending {
|
||||
state.chat_block_pending = true;
|
||||
state.chat_context = Some(context);
|
||||
state.chat_span_id = Some(Ulid::new().to_string());
|
||||
state.chat_last_command = extract_command_from_input(&data);
|
||||
state.chat_last_input_lines = extract_input_lines(&data);
|
||||
last_stream_content = None;
|
||||
last_stream_emit_at = Instant::now();
|
||||
}
|
||||
}
|
||||
SemanticEvent::Resize { rows, cols } => {
|
||||
state.emulator.set_size(rows, cols);
|
||||
}
|
||||
SemanticEvent::Flush { message_type, source } => {
|
||||
if state.chat_block_pending {
|
||||
let snapshot_lines = snapshot_service::normalize_lines(state.emulator.snapshot_lines());
|
||||
let snapshot_line_count = snapshot_lines.len();
|
||||
let filter_context = FilterContext {
|
||||
session_id: state.session_id.as_str(),
|
||||
terminal_type: state.terminal_type.as_str(),
|
||||
last_command: state.chat_last_command.as_deref(),
|
||||
last_input_lines: state.chat_last_input_lines.as_deref(),
|
||||
now_ms: now_millis().unwrap_or(0),
|
||||
source: FilterSource::Snapshot,
|
||||
};
|
||||
// 过滤仅影响聊天写回,不改变终端真实输出。
|
||||
let filter_result = state.filter.apply_snapshot(&filter_context, &snapshot_lines);
|
||||
let filtered_lines = filter_result
|
||||
.lines
|
||||
.as_ref()
|
||||
.map(|lines| lines.as_slice())
|
||||
.unwrap_or(snapshot_lines.as_slice());
|
||||
let filtered_line_count = filtered_lines.len();
|
||||
let (payload, should_clear_context) = match filter_result.decision {
|
||||
FilterDecision::Allow => (
|
||||
build_semantic_payload(&mut state, message_type, source, "final", filtered_lines),
|
||||
true,
|
||||
),
|
||||
FilterDecision::Drop => (None, true),
|
||||
FilterDecision::Defer => (None, false),
|
||||
};
|
||||
let member_id_for_log = state.member_id.clone();
|
||||
let workspace_id_for_log = state.workspace_id.clone();
|
||||
let conversation_id_for_log = state
|
||||
.chat_context
|
||||
.as_ref()
|
||||
.map(|context| context.conversation_id.clone());
|
||||
let span_id_for_log = state.chat_span_id.clone();
|
||||
let last_command_for_log = state.chat_last_command.clone();
|
||||
// 诊断链路需要完整记录语义 flush 的快照与上下文,便于定位聊天输出缺失。
|
||||
diagnostics_log_backend_event(
|
||||
&app.state::<DiagnosticsState>(),
|
||||
member_id_for_log.clone(),
|
||||
Some(state.session_id.clone()),
|
||||
conversation_id_for_log.clone(),
|
||||
None,
|
||||
workspace_id_for_log.clone(),
|
||||
"terminal_semantic_flush",
|
||||
json!({
|
||||
"sessionId": state.session_id,
|
||||
"memberId": member_id_for_log,
|
||||
"workspaceId": workspace_id_for_log,
|
||||
"conversationId": conversation_id_for_log,
|
||||
"messageType": message_type,
|
||||
"source": source,
|
||||
"hasPayload": payload.is_some(),
|
||||
"filterProfile": filter_result.profile.as_str(),
|
||||
"filterDecision": filter_result.decision.as_str(),
|
||||
"filterReason": filter_result.reason,
|
||||
"filteredLineCount": filtered_line_count,
|
||||
"snapshotLines": snapshot_lines,
|
||||
"lineCount": snapshot_line_count,
|
||||
"content": payload.as_ref().map(|item| item.content.clone()),
|
||||
"spanId": span_id_for_log,
|
||||
"lastCommand": last_command_for_log
|
||||
}),
|
||||
);
|
||||
if let Some(payload) = payload {
|
||||
let content_for_log = payload.content.clone();
|
||||
if let Err(err) = pipeline::process_terminal_final(&app, payload) {
|
||||
log::warn!("terminal chat append failed session_id={} err={}", state.session_id, err);
|
||||
diagnostics_log_backend_event(
|
||||
&app.state::<DiagnosticsState>(),
|
||||
state.member_id.clone(),
|
||||
Some(state.session_id.clone()),
|
||||
state.chat_context.as_ref().map(|context| context.conversation_id.clone()),
|
||||
None,
|
||||
state.workspace_id.clone(),
|
||||
"terminal_chat_append_error",
|
||||
json!({
|
||||
"sessionId": state.session_id,
|
||||
"memberId": state.member_id,
|
||||
"workspaceId": state.workspace_id,
|
||||
"conversationId": state.chat_context.as_ref().map(|context| context.conversation_id.clone()),
|
||||
"error": err,
|
||||
"content": content_for_log
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if should_clear_context {
|
||||
state.chat_block_pending = false;
|
||||
state.chat_context = None;
|
||||
state.chat_span_id = None;
|
||||
state.chat_last_command = None;
|
||||
state.chat_last_input_lines = None;
|
||||
last_stream_content = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
SemanticEvent::Shutdown => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
tx
|
||||
}
|
||||
|
||||
fn maybe_emit_stream(
|
||||
app: &AppHandle,
|
||||
state: &mut SemanticState,
|
||||
last_stream_emit_at: &mut Instant,
|
||||
last_stream_content: &mut Option<String>,
|
||||
) {
|
||||
if !state.chat_block_pending {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now.duration_since(*last_stream_emit_at) < Duration::from_millis(STREAM_EMIT_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
let snapshot_lines = snapshot_service::normalize_lines(state.emulator.snapshot_lines());
|
||||
let filter_context = FilterContext {
|
||||
session_id: state.session_id.as_str(),
|
||||
terminal_type: state.terminal_type.as_str(),
|
||||
last_command: state.chat_last_command.as_deref(),
|
||||
last_input_lines: state.chat_last_input_lines.as_deref(),
|
||||
now_ms: now_millis().unwrap_or(0),
|
||||
source: FilterSource::Snapshot,
|
||||
};
|
||||
let filter_result = state.filter.apply_snapshot(&filter_context, &snapshot_lines);
|
||||
let filtered_lines = filter_result
|
||||
.lines
|
||||
.as_ref()
|
||||
.map(|lines| lines.as_slice())
|
||||
.unwrap_or(snapshot_lines.as_slice());
|
||||
if filter_result.decision != FilterDecision::Allow {
|
||||
return;
|
||||
}
|
||||
let Some(payload) = build_semantic_payload(
|
||||
state,
|
||||
STREAM_MESSAGE_TYPE,
|
||||
STREAM_SOURCE,
|
||||
"stream",
|
||||
filtered_lines,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if last_stream_content.as_deref() == Some(payload.content.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = pipeline::process_terminal_stream(app, payload.clone()) {
|
||||
log::warn!("terminal stream dispatch failed session_id={} err={}", state.session_id, err);
|
||||
}
|
||||
*last_stream_content = Some(payload.content);
|
||||
*last_stream_emit_at = now;
|
||||
}
|
||||
Reference in New Issue
Block a user