fix: sync rust api-server runtime and bindings
This commit is contained in:
669
server-rs/crates/api-server/src/custom_world_foundation_draft.rs
Normal file
669
server-rs/crates/api-server/src/custom_world_foundation_draft.rs
Normal file
@@ -0,0 +1,669 @@
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
|
||||
use spacetime_client::CustomWorldAgentSessionRecord;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CustomWorldFoundationDraftResult {
|
||||
pub draft_profile_json: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DraftFoundationPayloadError {
|
||||
SerializePayload(String),
|
||||
InvalidPayloadShape,
|
||||
InvalidGeneratedDraft(String),
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_foundation_draft(
|
||||
llm_client: &LlmClient,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
) -> Result<CustomWorldFoundationDraftResult, String> {
|
||||
let system_prompt = build_foundation_draft_system_prompt();
|
||||
let user_prompt = build_foundation_draft_user_prompt(session);
|
||||
let response = llm_client
|
||||
.request_text(LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]))
|
||||
.await
|
||||
.map_err(|error| format!("foundation draft LLM 请求失败:{error}"))?;
|
||||
let parsed = parse_json_response_text(response.content.as_str())
|
||||
.map_err(|error| format!("foundation draft JSON 解析失败:{error}"))?;
|
||||
let draft_profile = normalize_foundation_draft_profile(parsed, session);
|
||||
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile))
|
||||
.map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?;
|
||||
|
||||
Ok(CustomWorldFoundationDraftResult { draft_profile_json })
|
||||
}
|
||||
|
||||
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
|
||||
pub fn build_draft_foundation_action_payload_json(
|
||||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||||
draft_profile_json: &str,
|
||||
) -> Result<String, DraftFoundationPayloadError> {
|
||||
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
|
||||
DraftFoundationPayloadError::SerializePayload(format!(
|
||||
"action payload JSON 序列化失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let payload_object = payload_value
|
||||
.as_object_mut()
|
||||
.ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?;
|
||||
let draft_profile_value =
|
||||
serde_json::from_str::<JsonValue>(draft_profile_json).map_err(|error| {
|
||||
DraftFoundationPayloadError::InvalidGeneratedDraft(format!(
|
||||
"foundation draft JSON 非法:{error}"
|
||||
))
|
||||
})?;
|
||||
if !draft_profile_value.is_object() {
|
||||
return Err(DraftFoundationPayloadError::InvalidGeneratedDraft(
|
||||
"foundation draft JSON 必须是 object".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
payload_object.insert("draftProfile".to_string(), draft_profile_value);
|
||||
serde_json::to_string(&payload_value).map_err(|error| {
|
||||
DraftFoundationPayloadError::SerializePayload(format!(
|
||||
"action payload JSON 序列化失败:{error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_foundation_draft_system_prompt() -> String {
|
||||
[
|
||||
"你是 RPG 世界共创后端里的底稿编译器。",
|
||||
"你的任务是根据当前会话已经确认的世界锚点,生成第一版“世界设定草稿” JSON。",
|
||||
"必须只输出一个 JSON object,不要输出 markdown、解释、前后缀。",
|
||||
"输出必须使用中文内容。",
|
||||
"不要返回占位符,不要写“待补充”“略”“TBD”“placeholder”。",
|
||||
"如果某些信息不完整,也要基于已知锚点给出一版合理、可继续精修的首稿。",
|
||||
"字段必须至少包含:name、subtitle、summary、worldHook、playerPremise、coreConflicts、playableNpcs、storyNpcs、landmarks、chapters、sceneChapterBlueprints。",
|
||||
"sceneChapterBlueprints 至少包含 1 个 chapter,且 chapter.acts 至少包含 1 个 act。",
|
||||
"playableNpcs、storyNpcs、landmarks 可以是小规模首批关键对象,不要求长尾铺满。",
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
|
||||
let anchor_content = to_pretty_json(&session.anchor_content);
|
||||
let creator_intent = to_pretty_json(&session.creator_intent);
|
||||
let anchor_pack = to_pretty_json(&session.anchor_pack);
|
||||
let current_draft = if is_non_null_json(&session.draft_profile) {
|
||||
to_pretty_json(&session.draft_profile)
|
||||
} else {
|
||||
"{}".to_string()
|
||||
};
|
||||
let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone()));
|
||||
|
||||
[
|
||||
format!("seedText:{}", session.seed_text.trim()),
|
||||
format!("当前 stage:{}", session.stage.trim()),
|
||||
format!("当前 progressPercent:{}", session.progress_percent),
|
||||
format!(
|
||||
"当前最后一条 assistant 回复:{}",
|
||||
session.last_assistant_reply.clone().unwrap_or_default()
|
||||
),
|
||||
format!("当前 anchorContent:\n{anchor_content}"),
|
||||
format!("当前 creatorIntent:\n{creator_intent}"),
|
||||
format!("当前 anchorPack:\n{anchor_pack}"),
|
||||
format!("当前已有 draftProfile:\n{current_draft}"),
|
||||
format!("当前 qualityFindings:\n{quality_findings}"),
|
||||
"请直接返回第一版 foundation draft JSON。".to_string(),
|
||||
"约束:".to_string(),
|
||||
"1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(),
|
||||
"2. playerPremise 必须明确玩家身份与切入前提。".to_string(),
|
||||
"3. coreConflicts 必须至少 1 条。".to_string(),
|
||||
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
|
||||
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
|
||||
"6. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
|
||||
]
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn normalize_foundation_draft_profile(
|
||||
value: JsonValue,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
) -> JsonMap<String, JsonValue> {
|
||||
let mut object = value.as_object().cloned().unwrap_or_default();
|
||||
let fallback_title = derive_world_name(&object, session);
|
||||
let fallback_world_hook = derive_world_hook(&object, session);
|
||||
let fallback_player_premise = derive_player_premise(&object, session);
|
||||
ensure_text_field(&mut object, "name", fallback_title.as_str());
|
||||
ensure_text_field(&mut object, "subtitle", "世界底稿已生成");
|
||||
ensure_text_field(
|
||||
&mut object,
|
||||
"summary",
|
||||
"第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。",
|
||||
);
|
||||
ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str());
|
||||
ensure_text_field(
|
||||
&mut object,
|
||||
"playerPremise",
|
||||
fallback_player_premise.as_str(),
|
||||
);
|
||||
ensure_text_array_field(
|
||||
&mut object,
|
||||
"coreConflicts",
|
||||
vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"],
|
||||
);
|
||||
ensure_object_array_field(&mut object, "playableNpcs");
|
||||
ensure_object_array_field(&mut object, "storyNpcs");
|
||||
ensure_object_array_field(&mut object, "landmarks");
|
||||
ensure_object_array_field(&mut object, "chapters");
|
||||
ensure_scene_chapter_blueprints(&mut object);
|
||||
object
|
||||
}
|
||||
|
||||
fn ensure_text_field(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
|
||||
let current = object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
object.insert(
|
||||
key.to_string(),
|
||||
JsonValue::String(current.unwrap_or_else(|| fallback.to_string())),
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_text_array_field(
|
||||
object: &mut JsonMap<String, JsonValue>,
|
||||
key: &str,
|
||||
fallback_items: Vec<&str>,
|
||||
) {
|
||||
let current_items = object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.as_str().map(str::trim))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| JsonValue::String(value.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if current_items.is_empty() {
|
||||
object.insert(
|
||||
key.to_string(),
|
||||
JsonValue::Array(
|
||||
fallback_items
|
||||
.into_iter()
|
||||
.map(|value| JsonValue::String(value.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
object.insert(key.to_string(), JsonValue::Array(current_items));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_object_array_field(object: &mut JsonMap<String, JsonValue>, key: &str) {
|
||||
let current = object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
object.insert(key.to_string(), JsonValue::Array(current));
|
||||
}
|
||||
|
||||
fn ensure_scene_chapter_blueprints(object: &mut JsonMap<String, JsonValue>) {
|
||||
let blueprints = object
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if blueprints.is_empty() {
|
||||
object.insert(
|
||||
"sceneChapterBlueprints".to_string(),
|
||||
JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let normalized = blueprints
|
||||
.into_iter()
|
||||
.map(|chapter| normalize_scene_chapter_blueprint(chapter))
|
||||
.collect::<Vec<_>>();
|
||||
object.insert(
|
||||
"sceneChapterBlueprints".to_string(),
|
||||
JsonValue::Array(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
|
||||
let mut object = chapter.as_object().cloned().unwrap_or_default();
|
||||
let title = object
|
||||
.get("title")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("第一幕");
|
||||
object.insert("title".to_string(), JsonValue::String(title.to_string()));
|
||||
let acts = object
|
||||
.get("acts")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if acts.is_empty() {
|
||||
object.insert(
|
||||
"acts".to_string(),
|
||||
JsonValue::Array(vec![build_fallback_scene_act()]),
|
||||
);
|
||||
}
|
||||
JsonValue::Object(object)
|
||||
}
|
||||
|
||||
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
|
||||
json!({
|
||||
"id": "chapter-act-1",
|
||||
"title": "第一幕",
|
||||
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
|
||||
"acts": [build_fallback_scene_act()],
|
||||
})
|
||||
}
|
||||
|
||||
fn build_fallback_scene_act() -> JsonValue {
|
||||
json!({
|
||||
"id": "scene-act-1",
|
||||
"title": "开场场景幕",
|
||||
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_world_name(
|
||||
object: &JsonMap<String, JsonValue>,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
) -> String {
|
||||
read_text_field(object, &["name", "title"])
|
||||
.or_else(|| {
|
||||
session
|
||||
.anchor_content
|
||||
.get("worldPromise")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|entry| entry.get("hook"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "未命名世界草稿".to_string())
|
||||
}
|
||||
|
||||
fn derive_world_hook(
|
||||
object: &JsonMap<String, JsonValue>,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
) -> String {
|
||||
read_text_field(object, &["worldHook"])
|
||||
.or_else(|| {
|
||||
session
|
||||
.anchor_content
|
||||
.get("worldPromise")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|entry| entry.get("hook"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_player_premise(
|
||||
object: &JsonMap<String, JsonValue>,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
) -> String {
|
||||
read_text_field(object, &["playerPremise"])
|
||||
.or_else(|| {
|
||||
session
|
||||
.anchor_content
|
||||
.get("playerEntryPoint")
|
||||
.and_then(JsonValue::as_object)
|
||||
.map(|entry| {
|
||||
let identity = entry
|
||||
.get("openingIdentity")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let problem = entry
|
||||
.get("openingProblem")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let motivation = entry
|
||||
.get("entryMotivation")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
[identity, problem, motivation]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn read_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||||
for key in keys {
|
||||
let mut current = JsonValue::Object(object.clone());
|
||||
let mut found = true;
|
||||
for segment in key.split('.') {
|
||||
if let Some(next) = current.get(segment) {
|
||||
current = next.clone();
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found
|
||||
&& let Some(value) = current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
|
||||
let trimmed = text.trim();
|
||||
if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
|
||||
}
|
||||
serde_json::from_str::<JsonValue>(trimmed)
|
||||
}
|
||||
|
||||
fn to_pretty_json(value: &JsonValue) -> String {
|
||||
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
|
||||
fn is_non_null_json(value: &JsonValue) -> bool {
|
||||
!matches!(value, JsonValue::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
|
||||
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn foundation_prompt_uses_real_seed_text() {
|
||||
let session = build_test_session();
|
||||
|
||||
let prompt = build_foundation_draft_user_prompt(&session);
|
||||
|
||||
assert!(prompt.contains("seedText:海雾会吞掉记错航线的人。"));
|
||||
assert!(!prompt.contains("seedText:custom-world-agent-session-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
|
||||
let payload = ExecuteCustomWorldAgentActionRequest {
|
||||
action: "draft_foundation".to_string(),
|
||||
profile_id: Some("profile-1".to_string()),
|
||||
draft_profile: Some(json!({ "name": "旧草稿" })),
|
||||
legacy_result_profile: None,
|
||||
setting_text: Some("旧设定".to_string()),
|
||||
card_id: None,
|
||||
sections: None,
|
||||
profile: None,
|
||||
count: None,
|
||||
prompt_text: Some("补充提示".to_string()),
|
||||
anchor_card_ids: Some(vec!["card-1".to_string()]),
|
||||
role_ids: None,
|
||||
role_id: None,
|
||||
portrait_path: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
scene_ids: None,
|
||||
scene_id: None,
|
||||
scene_kind: None,
|
||||
image_src: None,
|
||||
generated_scene_asset_id: None,
|
||||
generated_scene_prompt: None,
|
||||
generated_scene_model: None,
|
||||
checkpoint_id: None,
|
||||
};
|
||||
|
||||
let payload_json = build_draft_foundation_action_payload_json(
|
||||
&payload,
|
||||
r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#,
|
||||
)
|
||||
.expect("payload json should build");
|
||||
let payload_value =
|
||||
serde_json::from_str::<JsonValue>(&payload_json).expect("payload json should parse");
|
||||
|
||||
assert_eq!(
|
||||
payload_value.get("action"),
|
||||
Some(&json!("draft_foundation"))
|
||||
);
|
||||
assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1")));
|
||||
assert_eq!(
|
||||
payload_value
|
||||
.get("draftProfile")
|
||||
.and_then(|value| value.get("name")),
|
||||
Some(&json!("新草稿"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
|
||||
let request_capture = Arc::new(Mutex::new(String::new()));
|
||||
let server_url = spawn_mock_server(
|
||||
request_capture.clone(),
|
||||
r#"{"id":"resp_01","choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"]}"}}]}"#
|
||||
.to_string(),
|
||||
);
|
||||
let llm_client = build_test_llm_client(server_url);
|
||||
let session = build_test_session();
|
||||
|
||||
let result = generate_custom_world_foundation_draft(&llm_client, &session)
|
||||
.await
|
||||
.expect("draft generation should succeed");
|
||||
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
|
||||
.expect("draft profile should parse");
|
||||
let request_text = request_capture
|
||||
.lock()
|
||||
.expect("request capture should lock")
|
||||
.clone();
|
||||
|
||||
assert!(request_text.contains("海雾会吞掉记错航线的人。"));
|
||||
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
|
||||
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
|
||||
assert!(
|
||||
draft_profile
|
||||
.get("worldHook")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
draft_profile
|
||||
.get("playerPremise")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|entries| !entries.is_empty()),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
fn build_test_session() -> CustomWorldAgentSessionRecord {
|
||||
CustomWorldAgentSessionRecord {
|
||||
session_id: "custom-world-agent-session-1".to_string(),
|
||||
seed_text: "海雾会吞掉记错航线的人。".to_string(),
|
||||
current_turn: 2,
|
||||
anchor_content: json!({
|
||||
"worldPromise": {
|
||||
"hook": "在失真的海图上追查一场被篡改的沉船事故。"
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "被停职返乡的守灯人",
|
||||
"openingProblem": "灯塔记录被人改写",
|
||||
"entryMotivation": "查清父亲沉船真相"
|
||||
}
|
||||
}),
|
||||
progress_percent: 100,
|
||||
last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()),
|
||||
stage: "foundation_review".to_string(),
|
||||
focus_card_id: None,
|
||||
creator_intent: json!({
|
||||
"theme": "悬疑航海",
|
||||
"playerPremise": "玩家是返乡调查旧案的守灯人。"
|
||||
}),
|
||||
creator_intent_readiness: json!({
|
||||
"isReady": true
|
||||
}),
|
||||
anchor_pack: json!({
|
||||
"coreConflict": "群岛议会正在掩盖沉船真相。"
|
||||
}),
|
||||
lock_state: json!({}),
|
||||
draft_profile: JsonValue::Null,
|
||||
messages: Vec::new(),
|
||||
draft_cards: Vec::new(),
|
||||
pending_clarifications: Vec::new(),
|
||||
suggested_actions: Vec::new(),
|
||||
recommended_replies: Vec::new(),
|
||||
quality_findings: Vec::new(),
|
||||
asset_coverage: json!({}),
|
||||
checkpoints: Vec::new(),
|
||||
supported_actions: Vec::new(),
|
||||
publish_gate: None,
|
||||
result_preview: None,
|
||||
updated_at: "2026-04-23T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_test_llm_client(base_url: String) -> LlmClient {
|
||||
let config = LlmConfig::new(
|
||||
LlmProvider::Ark,
|
||||
base_url,
|
||||
"test-key".to_string(),
|
||||
"test-model".to_string(),
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.expect("llm config should build");
|
||||
|
||||
LlmClient::new(config).expect("llm client should build")
|
||||
}
|
||||
|
||||
fn spawn_mock_server(request_capture: Arc<Mutex<String>>, response_body: String) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("listener should expose address");
|
||||
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("request should connect");
|
||||
let request_text = read_request(&mut stream);
|
||||
*request_capture.lock().expect("request capture should lock") = request_text;
|
||||
write_response(&mut stream, response_body);
|
||||
});
|
||||
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(StdDuration::from_secs(1)))
|
||||
.expect("read timeout should be configured");
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let mut expected_total = None;
|
||||
|
||||
loop {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(bytes_read) => {
|
||||
buffer.extend_from_slice(&chunk[..bytes_read]);
|
||||
|
||||
if expected_total.is_none()
|
||||
&& let Some(header_end) = find_header_end(&buffer)
|
||||
{
|
||||
let content_length =
|
||||
read_content_length(&buffer[..header_end]).unwrap_or(0);
|
||||
expected_total = Some(header_end + content_length);
|
||||
}
|
||||
|
||||
if let Some(total_bytes) = expected_total
|
||||
&& buffer.len() >= total_bytes
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(buffer).expect("request should be utf-8")
|
||||
}
|
||||
|
||||
fn write_response(stream: &mut std::net::TcpStream, body: String) {
|
||||
let raw_response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(raw_response.as_bytes())
|
||||
.expect("mock response should be written");
|
||||
stream.flush().expect("mock response should flush");
|
||||
}
|
||||
|
||||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
}
|
||||
|
||||
fn read_content_length(headers: &[u8]) -> Option<usize> {
|
||||
let text = String::from_utf8_lossy(headers);
|
||||
text.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
return value.trim().parse::<usize>().ok();
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user