支持Agent聊天流式状态
单 Agent 聊天新增 Tauri 流式事件并按 runId 过滤增量 开发窗口和项目 Agent 对话发送时显示保存、连接、接收和落盘状态 流式草稿先在界面增量显示,完成后再保存 assistant 消息 补充前端流式状态回归和 Rust SSE 回调测试
This commit is contained in:
@@ -122,6 +122,49 @@ pub(crate) async fn chat_with_game_creator_role_agent_at(
|
||||
agent_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GameCreatorChatAgentReply, String> {
|
||||
let (llm, config_path, request) =
|
||||
build_game_creator_role_agent_chat_request(root, agent_id, prompt)?;
|
||||
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
|
||||
let response = request_game_creator_llm_text(&client, &llm, request)
|
||||
.await
|
||||
.map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?;
|
||||
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
|
||||
if reply_text.is_empty() {
|
||||
return Err(format!("{config_path} 单 Agent 聊天未返回内容"));
|
||||
}
|
||||
|
||||
Ok(GameCreatorChatAgentReply { reply_text })
|
||||
}
|
||||
|
||||
pub(crate) async fn chat_with_game_creator_role_agent_stream_at<F>(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
prompt: &str,
|
||||
mut on_delta: F,
|
||||
) -> Result<GameCreatorChatAgentReply, String>
|
||||
where
|
||||
F: FnMut(&platform_llm::LlmStreamDelta),
|
||||
{
|
||||
let (llm, config_path, request) =
|
||||
build_game_creator_role_agent_chat_request(root, agent_id, prompt)?;
|
||||
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
|
||||
let response = client
|
||||
.stream_run(request, |delta| on_delta(delta))
|
||||
.await
|
||||
.map_err(|error| format!("{config_path} 单 Agent 流式聊天调用 LLM 失败:{error}"))?;
|
||||
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
|
||||
if reply_text.is_empty() {
|
||||
return Err(format!("{config_path} 单 Agent 流式聊天未返回内容"));
|
||||
}
|
||||
|
||||
Ok(GameCreatorChatAgentReply { reply_text })
|
||||
}
|
||||
|
||||
pub(crate) fn build_game_creator_role_agent_chat_request(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
|
||||
let agent_id = agent_id.trim();
|
||||
let prompt = prompt.trim();
|
||||
if agent_id.is_empty() {
|
||||
@@ -168,7 +211,6 @@ pub(crate) async fn chat_with_game_creator_role_agent_at(
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, agent_id);
|
||||
let config_path = format!("agentLlm.{agent_id}");
|
||||
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
|
||||
let user_prompt = if context.trim().is_empty() {
|
||||
format!("用户这轮输入:\n{prompt}")
|
||||
} else {
|
||||
@@ -180,15 +222,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_at(
|
||||
])
|
||||
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
|
||||
.with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS);
|
||||
let response = request_game_creator_llm_text(&client, &llm, request)
|
||||
.await
|
||||
.map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?;
|
||||
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
|
||||
if reply_text.is_empty() {
|
||||
return Err(format!("{config_path} 单 Agent 聊天未返回内容"));
|
||||
}
|
||||
|
||||
Ok(GameCreatorChatAgentReply { reply_text })
|
||||
Ok((llm, config_path, request))
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str {
|
||||
|
||||
@@ -248,6 +248,89 @@ pub(crate) async fn chat_with_game_creator_role_agent(
|
||||
chat_with_game_creator_role_agent_at(root, agent_id.trim(), prompt.trim()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_role_agent_stream(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
agent_id: String,
|
||||
prompt: String,
|
||||
run_id: String,
|
||||
) -> Result<GameCreatorChatAgentReply, String> {
|
||||
let project_path = project_path.trim().to_string();
|
||||
let agent_id = agent_id.trim().to_string();
|
||||
let run_id = run_id.trim().to_string();
|
||||
let root = Path::new(project_path.as_str());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let emit_app = app.clone();
|
||||
let event_project_path = project_path.clone();
|
||||
let event_agent_id = agent_id.clone();
|
||||
let event_run_id = run_id.clone();
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: event_project_path.clone(),
|
||||
agent_id: event_agent_id.clone(),
|
||||
run_id: event_run_id.clone(),
|
||||
status: "started".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: String::new(),
|
||||
finish_reason: None,
|
||||
},
|
||||
);
|
||||
let result = chat_with_game_creator_role_agent_stream_at(
|
||||
root,
|
||||
agent_id.as_str(),
|
||||
prompt.trim(),
|
||||
|delta| {
|
||||
let _ = emit_app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: event_project_path.clone(),
|
||||
agent_id: event_agent_id.clone(),
|
||||
run_id: event_run_id.clone(),
|
||||
status: "delta".to_string(),
|
||||
delta_text: delta.delta_text.clone(),
|
||||
accumulated_text: delta.accumulated_text.clone(),
|
||||
finish_reason: delta.finish_reason.clone(),
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(reply) => {
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path,
|
||||
agent_id,
|
||||
run_id,
|
||||
status: "completed".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: reply.reply_text.clone(),
|
||||
finish_reason: None,
|
||||
},
|
||||
);
|
||||
Ok(reply)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path,
|
||||
agent_id,
|
||||
run_id,
|
||||
status: "failed".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: String::new(),
|
||||
finish_reason: None,
|
||||
},
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
||||
check_game_creator_llm_config_from_config()
|
||||
|
||||
@@ -115,6 +115,18 @@ struct GameCreatorChatAgentReply {
|
||||
reply_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: String,
|
||||
agent_id: String,
|
||||
run_id: String,
|
||||
status: String,
|
||||
delta_text: String,
|
||||
accumulated_text: String,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorAgentProgressEvent {
|
||||
@@ -928,6 +940,7 @@ fn main() {
|
||||
generate_local_game_draft,
|
||||
chat_with_game_creator_agent,
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
check_game_creator_llm_config,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
|
||||
@@ -589,6 +589,31 @@ fn spawn_mock_llm_server_responses_with_capture(
|
||||
base_url
|
||||
}
|
||||
|
||||
fn spawn_mock_llm_stream_server_with_capture(
|
||||
response_body: String,
|
||||
request_sender: Option<mpsc::Sender<String>>,
|
||||
) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock stream llm bind");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("mock stream llm accept");
|
||||
let mut request_buffer = [0_u8; 8192];
|
||||
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
|
||||
if let Some(sender) = request_sender.as_ref() {
|
||||
let _ = sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned());
|
||||
}
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_role_agent_stream\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("mock stream llm response");
|
||||
});
|
||||
base_url
|
||||
}
|
||||
|
||||
fn spawn_barrier_mock_llm_server(
|
||||
response_content: String,
|
||||
barrier: Arc<(StdMutex<usize>, Condvar)>,
|
||||
@@ -1051,6 +1076,80 @@ async fn chat_with_game_creator_role_agent_uses_agent_context_and_route() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_game_creator_role_agent_stream_emits_deltas() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
write_local_agent_memory_at(&root, "art-director", "私有记忆:先做角色规范图")
|
||||
.expect("write art memory");
|
||||
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let base_url = spawn_mock_llm_stream_server_with_capture(
|
||||
concat!(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"先\"}\n\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"生成规范图\"}\n\n",
|
||||
"data: {\"type\":\"response.completed\"}\n\n"
|
||||
)
|
||||
.to_string(),
|
||||
Some(sender),
|
||||
);
|
||||
let _config_guard = write_test_local_config(format!(
|
||||
r#"{{
|
||||
"llm": {{
|
||||
"apiKey": "global-key",
|
||||
"baseUrl": "https://global.example.test/v1",
|
||||
"model": "global-model",
|
||||
"apiKind": "openai_responses"
|
||||
}},
|
||||
"agentLlm": {{
|
||||
"art-director": {{
|
||||
"apiKey": "art-key",
|
||||
"baseUrl": {base_url:?},
|
||||
"model": "art-chat-model",
|
||||
"apiKind": "openai_responses"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
));
|
||||
|
||||
let mut deltas = Vec::new();
|
||||
let reply = chat_with_game_creator_role_agent_stream_at(
|
||||
&root,
|
||||
"art-director",
|
||||
"我要一个主角设定",
|
||||
|delta| {
|
||||
deltas.push((
|
||||
delta.delta_text.clone(),
|
||||
delta.accumulated_text.clone(),
|
||||
delta.finish_reason.clone(),
|
||||
));
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("role stream chat reply");
|
||||
|
||||
assert_eq!(reply.reply_text, "先生成规范图");
|
||||
assert_eq!(
|
||||
deltas,
|
||||
vec![
|
||||
("先".to_string(), "先".to_string(), None),
|
||||
("生成规范图".to_string(), "先生成规范图".to_string(), None),
|
||||
]
|
||||
);
|
||||
let request = receiver
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("captured role stream chat llm request");
|
||||
assert!(request.contains("POST /responses HTTP/1.1"));
|
||||
assert!(request.contains("\"stream\":true"));
|
||||
assert!(request.contains("art-chat-model"));
|
||||
assert!(request.contains("私有记忆:先做角色规范图"));
|
||||
assert!(request.contains("我要一个主角设定"));
|
||||
assert!(!request.contains("global-model"));
|
||||
assert!(!request.contains("global-key"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_loop_uses_per_agent_llm_overrides() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -212,6 +212,16 @@ interface GameCreatorChatAgentReply {
|
||||
replyText: string;
|
||||
}
|
||||
|
||||
interface GameCreatorRoleAgentChatStreamEvent {
|
||||
projectPath: string;
|
||||
agentId: string;
|
||||
runId: string;
|
||||
status: 'started' | 'delta' | 'completed' | 'failed';
|
||||
deltaText: string;
|
||||
accumulatedText: string;
|
||||
finishReason: string | null;
|
||||
}
|
||||
|
||||
interface GameCreatorLlmConfigStatus {
|
||||
configured: boolean;
|
||||
apiKeyPresent: boolean;
|
||||
@@ -402,6 +412,22 @@ interface LocalConversationResult {
|
||||
messages: LocalConversationMessageRecord[];
|
||||
}
|
||||
|
||||
function createLocalConversationDraftMessage(
|
||||
content: string,
|
||||
): LocalConversationMessageRecord {
|
||||
return {
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
role: 'assistant',
|
||||
content,
|
||||
agentId: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentChatRunId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
interface ProjectPermissionPolicy {
|
||||
deniedCommands: string[];
|
||||
confirmCommands: string[];
|
||||
@@ -2944,8 +2970,10 @@ export function WorkspaceLauncher({
|
||||
agentChatLoadVersionRef.current = saveVersion;
|
||||
setAgentChatBusy(true);
|
||||
setAgentChatInput('');
|
||||
setAgentChatStatus('正在保存');
|
||||
setAgentChatStatus('正在保存用户消息');
|
||||
let savedUserResult: LocalConversationResult | null = null;
|
||||
let stopStreamListen: (() => void) | null = null;
|
||||
let streamListenDisposed = false;
|
||||
try {
|
||||
savedUserResult = await invoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
@@ -2962,19 +2990,84 @@ export function WorkspaceLauncher({
|
||||
if (agentChatLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentChatMessages(savedUserResult.messages);
|
||||
setAgentChatStatus('Agent 正在思考');
|
||||
const reply = await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
},
|
||||
);
|
||||
const savedUserMessages = savedUserResult.messages;
|
||||
setAgentChatMessages(savedUserMessages);
|
||||
setAgentChatStatus('正在连接 Agent LLM');
|
||||
const streamRunId = createAgentChatRunId('launcher-agent-chat');
|
||||
const listen = window.__TAURI__?.event?.listen;
|
||||
if (listen) {
|
||||
stopStreamListen =
|
||||
await listen<GameCreatorRoleAgentChatStreamEvent>(
|
||||
'game-creator-role-agent-chat-stream',
|
||||
(event) => {
|
||||
const payload = event.payload;
|
||||
if (
|
||||
payload.projectPath !== projectPathForChat ||
|
||||
payload.agentId !== agent.id ||
|
||||
payload.runId !== streamRunId ||
|
||||
agentChatLoadVersionRef.current !== saveVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'started') {
|
||||
setAgentChatStatus('Agent 已连接,正在等待回复');
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'delta') {
|
||||
const draftText = payload.accumulatedText || payload.deltaText;
|
||||
if (draftText) {
|
||||
setAgentChatMessages([
|
||||
...savedUserMessages,
|
||||
createLocalConversationDraftMessage(draftText),
|
||||
]);
|
||||
}
|
||||
setAgentChatStatus(
|
||||
payload.finishReason
|
||||
? `Agent 回复结束:${payload.finishReason}`
|
||||
: '正在接收 Agent 回复',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'completed') {
|
||||
setAgentChatStatus('Agent 回复完成,正在保存');
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'failed') {
|
||||
setAgentChatStatus('Agent 流式回复失败,正在记录错误');
|
||||
}
|
||||
},
|
||||
);
|
||||
if (streamListenDisposed) {
|
||||
stopStreamListen();
|
||||
stopStreamListen = null;
|
||||
}
|
||||
}
|
||||
const reply = listen
|
||||
? await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
runId: streamRunId,
|
||||
},
|
||||
)
|
||||
: await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
},
|
||||
);
|
||||
if (agentChatLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentChatStatus('正在保存 Agent 回复');
|
||||
setAgentChatMessages([
|
||||
...savedUserMessages,
|
||||
createLocalConversationDraftMessage(reply.replyText),
|
||||
]);
|
||||
const assistantResult = await invoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
{
|
||||
@@ -3037,6 +3130,8 @@ export function WorkspaceLauncher({
|
||||
setAgentChatStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
streamListenDisposed = true;
|
||||
stopStreamListen?.();
|
||||
if (agentChatLoadVersionRef.current === saveVersion) {
|
||||
setAgentChatBusy(false);
|
||||
}
|
||||
@@ -12283,8 +12378,10 @@ export function App() {
|
||||
agentConversationSavingRef.current = true;
|
||||
setAgentConversationSaving(true);
|
||||
setAgentConversationInput('');
|
||||
setAgentConversationStatus('正在保存');
|
||||
setAgentConversationStatus('正在保存用户消息');
|
||||
let savedUserResult: LocalConversationResult | null = null;
|
||||
let stopStreamListen: (() => void) | null = null;
|
||||
let streamListenDisposed = false;
|
||||
try {
|
||||
savedUserResult = await invoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
@@ -12301,19 +12398,84 @@ export function App() {
|
||||
if (agentConversationLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentConversationMessages(savedUserResult.messages);
|
||||
setAgentConversationStatus('Agent 正在思考');
|
||||
const reply = await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
},
|
||||
);
|
||||
const savedUserMessages = savedUserResult.messages;
|
||||
setAgentConversationMessages(savedUserMessages);
|
||||
setAgentConversationStatus('正在连接 Agent LLM');
|
||||
const streamRunId = createAgentChatRunId('agent-conversation');
|
||||
const listen = window.__TAURI__?.event?.listen;
|
||||
if (listen) {
|
||||
stopStreamListen =
|
||||
await listen<GameCreatorRoleAgentChatStreamEvent>(
|
||||
'game-creator-role-agent-chat-stream',
|
||||
(event) => {
|
||||
const payload = event.payload;
|
||||
if (
|
||||
payload.projectPath !== nextProjectPath ||
|
||||
payload.agentId !== agent.id ||
|
||||
payload.runId !== streamRunId ||
|
||||
agentConversationLoadVersionRef.current !== saveVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'started') {
|
||||
setAgentConversationStatus('Agent 已连接,正在等待回复');
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'delta') {
|
||||
const draftText = payload.accumulatedText || payload.deltaText;
|
||||
if (draftText) {
|
||||
setAgentConversationMessages([
|
||||
...savedUserMessages,
|
||||
createLocalConversationDraftMessage(draftText),
|
||||
]);
|
||||
}
|
||||
setAgentConversationStatus(
|
||||
payload.finishReason
|
||||
? `Agent 回复结束:${payload.finishReason}`
|
||||
: '正在接收 Agent 回复',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'completed') {
|
||||
setAgentConversationStatus('Agent 回复完成,正在保存');
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'failed') {
|
||||
setAgentConversationStatus('Agent 流式回复失败,正在记录错误');
|
||||
}
|
||||
},
|
||||
);
|
||||
if (streamListenDisposed) {
|
||||
stopStreamListen();
|
||||
stopStreamListen = null;
|
||||
}
|
||||
}
|
||||
const reply = listen
|
||||
? await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
runId: streamRunId,
|
||||
},
|
||||
)
|
||||
: await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_role_agent',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: agent.id,
|
||||
prompt: content,
|
||||
},
|
||||
);
|
||||
if (agentConversationLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentConversationStatus('正在保存 Agent 回复');
|
||||
setAgentConversationMessages([
|
||||
...savedUserMessages,
|
||||
createLocalConversationDraftMessage(reply.replyText),
|
||||
]);
|
||||
const assistantResult = await invoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
{
|
||||
@@ -12379,6 +12541,8 @@ export function App() {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
streamListenDisposed = true;
|
||||
stopStreamListen?.();
|
||||
agentConversationSavingRef.current = false;
|
||||
setAgentConversationSaving(false);
|
||||
}
|
||||
@@ -19450,6 +19614,7 @@ export function App() {
|
||||
{selectedAgentLlmStatus ? (
|
||||
<p className="status-line">{selectedAgentLlmStatus}</p>
|
||||
) : null}
|
||||
<p className="status-line">{agentConversationStatus}</p>
|
||||
<p className="status-line">{selectedAgent.summary}</p>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
@@ -19617,7 +19782,6 @@ export function App() {
|
||||
记入记忆
|
||||
</button>
|
||||
</form>
|
||||
<p className="status-line">{agentConversationStatus}</p>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1060,6 +1060,180 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('streams developer agent chat status and draft reply before persisting', async () => {
|
||||
const persistedMessages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
}> = [];
|
||||
let releaseStream: (() => void) | null = null;
|
||||
let streamHandler: ((event: { payload: Record<string, unknown> }) => void) | null =
|
||||
null;
|
||||
const listen = vi.fn(
|
||||
async (
|
||||
eventName: string,
|
||||
handler: (event: { payload: Record<string, unknown> }) => void,
|
||||
) => {
|
||||
expect(eventName).toBe('game-creator-role-agent-chat-stream');
|
||||
streamHandler = handler;
|
||||
return () => {
|
||||
if (streamHandler === handler) {
|
||||
streamHandler = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'check_game_creator_llm_config') {
|
||||
return {
|
||||
configured: true,
|
||||
apiKeyPresent: true,
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-5.5',
|
||||
apiKind: 'openai_chat',
|
||||
stream: true,
|
||||
error: null,
|
||||
agents: [
|
||||
{
|
||||
agentId: 'design-director',
|
||||
label: '拆解创作方向',
|
||||
configured: true,
|
||||
apiKeyPresent: true,
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-5.5',
|
||||
apiKind: 'openai_chat',
|
||||
stream: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: persistedMessages.map((message, index) => ({
|
||||
schemaVersion: '1',
|
||||
...message,
|
||||
updatedAt: 1000 + index,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_role_agent_stream') {
|
||||
const payloadBase = {
|
||||
projectPath: args?.projectPath,
|
||||
agentId: args?.agentId,
|
||||
runId: args?.runId,
|
||||
finishReason: null,
|
||||
};
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
...payloadBase,
|
||||
status: 'started',
|
||||
deltaText: '',
|
||||
accumulatedText: '',
|
||||
},
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
...payloadBase,
|
||||
status: 'delta',
|
||||
deltaText: '专业',
|
||||
accumulatedText: '专业',
|
||||
},
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
...payloadBase,
|
||||
status: 'delta',
|
||||
deltaText: ' Agent',
|
||||
accumulatedText: '专业 Agent',
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStream = resolve;
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
...payloadBase,
|
||||
status: 'completed',
|
||||
deltaText: '',
|
||||
accumulatedText: '专业 Agent 已流式完成。',
|
||||
},
|
||||
});
|
||||
return {
|
||||
replyText: '专业 Agent 已流式完成。',
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
const message = args?.message as {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
};
|
||||
persistedMessages.push(message);
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: persistedMessages.map((record, index) => ({
|
||||
schemaVersion: '1',
|
||||
...record,
|
||||
updatedAt: 2000 + index,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke }, event: { listen } };
|
||||
renderLauncherAgentChatAt('/?agent-chat');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/authorized-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '请流式回答' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
expect(await screen.findByText('专业 Agent')).not.toBeNull();
|
||||
expect(screen.getByText('正在接收 Agent 回复')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
expect.objectContaining({
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
prompt: '请流式回答',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
invoke,
|
||||
).not.toHaveBeenCalledWith('chat_with_game_creator_role_agent', expect.anything());
|
||||
|
||||
await act(async () => {
|
||||
releaseStream?.();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('专业 Agent 已流式完成。')).not.toBeNull();
|
||||
expect(await screen.findByText(/已保存 2 条/)).not.toBeNull();
|
||||
expect(persistedMessages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: '请流式回答',
|
||||
agentId: null,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '专业 Agent 已流式完成。',
|
||||
agentId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists developer agent chat reply failures after saving the user message', async () => {
|
||||
const persistedMessages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
@@ -12026,7 +12200,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
const form = input.closest('form') as HTMLFormElement;
|
||||
fireEvent.change(input, { target: { value: '只保存一次' } });
|
||||
fireEvent.submit(form);
|
||||
await screen.findByText('正在保存');
|
||||
await screen.findByText('正在保存用户消息');
|
||||
|
||||
fireEvent.submit(form);
|
||||
|
||||
@@ -12764,7 +12938,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
const input = await screen.findByLabelText('Agent 对话内容');
|
||||
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
await screen.findByText('正在保存');
|
||||
await screen.findByText('正在保存用户消息');
|
||||
fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ }));
|
||||
|
||||
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user