Merge remote-tracking branch 'origin/master' into feat/agc-client-sync-llm-config
# Conflicts: # apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx # apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx # apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx # docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
This commit is contained in:
@@ -107,12 +107,16 @@ const rustSharedContractSource = fs.readFileSync(
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const allowedUncalledTauriCommands = [
|
const allowedUncalledTauriCommands = [
|
||||||
|
'append_direct_project_conversation_message',
|
||||||
'chat_with_game_creator_agent',
|
'chat_with_game_creator_agent',
|
||||||
'check_ui_editor_font_glyph_coverage',
|
'check_ui_editor_font_glyph_coverage',
|
||||||
'create_ui_design_resource',
|
'create_ui_design_resource',
|
||||||
'open_game_creator_launcher_window',
|
'open_game_creator_launcher_window',
|
||||||
'open_game_creator_workspace_window',
|
'open_game_creator_workspace_window',
|
||||||
|
'read_direct_project_conversation',
|
||||||
'stop_local_game_preview_if_matches',
|
'stop_local_game_preview_if_matches',
|
||||||
|
'start_game_creator_external_mcp',
|
||||||
|
'stop_game_creator_external_mcp',
|
||||||
];
|
];
|
||||||
const sourceExtensions = new Set([
|
const sourceExtensions = new Set([
|
||||||
'.json',
|
'.json',
|
||||||
|
|||||||
@@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBackendServiceFailure(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
expectedDatabase = backendDatabase,
|
||||||
|
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const targets = resolveBackendTargetsFromState(state, {
|
||||||
|
requireAgcBackend: true,
|
||||||
|
expectedDatabase,
|
||||||
|
expectedSpacetimeDataDir,
|
||||||
|
});
|
||||||
|
if (!targets.hasMatchingBackend) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
|
||||||
|
const service = state?.services?.[serviceName];
|
||||||
|
if (service?.status !== 'failed') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceName,
|
||||||
|
failure: service.signal
|
||||||
|
? `signal=${service.signal}`
|
||||||
|
: `code=${service.exitCode ?? 1}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function isBackendReady({
|
async function isBackendReady({
|
||||||
state = readJson(devStackStatePath),
|
state = readJson(devStackStatePath),
|
||||||
isReady = isHttpReady,
|
isReady = isHttpReady,
|
||||||
@@ -505,11 +538,29 @@ async function terminateChildTree(
|
|||||||
return { stopped, forced: true };
|
return { stopped, forced: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
async function waitForBackendReady(
|
||||||
|
backendChild,
|
||||||
|
timeoutMs = 600_000,
|
||||||
|
{
|
||||||
|
checkBackendReady = isBackendReady,
|
||||||
|
readState = () => readJson(devStackStatePath),
|
||||||
|
resolveTargets = readBackendTargets,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
if (await isBackendReady()) {
|
if (await checkBackendReady()) {
|
||||||
return readBackendTargets();
|
return resolveTargets();
|
||||||
|
}
|
||||||
|
const state = readState();
|
||||||
|
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
|
||||||
|
const serviceFailure = readBackendServiceFailure(state);
|
||||||
|
if (serviceFailure) {
|
||||||
|
throw new Error(
|
||||||
|
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const failure = readChildFailure(backendChild);
|
const failure = readChildFailure(backendChild);
|
||||||
if (failure) {
|
if (failure) {
|
||||||
@@ -686,6 +737,7 @@ export {
|
|||||||
isDirectModuleExecution,
|
isDirectModuleExecution,
|
||||||
isProcessGroupAlive,
|
isProcessGroupAlive,
|
||||||
preflightExistingVite,
|
preflightExistingVite,
|
||||||
|
readBackendServiceFailure,
|
||||||
readChildFailure,
|
readChildFailure,
|
||||||
readExistingViteServer,
|
readExistingViteServer,
|
||||||
readLinuxProcessGroupAlive,
|
readLinuxProcessGroupAlive,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ mod codex_cli;
|
|||||||
mod codex_provider_proxy;
|
mod codex_provider_proxy;
|
||||||
mod direct_codex_attachments;
|
mod direct_codex_attachments;
|
||||||
mod direct_codex_audit;
|
mod direct_codex_audit;
|
||||||
|
mod direct_project_history;
|
||||||
|
mod direct_project_turn_history;
|
||||||
mod direct_runtime;
|
mod direct_runtime;
|
||||||
mod direct_tool_bridge;
|
mod direct_tool_bridge;
|
||||||
mod direct_tools_mcp;
|
mod direct_tools_mcp;
|
||||||
@@ -38,6 +40,8 @@ pub(crate) use codex_cli::{
|
|||||||
pub(crate) use codex_provider_proxy::*;
|
pub(crate) use codex_provider_proxy::*;
|
||||||
pub(crate) use direct_codex_attachments::*;
|
pub(crate) use direct_codex_attachments::*;
|
||||||
pub(crate) use direct_codex_audit::*;
|
pub(crate) use direct_codex_audit::*;
|
||||||
|
pub(crate) use direct_project_history::*;
|
||||||
|
pub(crate) use direct_project_turn_history::*;
|
||||||
pub(crate) use direct_runtime::*;
|
pub(crate) use direct_runtime::*;
|
||||||
pub(crate) use direct_tool_bridge::*;
|
pub(crate) use direct_tool_bridge::*;
|
||||||
pub(crate) use direct_tools_mcp::*;
|
pub(crate) use direct_tools_mcp::*;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
|||||||
|
use crate::config::prepare_game_creator_private_path_for_read;
|
||||||
|
use crate::project::{
|
||||||
|
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
|
||||||
|
};
|
||||||
|
use crate::{LocalConversationMessageRecord, LocalConversationResult};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
|
||||||
|
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
|
||||||
|
"host_skills.instructions",
|
||||||
|
"permissions.instructions",
|
||||||
|
"environments.environment_context",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
|
||||||
|
("# AGENTS.md instructions", "</INSTRUCTIONS>"),
|
||||||
|
("<environment_context>", "</environment_context>"),
|
||||||
|
("<skill>", "</skill>"),
|
||||||
|
("<user_shell_command>", "</user_shell_command>"),
|
||||||
|
("<turn_aborted>", "</turn_aborted>"),
|
||||||
|
("<subagent_notification>", "</subagent_notification>"),
|
||||||
|
("<recommended_plugins>", "</recommended_plugins>"),
|
||||||
|
("<goal_context>", "</goal_context>"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_known_contextual_user_text(text: &str) -> bool {
|
||||||
|
let text = text.trim();
|
||||||
|
if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
|
||||||
|
.iter()
|
||||||
|
.any(|(start, end)| text.starts_with(start) && text.ends_with(end))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if text.starts_with("<codex_internal_context") && text.ends_with("</codex_internal_context>") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
text.starts_with("<external_")
|
||||||
|
&& text
|
||||||
|
.split_once('>')
|
||||||
|
.and_then(|(start, _)| start.strip_prefix("<external_"))
|
||||||
|
.is_some_and(|key| text.ends_with(&format!("</external_{key}>")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
|
||||||
|
if matches!(
|
||||||
|
item.get("role").and_then(Value::as_str),
|
||||||
|
Some("developer" | "system")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if item
|
||||||
|
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|kinds| {
|
||||||
|
kinds
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
item.get("content")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|parts| {
|
||||||
|
parts.iter().any(|part| {
|
||||||
|
let Some(text) = part.get("text").and_then(Value::as_str) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
is_known_contextual_user_text(text)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_path(root: &Path) -> PathBuf {
|
||||||
|
root.join(".agent/conversations/project.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(item: &Value) -> Result<String, String> {
|
||||||
|
serde_json::to_string(&serde_json::json!({
|
||||||
|
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
|
||||||
|
"payload": item,
|
||||||
|
}))
|
||||||
|
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
fn find_direct_project_history_item_by_id_at(
|
||||||
|
path: &Path,
|
||||||
|
item_id: &str,
|
||||||
|
) -> Result<Option<Value>, String> {
|
||||||
|
let mut file = File::open(path)
|
||||||
|
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||||
|
let mut position = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"读取 DirectProject 历史元数据失败:{}: {error}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.len();
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if position == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let read_len = usize::try_from(position)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
.min(chunk.len());
|
||||||
|
position -= read_len as u64;
|
||||||
|
file.seek(SeekFrom::Start(position))
|
||||||
|
.map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||||
|
file.read_exact(&mut chunk[..read_len])
|
||||||
|
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||||
|
|
||||||
|
let mut combined = Vec::with_capacity(read_len + pending.len());
|
||||||
|
combined.extend_from_slice(&chunk[..read_len]);
|
||||||
|
combined.extend_from_slice(&pending);
|
||||||
|
let mut line_end = combined.len();
|
||||||
|
while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
|
||||||
|
let line = &combined[newline + 1..line_end];
|
||||||
|
if !line.is_empty() {
|
||||||
|
if let Some(item) = direct_project_history_item_from_line(path, line)? {
|
||||||
|
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||||
|
return Ok(Some(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line_end = newline;
|
||||||
|
}
|
||||||
|
pending = combined[..line_end].to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pending.is_empty() {
|
||||||
|
// The append path repairs an unterminated final JSONL record before
|
||||||
|
// writing. A duplicate scan must not reject that repairable tail.
|
||||||
|
if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
|
||||||
|
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||||
|
return Ok(Some(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_project_history_item_from_line(
|
||||||
|
path: &Path,
|
||||||
|
line: &[u8],
|
||||||
|
) -> Result<Option<Value>, String> {
|
||||||
|
if line.iter().all(|byte| byte.is_ascii_whitespace()) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let parsed: Value = serde_json::from_slice(line)
|
||||||
|
.map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||||
|
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||||
|
return Err(format!(
|
||||||
|
"DirectProject 历史记录类型无效:{}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let item = parsed
|
||||||
|
.get("payload")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||||
|
if is_direct_project_internal_context_item(&item) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn append_direct_project_history_item_at(
|
||||||
|
root: &Path,
|
||||||
|
item: &Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
append_direct_project_history_item_at_with_user_policy(root, item, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a user message authored by AGC itself. Codex response items use
|
||||||
|
/// the default path above, which deliberately ignores echoed user messages;
|
||||||
|
/// this explicit entry point keeps the two sources distinct.
|
||||||
|
pub(crate) fn append_direct_project_user_message_at(
|
||||||
|
root: &Path,
|
||||||
|
item: &Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
append_direct_project_history_item_at_with_user_policy(root, item, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_direct_project_history_item_at_with_user_policy(
|
||||||
|
root: &Path,
|
||||||
|
item: &Value,
|
||||||
|
allow_user_item: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
enforce_project_permission_policy(root, "conversation.write")?;
|
||||||
|
if is_direct_project_internal_context_item(item) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !allow_user_item && is_direct_project_codex_user_item(item) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
||||||
|
let path = history_path(root);
|
||||||
|
let history_exists =
|
||||||
|
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
|
||||||
|
let lock = project_append_lock_for(&path)?;
|
||||||
|
let _append_guard = lock.lock("DirectProject 历史追加写")?;
|
||||||
|
if history_exists {
|
||||||
|
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||||
|
if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
|
||||||
|
if &existing == item {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line = record(item)?;
|
||||||
|
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_direct_project_codex_user_item(item: &Value) -> bool {
|
||||||
|
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!item
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn direct_project_local_message_item(
|
||||||
|
role: &str,
|
||||||
|
content: &str,
|
||||||
|
message_id: Option<&str>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let role = role.trim();
|
||||||
|
let content = content.trim();
|
||||||
|
if content.is_empty() || !matches!(role, "user" | "assistant") {
|
||||||
|
return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
|
||||||
|
}
|
||||||
|
let mut item = serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": role,
|
||||||
|
"content": [{
|
||||||
|
"type": if role == "user" { "input_text" } else { "output_text" },
|
||||||
|
"text": content,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
item["id"] = Value::String(message_id.to_string());
|
||||||
|
}
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
|
||||||
|
let path = history_path(root);
|
||||||
|
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let file = File::open(&path)
|
||||||
|
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
let bytes = reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||||
|
if bytes == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let had_newline = line.ends_with('\n');
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parsed: Value = match serde_json::from_str(trimmed) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_error) if !had_newline => break,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"解析 DirectProject 历史失败:{}: {error}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||||
|
return Err(format!(
|
||||||
|
"DirectProject 历史记录类型无效:{}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let item = parsed
|
||||||
|
.get("payload")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||||
|
if is_direct_project_internal_context_item(&item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_direct_project_chat_history_at(
|
||||||
|
root: &Path,
|
||||||
|
) -> Result<LocalConversationResult, String> {
|
||||||
|
let path = history_path(root);
|
||||||
|
let items = read_direct_project_history_items_at(root)?;
|
||||||
|
let messages = items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let role = item.get("role").and_then(Value::as_str)?;
|
||||||
|
if !matches!(role, "user" | "assistant") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let content = item
|
||||||
|
.get("content")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|parts| {
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(!content.is_empty()).then(|| LocalConversationMessageRecord {
|
||||||
|
schema_version: "agc-direct-project-context.v1".to_string(),
|
||||||
|
role: role.to_string(),
|
||||||
|
content,
|
||||||
|
agent_id: None,
|
||||||
|
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||||
|
updated_at: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(LocalConversationResult {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
agent_id: None,
|
||||||
|
session_id: None,
|
||||||
|
messages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
|
||||||
|
is_direct_project_internal_context_item, read_direct_project_history_items_at,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_host_context_but_keeps_real_user_items() {
|
||||||
|
assert!(is_direct_project_internal_context_item(&json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "developer",
|
||||||
|
"content": [{"type": "input_text", "text": "<skills_instructions>"}]
|
||||||
|
})));
|
||||||
|
assert!(is_direct_project_internal_context_item(&json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": "<environment_context>"}],
|
||||||
|
"internal_chat_message_metadata_passthrough": {
|
||||||
|
"content_item_kinds": ["environments.environment_context"]
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
assert!(!is_direct_project_internal_context_item(&json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": "ls ui"}],
|
||||||
|
"internal_chat_message_metadata_passthrough": {
|
||||||
|
"content_item_kinds": ["user.text"]
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_codex_contextual_user_item_without_passthrough_metadata() {
|
||||||
|
assert!(is_direct_project_internal_context_item(&json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "input_text",
|
||||||
|
"text": "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"
|
||||||
|
}]
|
||||||
|
})));
|
||||||
|
assert!(!is_direct_project_internal_context_item(&json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "input_text",
|
||||||
|
"text": "请读取 <environment_context> 中的说明"
|
||||||
|
}]
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_repairs_truncated_tail_before_idempotency_scan() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
|
||||||
|
.expect("init project");
|
||||||
|
let path = history_path(root.path());
|
||||||
|
std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{"type":"response_item","payload":{"type":"message"}"#,
|
||||||
|
)
|
||||||
|
.expect("write truncated history tail");
|
||||||
|
let item = serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"id": "tail-repair-item",
|
||||||
|
"content": [{"type": "output_text", "text": "已修复"}]
|
||||||
|
});
|
||||||
|
append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
|
||||||
|
let items =
|
||||||
|
read_direct_project_history_items_at(root.path()).expect("read repaired history");
|
||||||
|
assert_eq!(items, vec![item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
|
||||||
|
.expect("init project");
|
||||||
|
let user = serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "direct-codex:turn-0001:user",
|
||||||
|
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||||
|
});
|
||||||
|
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
|
||||||
|
append_direct_project_history_item_at(
|
||||||
|
root.path(),
|
||||||
|
&serde_json::json!({
|
||||||
|
"type": "userMessage",
|
||||||
|
"id": "codex-user-item-1",
|
||||||
|
"clientId": "turn-0001",
|
||||||
|
"content": [{"type": "text", "text": "请创建菜单"}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("ignore Codex echo");
|
||||||
|
append_direct_project_history_item_at(
|
||||||
|
root.path(),
|
||||||
|
&serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "codex-raw-user-item-1",
|
||||||
|
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("ignore raw Codex user echo");
|
||||||
|
let items = read_direct_project_history_items_at(root.path()).expect("read history");
|
||||||
|
assert_eq!(items, vec![user]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use super::direct_project_history::append_direct_project_history_item_at;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct DirectProjectHistoryAccumulator {
|
||||||
|
text_by_item_id: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirectProjectHistoryAccumulator {
|
||||||
|
pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
|
||||||
|
self.text_by_item_id
|
||||||
|
.entry(item_id.to_string())
|
||||||
|
.or_default()
|
||||||
|
.push_str(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn complete_item(&mut self, item: &Value) {
|
||||||
|
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||||
|
self.text_by_item_id.remove(item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
|
||||||
|
std::mem::take(&mut self.text_by_item_id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, text)| !text.is_empty())
|
||||||
|
.map(|(item_id, text)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"id": item_id,
|
||||||
|
"content": [{"type": "output_text", "text": text}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn persist_direct_project_partial_items_at(
|
||||||
|
root: &Path,
|
||||||
|
accumulator: &mut DirectProjectHistoryAccumulator,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let items = accumulator.take_partial_items().collect::<Vec<_>>();
|
||||||
|
let mut first_error = None;
|
||||||
|
for item in items {
|
||||||
|
if let Err(error) = append_direct_project_history_item_at(root, &item) {
|
||||||
|
if first_error.is_none() {
|
||||||
|
first_error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first_error.map_or(Ok(()), Err)
|
||||||
|
}
|
||||||
@@ -2147,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
|
|||||||
let Ok(validated_slices) = validated_art_slices(root) else {
|
let Ok(validated_slices) = validated_art_slices(root) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
if validated_slices.len() != 4 {
|
if validated_slices.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
|
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
|
||||||
@@ -2718,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at(
|
|||||||
asset_kind: asset_kind.to_string(),
|
asset_kind: asset_kind.to_string(),
|
||||||
asset_label: asset_label.to_string(),
|
asset_label: asset_label.to_string(),
|
||||||
replace_existing: root.join(output_path).is_file(),
|
replace_existing: root.join(output_path).is_file(),
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let runtime_context =
|
let runtime_context =
|
||||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||||
@@ -2821,9 +2822,9 @@ fn direct_taonier_art_package_result(
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
if includes_spritesheet && slice_paths.len() != 4 {
|
if includes_spritesheet && slice_paths.is_empty() {
|
||||||
slice_warnings.push(
|
slice_warnings.push(
|
||||||
"当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
|
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4406,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<Strin
|
|||||||
Ok(client_turn_id.to_string())
|
Ok(client_turn_id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_direct_codex_assistant_reply_at(
|
|
||||||
root: &Path,
|
|
||||||
client_turn_id: &str,
|
|
||||||
reply: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
enforce_project_permission_policy(root, "conversation.write")?;
|
|
||||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
|
||||||
append_local_conversation_message_for_session_idempotent_at(
|
|
||||||
root,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
LocalConversationMessage {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: reply.to_string(),
|
|
||||||
agent_id: None,
|
|
||||||
},
|
|
||||||
&format!("direct-codex:{client_turn_id}:assistant"),
|
|
||||||
)
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_direct_codex_user_prompt_at(
|
|
||||||
root: &Path,
|
|
||||||
client_turn_id: &str,
|
|
||||||
prompt: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
enforce_project_permission_policy(root, "conversation.write")?;
|
|
||||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
|
||||||
append_local_conversation_message_for_session_idempotent_at(
|
|
||||||
root,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
LocalConversationMessage {
|
|
||||||
role: "user".to_string(),
|
|
||||||
content: prompt.trim().to_string(),
|
|
||||||
agent_id: None,
|
|
||||||
},
|
|
||||||
&format!("direct-codex:{client_turn_id}:user"),
|
|
||||||
)
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
@@ -4479,15 +4438,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
|
|
||||||
audit.finish(false);
|
|
||||||
turn_emitter.emit("failed", Some("none"), None);
|
|
||||||
return Err(redact_agent_runtime_error(
|
|
||||||
root,
|
|
||||||
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
|
|
||||||
500,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||||
root,
|
root,
|
||||||
&user_prompt,
|
&user_prompt,
|
||||||
@@ -4503,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
|
|
||||||
audit.finish(false);
|
|
||||||
turn_emitter.emit("failed", Some("none"), None);
|
|
||||||
return Err(redact_agent_runtime_error(
|
|
||||||
root,
|
|
||||||
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
|
|
||||||
500,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
audit.finish(true);
|
audit.finish(true);
|
||||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||||
Ok(reply)
|
Ok(reply)
|
||||||
@@ -4525,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
|||||||
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
|
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn persist_direct_codex_user_prompt_at(
|
||||||
|
root: &Path,
|
||||||
|
client_turn_id: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let item = direct_project_local_message_item(
|
||||||
|
"user",
|
||||||
|
prompt,
|
||||||
|
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||||
|
)?;
|
||||||
|
append_direct_project_user_message_at(root, &item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn persist_direct_codex_assistant_reply_at(
|
||||||
|
root: &Path,
|
||||||
|
client_turn_id: &str,
|
||||||
|
reply: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let item = direct_project_local_message_item(
|
||||||
|
"assistant",
|
||||||
|
reply,
|
||||||
|
Some(&format!("direct-codex:{client_turn_id}:assistant")),
|
||||||
|
)?;
|
||||||
|
append_direct_project_history_item_at(root, &item)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -4609,8 +4578,8 @@ mod tests {
|
|||||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
|
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
|
||||||
.expect("the App's same-id write converges idempotently");
|
.expect("the App's same-id write converges idempotently");
|
||||||
|
|
||||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
let conversation =
|
||||||
.expect("read project conversation");
|
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||||
let message_id = format!("direct-codex:{turn_id}:assistant");
|
let message_id = format!("direct-codex:{turn_id}:assistant");
|
||||||
let persisted = conversation
|
let persisted = conversation
|
||||||
.messages
|
.messages
|
||||||
@@ -4623,7 +4592,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
|
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
|
||||||
.expect_err("same message identity cannot be rebound")
|
.expect_err("same message identity cannot be rebound")
|
||||||
.contains("messageId 冲突")
|
.contains("item id 冲突")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4640,8 +4609,8 @@ mod tests {
|
|||||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
|
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
|
||||||
.expect("retry reuses the same user message identity");
|
.expect("retry reuses the same user message identity");
|
||||||
|
|
||||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
let conversation =
|
||||||
.expect("read project conversation");
|
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||||
let message_id = format!("direct-codex:{turn_id}:user");
|
let message_id = format!("direct-codex:{turn_id}:user");
|
||||||
let persisted = conversation
|
let persisted = conversation
|
||||||
.messages
|
.messages
|
||||||
@@ -4654,7 +4623,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
|
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
|
||||||
.expect_err("same turn identity cannot be rebound")
|
.expect_err("same turn identity cannot be rebound")
|
||||||
.contains("messageId 冲突")
|
.contains("item id 冲突")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization {
|
|||||||
|
|
||||||
struct DirectToolBridgeActiveTurnAuthorization {
|
struct DirectToolBridgeActiveTurnAuthorization {
|
||||||
turn_id: String,
|
turn_id: String,
|
||||||
allows_regeneration: bool,
|
|
||||||
brief_sha256: Option<String>,
|
brief_sha256: Option<String>,
|
||||||
completed_result: Option<Value>,
|
completed_result: Option<Value>,
|
||||||
resource_request_ids: BTreeMap<String, (String, String)>,
|
resource_request_ids: BTreeMap<String, (String, String)>,
|
||||||
@@ -181,30 +180,23 @@ impl DirectToolBridge {
|
|||||||
&self.url
|
&self.url
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arm exactly one client-owned Direct turn. The raw user message is used
|
/// Arm exactly one client-owned Direct turn. Codex chooses the business
|
||||||
/// only for this synchronous decision and is never retained by the bridge.
|
/// operation through the reviewed MCP tool and arguments; the bridge only
|
||||||
pub(crate) fn begin_user_turn(
|
/// binds that call to the active client turn.
|
||||||
&self,
|
pub(crate) fn begin_user_turn(&self) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||||
user_prompt: &str,
|
self.state.begin_user_turn()
|
||||||
) -> Result<DirectToolBridgeTurnGuard, String> {
|
|
||||||
self.state.begin_user_turn(user_prompt)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DirectToolBridgeState {
|
impl DirectToolBridgeState {
|
||||||
fn begin_user_turn(
|
fn begin_user_turn(self: &Arc<Self>) -> Result<DirectToolBridgeTurnGuard, String> {
|
||||||
self: &Arc<Self>,
|
|
||||||
user_prompt: &str,
|
|
||||||
) -> Result<DirectToolBridgeTurnGuard, String> {
|
|
||||||
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
|
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
|
||||||
let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt);
|
|
||||||
let mut authorization = self
|
let mut authorization = self
|
||||||
.turn_authorization
|
.turn_authorization
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
|
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
|
||||||
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
|
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
|
||||||
turn_id: turn_id.clone(),
|
turn_id: turn_id.clone(),
|
||||||
allows_regeneration,
|
|
||||||
brief_sha256: None,
|
brief_sha256: None,
|
||||||
completed_result: None,
|
completed_result: None,
|
||||||
resource_request_ids: BTreeMap::new(),
|
resource_request_ids: BTreeMap::new(),
|
||||||
@@ -594,12 +586,9 @@ impl DirectToolBridgeState {
|
|||||||
.active
|
.active
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
|
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
|
||||||
if !active.allows_regeneration {
|
|
||||||
return Err("当前用户消息未显式授权重新生成或替换美术".to_string());
|
|
||||||
}
|
|
||||||
match active.brief_sha256.as_deref() {
|
match active.brief_sha256.as_deref() {
|
||||||
Some(expected) if expected != brief_sha256 => {
|
Some(expected) if expected != brief_sha256 => {
|
||||||
return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string())
|
return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string())
|
||||||
}
|
}
|
||||||
None => active.brief_sha256 = Some(brief_sha256.clone()),
|
None => active.brief_sha256 = Some(brief_sha256.clone()),
|
||||||
Some(_) => {}
|
Some(_) => {}
|
||||||
@@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
|||||||
asset_kind: kind.clone(),
|
asset_kind: kind.clone(),
|
||||||
asset_label: asset_name.clone(),
|
asset_label: asset_name.clone(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let _generation_guard = state.image_generation_gate.lock().await;
|
let _generation_guard = state.image_generation_gate.lock().await;
|
||||||
let generated = with_direct_editor_api_credentials(
|
let generated = with_direct_editor_api_credentials(
|
||||||
@@ -2736,106 +2726,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() {
|
fn regenerate_uses_current_client_turn_and_one_stable_brief() {
|
||||||
for prompt in [
|
|
||||||
"继续修复布局",
|
|
||||||
"解释一下重新生成美术是什么意思",
|
|
||||||
"不要重新生成美术,只调整代码",
|
|
||||||
"别换一套美术,继续用现在这套",
|
|
||||||
"解释一下换一套美术按钮",
|
|
||||||
"是否要改变视觉风格?",
|
|
||||||
"Do not regenerate the art; keep the current package.",
|
|
||||||
"I don't want to change the visual style.",
|
|
||||||
"What does use a new art set mean?",
|
|
||||||
"文案写着“换一套美术”",
|
|
||||||
"Yesterday I said regenerate art, but today keep it.",
|
|
||||||
"Please explain how to regenerate art.",
|
|
||||||
"重新生成美术以后再说,现在只修代码",
|
|
||||||
"重做美术先不做,先改玩法",
|
|
||||||
"Regenerate the art maybe later; for now just fix the code.",
|
|
||||||
"把按钮文案改成“请重新生成美术”,不要执行生成工具",
|
|
||||||
"把按钮文案改成‘请重新生成美术’,不要执行生成工具",
|
|
||||||
"Change the button label to 'please regenerate the art'; do not execute it.",
|
|
||||||
"用户之前说请重新生成美术,我只是在复述",
|
|
||||||
"Yesterday the user said please regenerate the art; I am just quoting it.",
|
|
||||||
"以后请重新生成美术,现在先改代码",
|
|
||||||
"你能不能帮我重新生成美术,顺便解释一下价格",
|
|
||||||
"请重新生成美术吗",
|
|
||||||
"请重新生成美术吗,还是只改代码",
|
|
||||||
"请重新生成美术或者只改代码",
|
|
||||||
"请重新生成美术以外的内容",
|
|
||||||
"请重新生成美术,但不要执行生成工具",
|
|
||||||
"不需要重新生成美术",
|
|
||||||
"界面上显示:请重新生成美术",
|
|
||||||
"界面标题是请重新生成美术",
|
|
||||||
"产品经理让我写请重新生成美术",
|
|
||||||
"下周请重新生成美术",
|
|
||||||
"他说«请重新生成美术»",
|
|
||||||
"Could you please regenerate the art",
|
|
||||||
"Please regenerate the art? Or only fix code.",
|
|
||||||
"Please regenerate the art except for the paid generation.",
|
|
||||||
"Please regenerate the art, but do not execute the tool.",
|
|
||||||
"Please regenerate the art, but don’t execute the tool.",
|
|
||||||
"Please regenerate the art, but I don't authorize this paid generation.",
|
|
||||||
"Please regenerate the art, but I don‘t authorize this paid generation.",
|
|
||||||
"Please regenerate the art, but do not execute the paid tool.",
|
|
||||||
"Please regenerate the art, but never execute the paid tool.",
|
|
||||||
"Please regenerate the art, but avoid executing the paid tool.",
|
|
||||||
"Please regenerate the art, but 'do not execute the tool",
|
|
||||||
"Please regenerate the art only if it is free.",
|
|
||||||
"Please regenerate the art only after I confirm the charge.",
|
|
||||||
"Please regenerate the art, but do “not” execute the paid tool.",
|
|
||||||
"请重新生成美术,三天后再执行。",
|
|
||||||
"请重新生成美术,得到我的许可再做。",
|
|
||||||
"请重新生成美术,地面需要无缝循环。",
|
|
||||||
"Please regenerate the art, but skip the paid generation.",
|
|
||||||
"请重新生成美术【生成操作跳过】",
|
|
||||||
"请重新生成美术【仅在零元时执行】",
|
|
||||||
"Please regenerate the art “but skip the paid generation”",
|
|
||||||
"Please regenerate the art; alternatively, just fix the code.",
|
|
||||||
"Please regenerate the art, but do n\u{200B}ot execute the paid tool.",
|
|
||||||
"Please regenerate the art with a clay style.",
|
|
||||||
"I don't need you to regenerate the art",
|
|
||||||
"The UI shows: please regenerate the art",
|
|
||||||
"Please regenerate the art next week",
|
|
||||||
"He said «please regenerate the art»",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!direct_user_explicitly_authorizes_art_regeneration(prompt),
|
|
||||||
"prompt must fail closed: {prompt}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for prompt in [
|
|
||||||
"请重新生成美术。",
|
|
||||||
"那就请重新生成美术!",
|
|
||||||
"换一套美术",
|
|
||||||
"Please regenerate the art!",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
direct_user_explicitly_authorizes_art_regeneration(prompt),
|
|
||||||
"prompt must explicitly authorize: {prompt}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = tempfile::tempdir().expect("stable client turn root");
|
let root = tempfile::tempdir().expect("stable client turn root");
|
||||||
let state = direct_tool_bridge_state(root.path().to_path_buf());
|
let state = direct_tool_bridge_state(root.path().to_path_buf());
|
||||||
assert!(state.begin_user_turn("请重新生成美术").is_err());
|
assert!(state.begin_user_turn().is_err());
|
||||||
let client_turn_id = "client-turn-stable-0001";
|
let client_turn_id = "client-turn-stable-0001";
|
||||||
let _active_invocation =
|
let _active_invocation =
|
||||||
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
|
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
|
||||||
.expect("client-owned stable invocation");
|
.expect("client-owned stable invocation");
|
||||||
let ordinary_turn = state
|
let active_turn = state
|
||||||
.begin_user_turn("继续优化交互")
|
.begin_user_turn()
|
||||||
.expect("ordinary turn authorization state");
|
.expect("client turn authorization state");
|
||||||
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
|
||||||
drop(ordinary_turn);
|
|
||||||
|
|
||||||
let authorized_turn = state
|
|
||||||
.begin_user_turn("请重新生成美术")
|
|
||||||
.expect("authorized regeneration turn");
|
|
||||||
let (turn_id, brief_sha256) = match state
|
let (turn_id, brief_sha256) = match state
|
||||||
.authorize_regeneration_call("陶泥风格")
|
.authorize_regeneration_call("陶泥风格")
|
||||||
.expect("first stable regeneration call")
|
.expect("MCP mode selects regeneration explicitly")
|
||||||
{
|
{
|
||||||
DirectToolBridgeRegenerationCall::Execute {
|
DirectToolBridgeRegenerationCall::Execute {
|
||||||
turn_id,
|
turn_id,
|
||||||
@@ -2864,7 +2768,7 @@ mod tests {
|
|||||||
panic!("completed stable retry must not execute a second paid call")
|
panic!("completed stable retry must not execute a second paid call")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(authorized_turn);
|
drop(active_turn);
|
||||||
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
|
|||||||
pub(crate) asset_kind: String,
|
pub(crate) asset_kind: String,
|
||||||
pub(crate) asset_label: String,
|
pub(crate) asset_label: String,
|
||||||
pub(crate) replace_existing: bool,
|
pub(crate) replace_existing: bool,
|
||||||
|
pub(crate) slice_count: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlatformArtAssetGenerationOptions {
|
impl Default for PlatformArtAssetGenerationOptions {
|
||||||
@@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions {
|
|||||||
asset_kind: "game-art".to_string(),
|
asset_kind: "game-art".to_string(),
|
||||||
asset_label: "AI 游戏首版美术素材".to_string(),
|
asset_label: "AI 游戏首版美术素材".to_string(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
|
|||||||
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
|
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Observe an accepted operation once without waiting. A single GET that
|
||||||
|
/// reports `failed` is authoritative and allows a changed retry to release
|
||||||
|
/// the old local slot; queued/running/unknown outcomes remain protected.
|
||||||
|
async fn accepted_generation_is_authoritatively_failed_once(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
access: &ExternalEditorBindingAccess<'_>,
|
||||||
|
submission_payload: &serde_json::Value,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let submission = external_editor_response_data(submission_payload);
|
||||||
|
let operation_id = json_string_field(submission, "operationId")
|
||||||
|
.ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?;
|
||||||
|
access.validate_frozen_session()?;
|
||||||
|
let payload = tokio::time::timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
external_editor_json_request(
|
||||||
|
client
|
||||||
|
.get(format!(
|
||||||
|
"{}{}",
|
||||||
|
access.api_base_url(),
|
||||||
|
access.generation_status_route(&operation_id)
|
||||||
|
))
|
||||||
|
.bearer_auth(access.bearer_token()),
|
||||||
|
"查询平台图片生成任务",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
|
||||||
|
access.validate_frozen_session()?;
|
||||||
|
let generation = platform_generation_status_data(&payload);
|
||||||
|
match json_string_field(generation, "status").as_deref() {
|
||||||
|
Some("failed") => Ok(true),
|
||||||
|
Some("queued" | "running" | "completed") => Ok(false),
|
||||||
|
Some(status) => Err(format!(
|
||||||
|
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}"
|
||||||
|
)),
|
||||||
|
None => Err(format!(
|
||||||
|
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn external_editor_json_request(
|
pub(crate) async fn external_editor_json_request(
|
||||||
request: reqwest::RequestBuilder,
|
request: reqwest::RequestBuilder,
|
||||||
action: &str,
|
action: &str,
|
||||||
@@ -1714,24 +1757,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
|
|||||||
// long creation request cannot reject the atlas before it is queued.
|
// long creation request cannot reject the atlas before it is queued.
|
||||||
const MAX_DESCRIPTION_CHARS: usize = 200;
|
const MAX_DESCRIPTION_CHARS: usize = 200;
|
||||||
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
|
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
|
||||||
[
|
let category =
|
||||||
"第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
|
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
|
||||||
"第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
|
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
||||||
"第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材",
|
category
|
||||||
"第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
|
.chars()
|
||||||
]
|
.count()
|
||||||
.into_iter()
|
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
||||||
.map(|category| {
|
);
|
||||||
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
||||||
category
|
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
|
||||||
.chars()
|
|
||||||
.count()
|
|
||||||
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
|
||||||
);
|
|
||||||
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
|
||||||
format!("{category}{CONTEXT_PREFIX}{project_context}")
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
|
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
|
||||||
@@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
|||||||
}
|
}
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||||
let runtime_context =
|
let runtime_context =
|
||||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?;
|
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||||
generate_platform_art_asset_with_runtime_options_at(
|
generate_platform_art_asset_with_runtime_options_at(
|
||||||
root,
|
root,
|
||||||
prompt,
|
prompt,
|
||||||
briefs,
|
briefs,
|
||||||
options,
|
options,
|
||||||
true,
|
false,
|
||||||
&runtime_context,
|
&runtime_context,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if snapshot.generation_prompt != generation_prompt {
|
if snapshot.generation_prompt != generation_prompt {
|
||||||
|
if platform_art_generation_runtime_status(&state) == "accepted" {
|
||||||
|
if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) {
|
||||||
|
if accepted_generation_is_authoritatively_failed_once(
|
||||||
|
&client,
|
||||||
|
&binding_access,
|
||||||
|
&submission,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Some(context) = runtime_context {
|
||||||
|
remove_platform_art_generation_runtime_state_at(
|
||||||
|
root,
|
||||||
|
&context.agent_id,
|
||||||
|
&context.run_id,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
return Box::pin(request_platform_art_asset_with_runtime_options_at(
|
||||||
|
root,
|
||||||
|
prompt,
|
||||||
|
briefs,
|
||||||
|
options,
|
||||||
|
runtime_context,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作"
|
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作"
|
||||||
));
|
));
|
||||||
@@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if snapshot.reference_resource_ids != [current_reference] {
|
if snapshot.reference_resource_ids != [current_reference] {
|
||||||
|
if platform_art_generation_runtime_status(&state) == "accepted" {
|
||||||
|
if let Ok(submission) =
|
||||||
|
platform_art_generation_runtime_submission_payload(&state)
|
||||||
|
{
|
||||||
|
if accepted_generation_is_authoritatively_failed_once(
|
||||||
|
&client,
|
||||||
|
&binding_access,
|
||||||
|
&submission,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Some(context) = runtime_context {
|
||||||
|
remove_platform_art_generation_runtime_state_at(
|
||||||
|
root,
|
||||||
|
&context.agent_id,
|
||||||
|
&context.run_id,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
return Box::pin(request_platform_art_asset_with_runtime_options_at(
|
||||||
|
root,
|
||||||
|
prompt,
|
||||||
|
briefs,
|
||||||
|
options,
|
||||||
|
runtime_context,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作"
|
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作"
|
||||||
));
|
));
|
||||||
@@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"referenceId": reference_id,
|
"referenceId": reference_id,
|
||||||
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
|
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
|
||||||
"sliceLayout": "grid-2x2",
|
"sliceCount": options.slice_count,
|
||||||
"screenColor": "auto",
|
"screenColor": "auto",
|
||||||
"aspectRatio": options.aspect_ratio,
|
"aspectRatio": options.aspect_ratio,
|
||||||
"imageSize": options.image_size,
|
"imageSize": options.image_size,
|
||||||
@@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract(
|
|||||||
has_transparent_pixels: bool,
|
has_transparent_pixels: bool,
|
||||||
has_visible_pixels: bool,
|
has_visible_pixels: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if slices.len() != 4 {
|
if slices.is_empty() {
|
||||||
return Err(format!(
|
return Err("spritesheet 图集至少需要一个独立切片".to_string());
|
||||||
"strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个",
|
|
||||||
slices.len()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let resource_id = resource_id
|
let resource_id = resource_id
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract(
|
|||||||
{
|
{
|
||||||
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
|
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
|
||||||
}
|
}
|
||||||
if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") {
|
let _requested_slice_layout = spritesheet_slice_layout;
|
||||||
return Err(
|
|
||||||
"strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if reference_resource_ids.len() != 1
|
if reference_resource_ids.len() != 1
|
||||||
|| reference_resource_ids[0].trim().is_empty()
|
|| reference_resource_ids[0].trim().is_empty()
|
||||||
|| reference_resource_ids[0].trim() == resource_id
|
|| reference_resource_ids[0].trim() == resource_id
|
||||||
@@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete(
|
|||||||
manifest: &GameCreationAppManifest,
|
manifest: &GameCreationAppManifest,
|
||||||
registrations: &[PlatformArtSliceManifestRegistration],
|
registrations: &[PlatformArtSliceManifestRegistration],
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
if registrations.len() != 4 {
|
if registrations.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len());
|
let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len());
|
||||||
@@ -7594,6 +7679,7 @@ mod canvas_generation_tests {
|
|||||||
asset_kind: "game-background".to_string(),
|
asset_kind: "game-background".to_string(),
|
||||||
asset_label: "手工背景".to_string(),
|
asset_label: "手工背景".to_string(),
|
||||||
replace_existing: true,
|
replace_existing: true,
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let ordinary =
|
let ordinary =
|
||||||
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
|
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
|
||||||
@@ -9402,6 +9488,7 @@ mod canvas_generation_tests {
|
|||||||
asset_kind: "icon-spec".to_string(),
|
asset_kind: "icon-spec".to_string(),
|
||||||
asset_label: "整包规范图".to_string(),
|
asset_label: "整包规范图".to_string(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let prompt = "生成同一套整包美术";
|
let prompt = "生成同一套整包美术";
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||||
@@ -10239,6 +10326,7 @@ mod canvas_generation_tests {
|
|||||||
asset_kind: "game-background".to_string(),
|
asset_kind: "game-background".to_string(),
|
||||||
asset_label: "整包背景图".to_string(),
|
asset_label: "整包背景图".to_string(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let prompt = "保持同一个生成提示词";
|
let prompt = "保持同一个生成提示词";
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||||
@@ -10690,6 +10778,7 @@ mod canvas_generation_tests {
|
|||||||
asset_kind: "icon-spec".to_string(),
|
asset_kind: "icon-spec".to_string(),
|
||||||
asset_label: "游戏统一视觉规范图".to_string(),
|
asset_label: "游戏统一视觉规范图".to_string(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let prompt = "恢复已受理视觉规范图";
|
let prompt = "恢复已受理视觉规范图";
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||||
@@ -11264,6 +11353,7 @@ mod canvas_generation_tests {
|
|||||||
asset_kind: "art-spritesheet".to_string(),
|
asset_kind: "art-spritesheet".to_string(),
|
||||||
asset_label: "游戏首版核心美术素材".to_string(),
|
asset_label: "游戏首版核心美术素材".to_string(),
|
||||||
replace_existing: true,
|
replace_existing: true,
|
||||||
|
slice_count: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+3
-10
@@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
|
|||||||
"首批 art-director 必须是非只读规范图生成任务",
|
"首批 art-director 必须是非只读规范图生成任务",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let art_artifacts =
|
// 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。
|
||||||
|
let _art_artifacts =
|
||||||
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
|
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
|
||||||
if !art_artifacts
|
|
||||||
.iter()
|
|
||||||
.any(|path| path == "assets/art-spec.png")
|
|
||||||
{
|
|
||||||
return Err(autonomous_initial_collaboration_contract_error(
|
|
||||||
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let code_director = code_director.ok_or_else(|| {
|
let code_director = code_director.ok_or_else(|| {
|
||||||
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
|
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
|
||||||
@@ -1976,7 +1969,7 @@ mod tests {
|
|||||||
plan: Vec::new(),
|
plan: Vec::new(),
|
||||||
actions: vec![
|
actions: vec![
|
||||||
autonomous_initial_delegate("design-director", &[]),
|
autonomous_initial_delegate("design-director", &[]),
|
||||||
autonomous_initial_delegate("art-director", &["assets/art-spec.png"]),
|
autonomous_initial_delegate("art-director", &[]),
|
||||||
autonomous_initial_delegate("code-director", &[]),
|
autonomous_initial_delegate("code-director", &[]),
|
||||||
],
|
],
|
||||||
response: String::new(),
|
response: String::new(),
|
||||||
|
|||||||
@@ -1199,32 +1199,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
|
|||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
required_run_id: Option<&str>,
|
required_run_id: Option<&str>,
|
||||||
) -> Option<AgentRuntimeToolObservation> {
|
) -> Option<AgentRuntimeToolObservation> {
|
||||||
if !editor_api_key_is_configured() {
|
// 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。
|
||||||
return None;
|
return None;
|
||||||
}
|
#[allow(unreachable_code)]
|
||||||
let (expected_path, expected_kind, label) = match agent_id {
|
{
|
||||||
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
|
if !editor_api_key_is_configured() {
|
||||||
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
|
return None;
|
||||||
"art-asset-plan" => (
|
|
||||||
"assets/art-spritesheet.png",
|
|
||||||
"art-spritesheet",
|
|
||||||
"首版美术素材图",
|
|
||||||
),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let manifest = match read_manifest_for_project(root) {
|
|
||||||
Ok(manifest) => manifest,
|
|
||||||
Err(error) => {
|
|
||||||
return Some(AgentRuntimeToolObservation {
|
|
||||||
tool: "runtime.visual_asset".to_string(),
|
|
||||||
status: "blocked".to_string(),
|
|
||||||
summary: format!("无法核对{label},不能完成任务"),
|
|
||||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
let (expected_path, expected_kind, label) = match agent_id {
|
||||||
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
|
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
|
||||||
return Some(AgentRuntimeToolObservation {
|
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
|
||||||
|
"art-asset-plan" => (
|
||||||
|
"assets/art-spritesheet.png",
|
||||||
|
"art-spritesheet",
|
||||||
|
"首版美术素材图",
|
||||||
|
),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let manifest = match read_manifest_for_project(root) {
|
||||||
|
Ok(manifest) => manifest,
|
||||||
|
Err(error) => {
|
||||||
|
return Some(AgentRuntimeToolObservation {
|
||||||
|
tool: "runtime.visual_asset".to_string(),
|
||||||
|
status: "blocked".to_string(),
|
||||||
|
summary: format!("无法核对{label},不能完成任务"),
|
||||||
|
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
|
||||||
|
return Some(AgentRuntimeToolObservation {
|
||||||
tool: "runtime.visual_asset".to_string(),
|
tool: "runtime.visual_asset".to_string(),
|
||||||
status: "blocked".to_string(),
|
status: "blocked".to_string(),
|
||||||
summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"),
|
summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"),
|
||||||
@@ -1234,29 +1238,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
|
|||||||
redact_agent_runtime_project_paths(root, &error, 300),
|
redact_agent_runtime_project_paths(root, &error, 300),
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if agent_id != "design-foundation" {
|
if agent_id != "design-foundation" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match ui_prototype_visual_inspection_blocker_detail_at_locked(
|
match ui_prototype_visual_inspection_blocker_detail_at_locked(
|
||||||
root,
|
root,
|
||||||
agent_id,
|
agent_id,
|
||||||
required_run_id,
|
required_run_id,
|
||||||
expected_path,
|
expected_path,
|
||||||
) {
|
) {
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
Ok(Some(detail)) => Some(AgentRuntimeToolObservation {
|
Ok(Some(detail)) => Some(AgentRuntimeToolObservation {
|
||||||
tool: "runtime.visual_asset".to_string(),
|
tool: "runtime.visual_asset".to_string(),
|
||||||
status: "blocked".to_string(),
|
status: "blocked".to_string(),
|
||||||
summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(),
|
summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(),
|
||||||
detail: Some(detail),
|
detail: Some(detail),
|
||||||
}),
|
}),
|
||||||
Err(error) => Some(AgentRuntimeToolObservation {
|
Err(error) => Some(AgentRuntimeToolObservation {
|
||||||
tool: "runtime.visual_asset".to_string(),
|
tool: "runtime.visual_asset".to_string(),
|
||||||
status: "blocked".to_string(),
|
status: "blocked".to_string(),
|
||||||
summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(),
|
summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(),
|
||||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
||||||
}),
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool {
|
pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool {
|
||||||
editor_api_key_is_configured()
|
false
|
||||||
&& matches!(
|
|
||||||
task_id,
|
|
||||||
"art-director" | "design-foundation" | "art-asset-plan"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String {
|
fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String {
|
||||||
@@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() {
|
let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。";
|
||||||
"art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。"
|
|
||||||
} else {
|
|
||||||
"任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。"
|
|
||||||
};
|
|
||||||
let verification_requirement = match task.id.as_str() {
|
let verification_requirement = match task.id.as_str() {
|
||||||
"code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
|
"code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
|
||||||
task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
|
task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
|
||||||
@@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
|
|||||||
if task.id == "art-director" {
|
if task.id == "art-director" {
|
||||||
if autonomous_manifest_ready_task_requires_visual_asset(&task.id) {
|
if autonomous_manifest_ready_task_requires_visual_asset(&task.id) {
|
||||||
return format!(
|
return format!(
|
||||||
"{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
|
"{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论。"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return format!(
|
return format!(
|
||||||
|
|||||||
@@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
|
|||||||
agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]);
|
agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]);
|
||||||
let repair_of_delegation_id =
|
let repair_of_delegation_id =
|
||||||
(!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id);
|
(!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id);
|
||||||
let required_visual_artifact = if editor_api_key_is_configured() {
|
let required_visual_artifact: Option<&str> = None;
|
||||||
match target_agent_id.as_str() {
|
|
||||||
"design-foundation" => Some("assets/ui-prototype.png"),
|
|
||||||
"art-asset-plan" => Some("assets/art-spritesheet.png"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if repair_of_delegation_id.is_none()
|
if repair_of_delegation_id.is_none()
|
||||||
&& required_visual_artifact.is_some_and(|required| {
|
&& required_visual_artifact.is_some_and(|required| {
|
||||||
!expected_artifacts
|
!expected_artifacts
|
||||||
|
|||||||
@@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete(
|
|||||||
return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error);
|
return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" {
|
|
||||||
let manifest = match read_existing_manifest_for_project(root) {
|
|
||||||
Ok(manifest) => manifest,
|
|
||||||
Err(error) => {
|
|
||||||
return AgentRuntimeToolObservation {
|
|
||||||
tool: "file.delete".to_string(),
|
|
||||||
status: "blocked".to_string(),
|
|
||||||
summary: "无法确认首版美术素材登记状态,未执行删除".to_string(),
|
|
||||||
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| {
|
|
||||||
asset.local_path == "assets/art-spritesheet.png"
|
|
||||||
&& asset.kind == "art-spritesheet"
|
|
||||||
&& asset.media_type.starts_with("image/")
|
|
||||||
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
|
|
||||||
&& resolve_local_project_path(root, &asset.local_path)
|
|
||||||
.ok()
|
|
||||||
.is_some_and(|path| path.is_file())
|
|
||||||
});
|
|
||||||
if registered_fixed_asset_exists {
|
|
||||||
return AgentRuntimeToolObservation {
|
|
||||||
tool: "file.delete".to_string(),
|
|
||||||
status: "blocked".to_string(),
|
|
||||||
summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(),
|
|
||||||
detail: Some(
|
|
||||||
"path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete")
|
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -540,6 +540,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
|||||||
.or_else(|| input.get("replace_existing"))
|
.or_else(|| input.get("replace_existing"))
|
||||||
.and_then(serde_json::Value::as_bool)
|
.and_then(serde_json::Value::as_bool)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let slice_count = input
|
||||||
|
.get("sliceCount")
|
||||||
|
.or_else(|| input.get("slice_count"))
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.map(|value| value as usize);
|
||||||
let requested_options = PlatformArtAssetGenerationOptions {
|
let requested_options = PlatformArtAssetGenerationOptions {
|
||||||
output_path: (!output_path.trim().is_empty()).then_some(output_path),
|
output_path: (!output_path.trim().is_empty()).then_some(output_path),
|
||||||
aspect_ratio,
|
aspect_ratio,
|
||||||
@@ -547,83 +552,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
|||||||
asset_kind,
|
asset_kind,
|
||||||
asset_label,
|
asset_label,
|
||||||
replace_existing,
|
replace_existing,
|
||||||
|
slice_count,
|
||||||
};
|
};
|
||||||
let canonical_options = match agent_id {
|
let mut options = {
|
||||||
"art-director" => Some(PlatformArtAssetGenerationOptions {
|
|
||||||
output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()),
|
|
||||||
aspect_ratio: "1:1".to_string(),
|
|
||||||
image_size: "1K".to_string(),
|
|
||||||
asset_kind: "icon-spec".to_string(),
|
|
||||||
asset_label: "游戏统一视觉规范图".to_string(),
|
|
||||||
replace_existing: false,
|
|
||||||
}),
|
|
||||||
"design-foundation"
|
|
||||||
if requested_options
|
|
||||||
.output_path
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(design_foundation_ui_page_output_path_is_valid) =>
|
|
||||||
{
|
|
||||||
Some(PlatformArtAssetGenerationOptions {
|
|
||||||
output_path: requested_options.output_path.clone(),
|
|
||||||
aspect_ratio: "16:9".to_string(),
|
|
||||||
image_size: "2K".to_string(),
|
|
||||||
asset_kind: "ui-prototype".to_string(),
|
|
||||||
asset_label: if requested_options.asset_label.trim().is_empty() {
|
|
||||||
"游戏功能页面设计图".to_string()
|
|
||||||
} else {
|
|
||||||
requested_options.asset_label.clone()
|
|
||||||
},
|
|
||||||
replace_existing: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"design-foundation" => Some(PlatformArtAssetGenerationOptions {
|
|
||||||
output_path: Some("assets/ui-prototype.png".to_string()),
|
|
||||||
aspect_ratio: "16:9".to_string(),
|
|
||||||
image_size: "2K".to_string(),
|
|
||||||
asset_kind: "ui-prototype".to_string(),
|
|
||||||
asset_label: "游戏横屏界面原型图".to_string(),
|
|
||||||
replace_existing: false,
|
|
||||||
}),
|
|
||||||
"art-asset-plan" => Some(PlatformArtAssetGenerationOptions {
|
|
||||||
output_path: Some("assets/art-spritesheet.png".to_string()),
|
|
||||||
aspect_ratio: "1:1".to_string(),
|
|
||||||
image_size: "1K".to_string(),
|
|
||||||
asset_kind: "art-spritesheet".to_string(),
|
|
||||||
asset_label: "游戏首版核心美术素材".to_string(),
|
|
||||||
replace_existing: false,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let mut options = if let Some(canonical) = canonical_options {
|
|
||||||
let mismatch = requested_options
|
|
||||||
.output_path
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| Some(value) != canonical.output_path.as_deref())
|
|
||||||
|| (!requested_options.aspect_ratio.is_empty()
|
|
||||||
&& requested_options.aspect_ratio != canonical.aspect_ratio)
|
|
||||||
|| (!requested_options.image_size.is_empty()
|
|
||||||
&& requested_options.image_size != canonical.image_size)
|
|
||||||
|| (!requested_options.asset_kind.is_empty()
|
|
||||||
&& requested_options.asset_kind != canonical.asset_kind)
|
|
||||||
|| (!requested_options.asset_label.is_empty()
|
|
||||||
&& requested_options.asset_label != canonical.asset_label);
|
|
||||||
if mismatch {
|
|
||||||
return AgentRuntimeToolObservation {
|
|
||||||
tool: "canvas.asset_generate".to_string(),
|
|
||||||
status: "failed".to_string(),
|
|
||||||
summary: format!(
|
|
||||||
"图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}",
|
|
||||||
canonical.output_path.as_deref().unwrap_or("null"),
|
|
||||||
canonical.aspect_ratio,
|
|
||||||
canonical.image_size,
|
|
||||||
canonical.asset_kind,
|
|
||||||
canonical.asset_label,
|
|
||||||
),
|
|
||||||
detail: None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
canonical
|
|
||||||
} else {
|
|
||||||
let defaults = PlatformArtAssetGenerationOptions::default();
|
let defaults = PlatformArtAssetGenerationOptions::default();
|
||||||
PlatformArtAssetGenerationOptions {
|
PlatformArtAssetGenerationOptions {
|
||||||
output_path: requested_options.output_path,
|
output_path: requested_options.output_path,
|
||||||
@@ -648,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
|||||||
requested_options.asset_label
|
requested_options.asset_label
|
||||||
},
|
},
|
||||||
replace_existing,
|
replace_existing,
|
||||||
|
slice_count,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
options.replace_existing = replace_existing;
|
options.replace_existing = replace_existing;
|
||||||
|
|||||||
@@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
|||||||
Ok(lines.join("\n"))
|
Ok(lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_agc_skill_resource(resource: &str) -> Result<String, String> {
|
||||||
|
let manifest = validated_skill_pack_manifest()?;
|
||||||
|
let normalized = resource.trim().trim_start_matches('/').replace('\\', "/");
|
||||||
|
let (skill_name, relative) = normalized
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?;
|
||||||
|
let entry = manifest
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == skill_name)
|
||||||
|
.ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?;
|
||||||
|
if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) {
|
||||||
|
return Err("未登记或不安全的 AGC Skill 资源".to_string());
|
||||||
|
}
|
||||||
|
let bundled_path = format!("{skill_name}/{relative}");
|
||||||
|
let bytes =
|
||||||
|
bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?;
|
||||||
|
let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?;
|
||||||
|
String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String, String> {
|
pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String, String> {
|
||||||
let manifest = validated_skill_pack_manifest()?;
|
let manifest = validated_skill_pack_manifest()?;
|
||||||
let skills_root = isolated_os_home.join(".agents").join("skills");
|
let skills_root = isolated_os_home.join(".agents").join("skills");
|
||||||
|
|||||||
@@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
|
|||||||
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
|
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||||
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
|
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
|
||||||
"canvas.asset_generate" => {
|
"canvas.asset_generate" => {
|
||||||
"通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。"
|
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。"
|
||||||
}
|
}
|
||||||
"ui.workflow.run" => {
|
"ui.workflow.run" => {
|
||||||
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
|
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::agent::read_direct_project_chat_history_at;
|
||||||
use crate::ui_editor::resource::font::FontAsset;
|
use crate::ui_editor::resource::font::FontAsset;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
@@ -4752,6 +4753,15 @@ pub(crate) fn read_local_conversation(
|
|||||||
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
|
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn read_direct_project_conversation(
|
||||||
|
project_path: String,
|
||||||
|
) -> Result<LocalConversationResult, String> {
|
||||||
|
let root = Path::new(project_path.trim());
|
||||||
|
enforce_project_permission_policy(root, "conversation.read")?;
|
||||||
|
read_direct_project_chat_history_at(root)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn append_local_conversation_message(
|
pub(crate) fn append_local_conversation_message(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
@@ -4780,6 +4790,20 @@ pub(crate) fn append_local_conversation_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn append_direct_project_conversation_message(
|
||||||
|
project_path: String,
|
||||||
|
message: LocalConversationMessage,
|
||||||
|
message_id: Option<String>,
|
||||||
|
) -> Result<LocalConversationResult, String> {
|
||||||
|
let root = Path::new(project_path.trim());
|
||||||
|
enforce_project_permission_policy(root, "conversation.write")?;
|
||||||
|
let item =
|
||||||
|
direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?;
|
||||||
|
append_direct_project_history_item_at(root, &item)?;
|
||||||
|
read_direct_project_chat_history_at(root)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn build_local_project_index(
|
pub(crate) fn build_local_project_index(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
|
|||||||
@@ -2489,6 +2489,8 @@ fn main() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
start_game_creator_external_mcp,
|
||||||
|
stop_game_creator_external_mcp,
|
||||||
create_automatic_local_game_project,
|
create_automatic_local_game_project,
|
||||||
init_local_game_project,
|
init_local_game_project,
|
||||||
import_local_godot_project,
|
import_local_godot_project,
|
||||||
@@ -2594,7 +2596,9 @@ fn main() {
|
|||||||
set_active_game_creator_agent_session,
|
set_active_game_creator_agent_session,
|
||||||
archive_game_creator_agent_session,
|
archive_game_creator_agent_session,
|
||||||
read_local_conversation,
|
read_local_conversation,
|
||||||
|
read_direct_project_conversation,
|
||||||
append_local_conversation_message,
|
append_local_conversation_message,
|
||||||
|
append_direct_project_conversation_message,
|
||||||
build_local_project_index,
|
build_local_project_index,
|
||||||
create_local_project_checkpoint,
|
create_local_project_checkpoint,
|
||||||
export_local_project_package,
|
export_local_project_package,
|
||||||
|
|||||||
@@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>> {
|
|||||||
PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new()))
|
PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct ProjectAppendLock {
|
pub(crate) struct ProjectAppendLock {
|
||||||
process_lock: Arc<Mutex<()>>,
|
process_lock: Arc<Mutex<()>>,
|
||||||
os_lock_path: PathBuf,
|
os_lock_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct ProjectAppendGuard<'a> {
|
pub(crate) struct ProjectAppendGuard<'a> {
|
||||||
_process_guard: std::sync::MutexGuard<'a, ()>,
|
_process_guard: std::sync::MutexGuard<'a, ()>,
|
||||||
_os_lock: File,
|
_os_lock: File,
|
||||||
}
|
}
|
||||||
@@ -4335,7 +4335,7 @@ impl ProjectAppendLock {
|
|||||||
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
|
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
|
pub(crate) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
|
||||||
let process_guard = self
|
let process_guard = self
|
||||||
.process_lock
|
.process_lock
|
||||||
.lock()
|
.lock()
|
||||||
@@ -4348,7 +4348,7 @@ impl ProjectAppendLock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
|
pub(crate) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
|
||||||
let mut locks = project_append_locks()
|
let mut locks = project_append_locks()
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?;
|
.map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?;
|
||||||
@@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn append_jsonl_line_unlocked(
|
pub(crate) fn append_jsonl_line_unlocked(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
line: &str,
|
line: &str,
|
||||||
error_label: &str,
|
error_label: &str,
|
||||||
|
|||||||
@@ -1165,6 +1165,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
|
|||||||
asset_kind: "ui-prototype".to_string(),
|
asset_kind: "ui-prototype".to_string(),
|
||||||
asset_label: "游戏横屏界面原型图".to_string(),
|
asset_label: "游戏横屏界面原型图".to_string(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -4856,6 +4857,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
|
|||||||
asset_kind: "ui-prototype".to_string(),
|
asset_kind: "ui-prototype".to_string(),
|
||||||
asset_label: "游戏横屏界面原型图".to_string(),
|
asset_label: "游戏横屏界面原型图".to_string(),
|
||||||
replace_existing: false,
|
replace_existing: false,
|
||||||
|
slice_count: None,
|
||||||
};
|
};
|
||||||
let prompt = build_platform_art_asset_prompt(
|
let prompt = build_platform_art_asset_prompt(
|
||||||
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
|
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ type ExecuteChatAgentReplyInput = {
|
|||||||
clientTurnId?: string;
|
clientTurnId?: string;
|
||||||
creationType?: HomeCreationType | null;
|
creationType?: HomeCreationType | null;
|
||||||
attachments?: DirectCodexTurnAttachment[];
|
attachments?: DirectCodexTurnAttachment[];
|
||||||
|
directPolicyChecked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function App({
|
export function App({
|
||||||
@@ -601,11 +602,6 @@ export function App({
|
|||||||
const [directCodexTransientReply, setDirectCodexTransientReply] =
|
const [directCodexTransientReply, setDirectCodexTransientReply] =
|
||||||
useState('');
|
useState('');
|
||||||
const directCodexTransientReplyRef = useRef('');
|
const directCodexTransientReplyRef = useRef('');
|
||||||
const directCodexInterruptedPartialRef = useRef<{
|
|
||||||
projectPath: string;
|
|
||||||
text: string;
|
|
||||||
messageId: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [
|
const [
|
||||||
directCodexTransientReplyUpdatedAt,
|
directCodexTransientReplyUpdatedAt,
|
||||||
setDirectCodexTransientReplyUpdatedAt,
|
setDirectCodexTransientReplyUpdatedAt,
|
||||||
@@ -617,10 +613,6 @@ export function App({
|
|||||||
receivedDirectUpdate: boolean;
|
receivedDirectUpdate: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const lastDirectCodexActivityRef = useRef<string | null>(null);
|
const lastDirectCodexActivityRef = useRef<string | null>(null);
|
||||||
const recoveredDirectCodexTurnClaimsRef = useRef(new Set<string>());
|
|
||||||
const directCodexClaimReleaseOnConversationWriteFailureRef = useRef(
|
|
||||||
new Map<string, string>(),
|
|
||||||
);
|
|
||||||
const directCodexConversationTurnSequenceRef = useRef(0);
|
const directCodexConversationTurnSequenceRef = useRef(0);
|
||||||
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -1801,6 +1793,16 @@ export function App({
|
|||||||
projectConversationWriteConfirmedRef.current = null;
|
projectConversationWriteConfirmedRef.current = null;
|
||||||
projectConversationWriteCancelledRef.current = null;
|
projectConversationWriteCancelledRef.current = null;
|
||||||
}
|
}
|
||||||
|
// DirectProject history is written by Rust from raw app-server items.
|
||||||
|
// The browser only renders that projection and must not append chat rows.
|
||||||
|
if (projectSupervisorOnly && directCodexProductRuntime) {
|
||||||
|
// `messages` is only an optimistic UI projection in Direct mode; it is
|
||||||
|
// intentionally not proof of durability. Rust owns the raw response
|
||||||
|
// history, so this effect must not route these rows through the generic
|
||||||
|
// browser conversation writer.
|
||||||
|
savedConversationCountRef.current = messages.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const start = savedConversationCountRef.current;
|
const start = savedConversationCountRef.current;
|
||||||
const pendingMessages = messages.slice(start);
|
const pendingMessages = messages.slice(start);
|
||||||
if (pendingMessages.length === 0) {
|
if (pendingMessages.length === 0) {
|
||||||
@@ -1871,7 +1873,6 @@ export function App({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
conversationWriteInFlightRef.current = true;
|
conversationWriteInFlightRef.current = true;
|
||||||
let failedDirectTerminalMessageCount: number | null = null;
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let wroteMessage = false;
|
let wroteMessage = false;
|
||||||
for (const [index, message] of pendingMessages.entries()) {
|
for (const [index, message] of pendingMessages.entries()) {
|
||||||
@@ -1886,56 +1887,22 @@ export function App({
|
|||||||
savedConversationCountRef.current = start + index + 1;
|
savedConversationCountRef.current = start + index + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
await invoke<LocalConversationResult>(
|
||||||
await invoke<LocalConversationResult>(
|
'append_local_conversation_message',
|
||||||
'append_local_conversation_message',
|
{
|
||||||
{
|
projectPath: nextProjectPath,
|
||||||
projectPath: nextProjectPath,
|
agentId: null,
|
||||||
|
...(message.messageId ? { messageId: message.messageId } : {}),
|
||||||
|
message: {
|
||||||
|
role: message.role,
|
||||||
|
content: message.text,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
...(message.messageId ? { messageId: message.messageId } : {}),
|
...(typeof message.updatedAt === 'number'
|
||||||
message: {
|
? { updatedAt: message.updatedAt }
|
||||||
role: message.role,
|
: {}),
|
||||||
content: message.text,
|
|
||||||
agentId: null,
|
|
||||||
...(typeof message.updatedAt === 'number'
|
|
||||||
? { updatedAt: message.updatedAt }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
} catch (error) {
|
);
|
||||||
const claimKey = message.messageId
|
|
||||||
? directCodexClaimReleaseOnConversationWriteFailureRef.current.get(
|
|
||||||
message.messageId,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
if (claimKey) {
|
|
||||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.delete(
|
|
||||||
message.messageId!,
|
|
||||||
);
|
|
||||||
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
|
|
||||||
failedDirectTerminalMessageCount = start + index + 1;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (message.messageId) {
|
|
||||||
const claimKey =
|
|
||||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.get(
|
|
||||||
message.messageId,
|
|
||||||
);
|
|
||||||
if (claimKey) {
|
|
||||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.delete(
|
|
||||||
message.messageId,
|
|
||||||
);
|
|
||||||
if (localProjectPathRef.current === nextProjectPath) {
|
|
||||||
// Any history read started before this terminal append may hold
|
|
||||||
// A stale history snapshot may still be missing this terminal
|
|
||||||
// append. Invalidate it before releasing the in-memory claim.
|
|
||||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
|
||||||
}
|
|
||||||
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wroteMessage = true;
|
wroteMessage = true;
|
||||||
savedConversationCountRef.current = start + index + 1;
|
savedConversationCountRef.current = start + index + 1;
|
||||||
}
|
}
|
||||||
@@ -1950,12 +1917,10 @@ export function App({
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
savedConversationCountRef.current = failedDirectTerminalMessageCount
|
savedConversationCountRef.current = Math.min(
|
||||||
? Math.max(
|
savedConversationCountRef.current,
|
||||||
savedConversationCountRef.current,
|
start,
|
||||||
failedDirectTerminalMessageCount,
|
);
|
||||||
)
|
|
||||||
: Math.min(savedConversationCountRef.current, start);
|
|
||||||
setWorkspaceStatus(
|
setWorkspaceStatus(
|
||||||
`项目对话保存失败:${
|
`项目对话保存失败:${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
@@ -1985,6 +1950,7 @@ export function App({
|
|||||||
conversationWriteVersion,
|
conversationWriteVersion,
|
||||||
pendingUiConfirmation,
|
pendingUiConfirmation,
|
||||||
projectSupervisorOnly,
|
projectSupervisorOnly,
|
||||||
|
directCodexProductRuntime,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function appendLocalPermissionLog(
|
function appendLocalPermissionLog(
|
||||||
@@ -2741,11 +2707,12 @@ export function App({
|
|||||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||||
let runtimeError = '';
|
let runtimeError = '';
|
||||||
const projectConversation = await invoke<LocalConversationResult>(
|
const projectConversation = await invoke<LocalConversationResult>(
|
||||||
'read_local_conversation',
|
directCodexProductRuntime
|
||||||
{
|
? 'read_direct_project_conversation'
|
||||||
projectPath: nextProjectPath,
|
: 'read_local_conversation',
|
||||||
agentId: null,
|
directCodexProductRuntime
|
||||||
},
|
? { projectPath: nextProjectPath }
|
||||||
|
: { projectPath: nextProjectPath, agentId: null },
|
||||||
);
|
);
|
||||||
let supervisorConversation: LocalConversationResult | null = null;
|
let supervisorConversation: LocalConversationResult | null = null;
|
||||||
let runtime: AgentRuntimeState | null = null;
|
let runtime: AgentRuntimeState | null = null;
|
||||||
@@ -5450,6 +5417,7 @@ export function App({
|
|||||||
clientTurnId: directConversationTurnId,
|
clientTurnId: directConversationTurnId,
|
||||||
creationType,
|
creationType,
|
||||||
attachments,
|
attachments,
|
||||||
|
directPolicyChecked = false,
|
||||||
}: ExecuteChatAgentReplyInput) {
|
}: ExecuteChatAgentReplyInput) {
|
||||||
// Product default: send the conversation directly to Codex app-server.
|
// Product default: send the conversation directly to Codex app-server.
|
||||||
// The legacy Supervisor/harness path remains below for rollback and tests.
|
// The legacy Supervisor/harness path remains below for rollback and tests.
|
||||||
@@ -5459,6 +5427,44 @@ export function App({
|
|||||||
if (directProjectPath && directInvoke) {
|
if (directProjectPath && directInvoke) {
|
||||||
const clientTurnId =
|
const clientTurnId =
|
||||||
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
||||||
|
if (
|
||||||
|
!directPolicyChecked &&
|
||||||
|
projectConversationWriteConfirmedRef.current !== directProjectPath
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const policyPaused = await queueProjectPolicyConfirmationIfNeeded(
|
||||||
|
directInvoke,
|
||||||
|
'conversation.write',
|
||||||
|
directProjectPath,
|
||||||
|
'写入 DirectProject 对话历史',
|
||||||
|
'DirectProject 对话写入需要确认。',
|
||||||
|
() => {
|
||||||
|
projectConversationWriteConfirmedRef.current =
|
||||||
|
directProjectPath;
|
||||||
|
void executeChatAgentReply({
|
||||||
|
prompt,
|
||||||
|
clientTurnId,
|
||||||
|
creationType,
|
||||||
|
attachments,
|
||||||
|
directPolicyChecked: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (policyPaused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectConversationWriteConfirmedRef.current = directProjectPath;
|
||||||
|
} catch (error) {
|
||||||
|
if (localProjectPathRef.current === directProjectPath) {
|
||||||
|
setProjectSupervisorRuntimeError(
|
||||||
|
`DirectProject 对话权限检查失败:${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const directUserMessageId = directCodexConversationMessageId(
|
const directUserMessageId = directCodexConversationMessageId(
|
||||||
clientTurnId,
|
clientTurnId,
|
||||||
'user',
|
'user',
|
||||||
@@ -5470,20 +5476,45 @@ export function App({
|
|||||||
const appendDirectUserMessageIfMissing = (
|
const appendDirectUserMessageIfMissing = (
|
||||||
current: ChatMessage[],
|
current: ChatMessage[],
|
||||||
): ChatMessage[] => {
|
): ChatMessage[] => {
|
||||||
return current.some(
|
if (
|
||||||
(message) => message.messageId === directUserMessageId,
|
current.some((message) => message.messageId === directUserMessageId)
|
||||||
)
|
) {
|
||||||
? current
|
return current;
|
||||||
: [
|
}
|
||||||
...current,
|
let optimisticIndex = -1;
|
||||||
{
|
for (let index = current.length - 1; index >= 0; index -= 1) {
|
||||||
role: 'user' as const,
|
const message = current[index];
|
||||||
text: prompt,
|
if (
|
||||||
runtimeOwned: true,
|
message?.role === 'user' &&
|
||||||
messageId: directUserMessageId,
|
message.text === prompt &&
|
||||||
updatedAt: Date.now(),
|
!message.messageId
|
||||||
},
|
) {
|
||||||
];
|
optimisticIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (optimisticIndex >= 0) {
|
||||||
|
return current.map((message, index) =>
|
||||||
|
index === optimisticIndex
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
runtimeOwned: true,
|
||||||
|
messageId: directUserMessageId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
role: 'user' as const,
|
||||||
|
text: prompt,
|
||||||
|
runtimeOwned: true,
|
||||||
|
messageId: directUserMessageId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
const appendDirectAssistantMessage = (
|
const appendDirectAssistantMessage = (
|
||||||
current: ChatMessage[],
|
current: ChatMessage[],
|
||||||
@@ -5507,41 +5538,6 @@ export function App({
|
|||||||
index === existingIndex ? nextMessage : message,
|
index === existingIndex ? nextMessage : message,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const persistDirectAssistantMessage = (text: string) =>
|
|
||||||
directInvoke<LocalConversationResult>(
|
|
||||||
'append_local_conversation_message',
|
|
||||||
{
|
|
||||||
projectPath: directProjectPath,
|
|
||||||
agentId: null,
|
|
||||||
messageId: directAssistantMessageId,
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: text,
|
|
||||||
agentId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const persistDirectPartialMessage = (messageId: string, text: string) =>
|
|
||||||
directInvoke<LocalConversationResult>(
|
|
||||||
'append_local_conversation_message',
|
|
||||||
{
|
|
||||||
projectPath: directProjectPath,
|
|
||||||
agentId: null,
|
|
||||||
messageId,
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
// An interrupted partial is intentionally a normal assistant
|
|
||||||
// record so replay sees exactly what Codex emitted before the
|
|
||||||
// disconnect; the marker is product data, not UI metadata.
|
|
||||||
content: `${text.trim()}\nunexpected interrupt happened here`,
|
|
||||||
agentId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`;
|
|
||||||
recoveredDirectCodexTurnClaimsRef.current.add(
|
|
||||||
recoveredDirectCodexTurnClaimKey,
|
|
||||||
);
|
|
||||||
activeDirectCodexTurnRef.current = {
|
activeDirectCodexTurnRef.current = {
|
||||||
projectPath: directProjectPath,
|
projectPath: directProjectPath,
|
||||||
turnId: clientTurnId,
|
turnId: clientTurnId,
|
||||||
@@ -5558,36 +5554,6 @@ export function App({
|
|||||||
setDirectCodexTransientReplyUpdatedAt(null);
|
setDirectCodexTransientReplyUpdatedAt(null);
|
||||||
setProjectSupervisorRuntimeError('');
|
setProjectSupervisorRuntimeError('');
|
||||||
try {
|
try {
|
||||||
const interruptedPartial = directCodexInterruptedPartialRef.current;
|
|
||||||
if (
|
|
||||||
interruptedPartial?.projectPath === directProjectPath &&
|
|
||||||
interruptedPartial.text.trim()
|
|
||||||
) {
|
|
||||||
await persistDirectPartialMessage(
|
|
||||||
interruptedPartial.messageId,
|
|
||||||
interruptedPartial.text,
|
|
||||||
);
|
|
||||||
directCodexInterruptedPartialRef.current = null;
|
|
||||||
}
|
|
||||||
// Rust owns the normalized user record for attachment turns so the
|
|
||||||
// durable message includes the same bounded project mapping that is
|
|
||||||
// sent to Codex. Plain turns keep the optimistic browser write; the
|
|
||||||
// Rust writer then converges on it through messageId idempotency.
|
|
||||||
if (!attachments?.length) {
|
|
||||||
await directInvoke<LocalConversationResult>(
|
|
||||||
'append_local_conversation_message',
|
|
||||||
{
|
|
||||||
projectPath: directProjectPath,
|
|
||||||
agentId: null,
|
|
||||||
messageId: directUserMessageId,
|
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content: prompt,
|
|
||||||
agentId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const directTurnInput: {
|
const directTurnInput: {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -5609,28 +5575,11 @@ export function App({
|
|||||||
'chat_with_game_creator_direct_codex',
|
'chat_with_game_creator_direct_codex',
|
||||||
directTurnInput,
|
directTurnInput,
|
||||||
);
|
);
|
||||||
try {
|
// Rust already persisted the complete raw response items. Invalidate
|
||||||
await persistDirectAssistantMessage(reply);
|
// any history snapshot captured before the turn completed.
|
||||||
} catch (error) {
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
|
||||||
setProjectSupervisorRuntimeError(
|
|
||||||
`陶泥儿回复保存失败:${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Rust persists a successful Direct reply before returning Ok. The
|
|
||||||
// browser append is redundant, so the in-memory turn claim can be
|
|
||||||
// released without reopening the Provider side effect. Invalidate
|
|
||||||
// any history snapshot captured before Rust committed the terminal
|
|
||||||
// reply first.
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
if (localProjectPathRef.current === directProjectPath) {
|
||||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||||
}
|
}
|
||||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
|
||||||
recoveredDirectCodexTurnClaimKey,
|
|
||||||
);
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
if (localProjectPathRef.current === directProjectPath) {
|
||||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
@@ -5643,9 +5592,6 @@ export function App({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDirectCodexTurnAlreadyRunningError(error)) {
|
if (isDirectCodexTurnAlreadyRunningError(error)) {
|
||||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
|
||||||
recoveredDirectCodexTurnClaimKey,
|
|
||||||
);
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
if (localProjectPathRef.current === directProjectPath) {
|
||||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||||
setProjectSupervisorRuntimeError(
|
setProjectSupervisorRuntimeError(
|
||||||
@@ -5665,46 +5611,7 @@ export function App({
|
|||||||
'陶泥儿智能创作',
|
'陶泥儿智能创作',
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const partial = directCodexTransientReplyRef.current.trim();
|
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||||
if (partial) {
|
|
||||||
const partialMessageId =
|
|
||||||
globalThis.crypto?.randomUUID?.() ||
|
|
||||||
`direct-partial-${Date.now().toString(36)}`;
|
|
||||||
directCodexInterruptedPartialRef.current = {
|
|
||||||
projectPath: directProjectPath,
|
|
||||||
text: partial,
|
|
||||||
messageId: partialMessageId,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await persistDirectPartialMessage(partialMessageId, partial);
|
|
||||||
} catch {
|
|
||||||
// The next user send retries this idempotent append before
|
|
||||||
// constructing the replay prompt.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await persistDirectAssistantMessage(visibleMessage);
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
|
||||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
|
||||||
}
|
|
||||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
|
||||||
recoveredDirectCodexTurnClaimKey,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
|
||||||
// Do not release the claim while the React conversation writer
|
|
||||||
// can still persist this terminal record. That writer releases
|
|
||||||
// the claim only after its exact append resolves or rejects.
|
|
||||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.set(
|
|
||||||
directAssistantMessageId,
|
|
||||||
recoveredDirectCodexTurnClaimKey,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
|
||||||
recoveredDirectCodexTurnClaimKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
if (localProjectPathRef.current === directProjectPath) {
|
||||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||||
setDirectCodexStatus('failed');
|
setDirectCodexStatus('failed');
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
Component,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useContext,
|
||||||
|
} from 'react';
|
||||||
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
export type ChatMarkdownMessageProps = {
|
||||||
|
text: string;
|
||||||
|
role: 'assistant' | 'user';
|
||||||
|
streaming?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkdownErrorBoundaryProps = {
|
||||||
|
fallbackText: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkdownErrorBoundaryState = {
|
||||||
|
hasError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MarkdownErrorBoundary extends Component<
|
||||||
|
MarkdownErrorBoundaryProps,
|
||||||
|
MarkdownErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: MarkdownErrorBoundaryState = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): MarkdownErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
|
||||||
|
// Keep the original message visible without logging its potentially sensitive content.
|
||||||
|
const errorName =
|
||||||
|
error instanceof Error && error.name ? error.name : 'UnknownError';
|
||||||
|
console.error('[chat-markdown] render failed', {
|
||||||
|
errorName,
|
||||||
|
hasComponentStack: Boolean(errorInfo.componentStack?.trim()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) {
|
||||||
|
if (
|
||||||
|
this.state.hasError &&
|
||||||
|
prevProps.fallbackText !== this.props.fallbackText
|
||||||
|
) {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<span className="whitespace-pre-wrap break-words">
|
||||||
|
{this.props.fallbackText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListDepthContext = createContext(0);
|
||||||
|
const ListKindContext = createContext<'unordered' | 'ordered' | null>(null);
|
||||||
|
type ListItemParagraphPosition = 'first' | 'continuation';
|
||||||
|
|
||||||
|
const ListItemContext = createContext<ListItemParagraphPosition | null>(null);
|
||||||
|
|
||||||
|
function MarkdownUnorderedList({ children }: { children?: ReactNode }) {
|
||||||
|
const depth = useContext(ListDepthContext);
|
||||||
|
return (
|
||||||
|
<ListDepthContext.Provider value={depth + 1}>
|
||||||
|
<ListKindContext.Provider value="unordered">
|
||||||
|
<ul
|
||||||
|
className={`m-0 mt-2 list-none space-y-1 first:mt-0 ${
|
||||||
|
depth > 0 ? 'pl-4' : 'pl-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
</ListKindContext.Provider>
|
||||||
|
</ListDepthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownOrderedList({
|
||||||
|
children,
|
||||||
|
start,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
start?: number;
|
||||||
|
}) {
|
||||||
|
const depth = useContext(ListDepthContext);
|
||||||
|
return (
|
||||||
|
<ListDepthContext.Provider value={depth + 1}>
|
||||||
|
<ListKindContext.Provider value="ordered">
|
||||||
|
<ol
|
||||||
|
start={start}
|
||||||
|
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
</ListKindContext.Provider>
|
||||||
|
</ListDepthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownParagraph({ children }: { children?: ReactNode }) {
|
||||||
|
const paragraphPosition = useContext(ListItemContext);
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={`m-0 break-words ${
|
||||||
|
paragraphPosition === 'first'
|
||||||
|
? 'inline'
|
||||||
|
: paragraphPosition === 'continuation'
|
||||||
|
? 'mt-2'
|
||||||
|
: 'mt-2 first:mt-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) {
|
||||||
|
const paragraphPosition = useContext(ListItemContext);
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={`m-0 break-words opacity-95 ${
|
||||||
|
paragraphPosition === 'first'
|
||||||
|
? 'inline'
|
||||||
|
: paragraphPosition === 'continuation'
|
||||||
|
? 'mt-2'
|
||||||
|
: 'mt-2 first:mt-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownListItem({ children }: { children?: ReactNode }) {
|
||||||
|
const listKind = useContext(ListKindContext);
|
||||||
|
let paragraphIndex = 0;
|
||||||
|
const childrenWithParagraphContext = Children.map(
|
||||||
|
children,
|
||||||
|
(child, index) => {
|
||||||
|
if (
|
||||||
|
isValidElement(child) &&
|
||||||
|
(child.type === MarkdownParagraph ||
|
||||||
|
child.type === StreamingMarkdownParagraph)
|
||||||
|
) {
|
||||||
|
const position: ListItemParagraphPosition =
|
||||||
|
paragraphIndex++ === 0 ? 'first' : 'continuation';
|
||||||
|
return (
|
||||||
|
<ListItemContext.Provider key={child.key ?? index} value={position}>
|
||||||
|
{child}
|
||||||
|
</ListItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li className="break-words whitespace-normal">
|
||||||
|
{listKind === 'unordered' ? '- ' : null}
|
||||||
|
{childrenWithParagraphContext}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownComponents: Components = {
|
||||||
|
// TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。
|
||||||
|
a: ({ children }) => children,
|
||||||
|
img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'),
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="m-0 mt-4 !text-xl font-bold first:mt-0">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="m-0 mt-4 !text-lg font-bold first:mt-0">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="m-0 mt-3 !text-base font-semibold first:mt-0">{children}</h3>
|
||||||
|
),
|
||||||
|
h4: ({ children }) => (
|
||||||
|
<h4 className="m-0 mt-3 !text-sm font-semibold first:mt-0">{children}</h4>
|
||||||
|
),
|
||||||
|
h5: ({ children }) => (
|
||||||
|
<h5 className="m-0 mt-2 !text-sm font-medium first:mt-0">{children}</h5>
|
||||||
|
),
|
||||||
|
h6: ({ children }) => (
|
||||||
|
<h6 className="m-0 mt-2 !text-xs font-medium uppercase tracking-wide first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
),
|
||||||
|
p: MarkdownParagraph,
|
||||||
|
ul: MarkdownUnorderedList,
|
||||||
|
ol: MarkdownOrderedList,
|
||||||
|
li: MarkdownListItem,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="m-0 mt-2 border-l-2 border-(--platform-surface-border) pl-3 text-(--platform-text-soft) first:mt-0">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
code: ({ className, children, node: _node, ...props }) => {
|
||||||
|
const isBlock =
|
||||||
|
Boolean(className?.includes('language-')) ||
|
||||||
|
String(children).includes('\n');
|
||||||
|
return isBlock ? (
|
||||||
|
<code {...props} className="font-mono whitespace-pre">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code
|
||||||
|
{...props}
|
||||||
|
className="rounded bg-black/6 px-1 py-0.5 font-mono text-[0.9em]"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="mt-2 max-w-full overflow-x-auto first:mt-0">
|
||||||
|
<table className="min-w-full border-collapse text-left text-sm">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-(--platform-surface-border) px-2 py-1 font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-(--platform-surface-border) px-2 py-1 align-top">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
hr: () => (
|
||||||
|
<hr className="mt-2 border-0 border-t border-(--platform-surface-border)" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamingMarkdownComponents: Components = {
|
||||||
|
...markdownComponents,
|
||||||
|
p: StreamingMarkdownParagraph,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatMarkdownMessage({
|
||||||
|
text,
|
||||||
|
role,
|
||||||
|
streaming = false,
|
||||||
|
}: ChatMarkdownMessageProps) {
|
||||||
|
if (role === 'user') {
|
||||||
|
return <span className="whitespace-pre-wrap break-words">{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownErrorBoundary fallbackText={text}>
|
||||||
|
<ReactMarkdown
|
||||||
|
skipHtml
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={
|
||||||
|
streaming ? streamingMarkdownComponents : markdownComponents
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</MarkdownErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user