Merge remote-tracking branch 'origin/master' into feat/adapt-taonier-skill
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 14:46:36 +08:00
13 changed files with 79 additions and 38 deletions
@@ -4487,21 +4487,12 @@ pub(crate) async fn fetch_custom_llm_models(
.await
.map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
body.trim(),
600,
);
return Err(if detail.is_empty() {
format!("模型列表读取失败(HTTP {})", status.as_u16())
} else {
format!("模型列表读取失败(HTTP {}):{detail}", status.as_u16())
});
// 自定义模型端点由用户提供、响应不可信:只回报状态码,绝不回显上游正文,
// 否则会把密钥或私有数据带进错误文案。
return Err(format!(
"模型列表读取失败(HTTP {})",
response.status().as_u16()
));
}
let mut body = Vec::new();
while let Some(chunk) = response
@@ -53,9 +53,17 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result<LlmModelsResponse, String> {
.unwrap_or("模型列表读取失败");
return Err(message.to_string());
}
return envelope
.data
.ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string());
return envelope.data.ok_or_else(|| {
// `ok=true` 但 `data` 为空时,把服务端协议里的 error 明细带出来,
// 否则排障只能看到笼统的「缺少 data」。
let error_detail = envelope
.error
.as_ref()
.and_then(|error| error.message.as_deref().or(error.code.as_deref()))
.filter(|detail| !detail.trim().is_empty())
.unwrap_or("error 字段为空");
format!("模型列表响应缺少 data:ok=true,error={error_detail}")
});
}
serde_json::from_value::<LlmModelsResponse>(value)
@@ -11,6 +11,8 @@
* 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。
*/
import { visibleClientErrorMessage } from '../../services/errorReporting';
/** 未分类失败的专用兜底文案。 */
export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试';
@@ -64,6 +66,19 @@ function isHomeCreationTransportText(text: string): boolean {
);
}
function safeHomeCreationDetail(error: unknown, code?: string) {
const detail = visibleClientErrorMessage(error, '').trim();
if (!detail) return '';
if (!code) return detail;
const withoutCode = detail
.replace(code, '')
.replace(/^\s*[::;;,,-]\s*/u, '')
.trim();
return withoutCode === code || /[::;;,,-]\s*$/u.test(withoutCode)
? ''
: withoutCode;
}
/**
* 由宿主错误算出用户可见文案。
*
@@ -76,20 +91,25 @@ export function homeCreationFailureMessage(
): string {
const projectCreated = options.projectCreated === true;
const text = homeCreationDiagnosticText(error);
const safeDetail = safeHomeCreationDetail(error);
if (isHomeCreationTransportText(text)) {
return projectCreated
const base = projectCreated
? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE
: HOME_CREATION_IPC_FAILURE;
return safeDetail ? `${base}(${safeDetail})` : base;
}
const code = HOME_CREATION_HOST_CODE.exec(text)?.[0];
if (code) {
return projectCreated
const base = projectCreated
? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。`
: `创建未完成(${code});请重试。`;
const detail = safeHomeCreationDetail(error, code);
return detail ? `${base}(${detail})` : base;
}
return projectCreated
const base = projectCreated
? HOME_CREATION_AFTER_PROJECT_FAILURE
: HOME_CREATION_UNCLASSIFIED_FAILURE;
return safeDetail ? `${base}(${safeDetail})` : base;
}
/** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */
@@ -9,7 +9,10 @@ import {
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
import { visibleClientErrorMessage } from '../../services/errorReporting';
import {
clientErrorRawText,
visibleClientErrorMessage,
} from '../../services/errorReporting';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
@@ -78,11 +81,12 @@ async function inspectRecentWorkspace(
]);
return { path: workspace, status, retryable: true };
} catch (error) {
const message = visibleClientErrorMessage(error);
// 分类判据看**原始**正文:脱敏会把 `C:\a\b(DACL …)` 这类无空格路径连同后面的 DACL
// 一起吞成 `<path>`,标记消失后提权失败会被误判成可重试,等于在用户点「否」后再弹一次 UAC。
return {
path: workspace,
status: null,
retryable: recentWorkspaceFailureIsRetryable(message),
retryable: recentWorkspaceFailureIsRetryable(clientErrorRawText(error)),
};
} finally {
if (timeoutHandle !== undefined) {
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import type { AgcPluginPanel } from '../../app/types';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { visibleClientErrorMessage } from '../../services/errorReporting';
import { captureClientError } from '../../services/errorReporting';
import { readAgcPluginPanel } from '../../services/pluginHost';
export type PluginPanelHostProps = {
@@ -32,8 +32,12 @@ export function PluginPanelHost({
if (active) setHtml(result.html);
},
(error) => {
if (active)
setError(visibleClientErrorMessage(error, '插件面板加载失败'));
// 插件面板是不可信内容:原始正文(可能含私有路径)只进错误池,用户可见文案保持通用。
void captureClientError(error, {
source: 'plugin-panel',
action: 'read_agc_plugin_panel',
}).catch(() => undefined);
if (active) setError('插件面板加载失败');
},
);
return () => {
@@ -96,6 +96,11 @@ function clientErrorText(error: unknown): string {
return String(error ?? '');
}
/** 未脱敏的原始错误正文:只给分类/判据用,禁止直接展示给用户。 */
export function clientErrorRawText(error: unknown) {
return clientErrorText(error);
}
/** 用户可见的普通客户端错误正文:保留状态码/原因,替换凭据、URL、路径和长标识。 */
export function visibleClientErrorMessage(
error: unknown,
@@ -106,7 +111,9 @@ export function visibleClientErrorMessage(
0,
400,
);
return safe || fallback;
// `ClientAuthErrorWrapper` 把映射外的 Tauri 级失败序列化成 `{}`:这不是可展示的正文,落回兜底,
// 否则登录页会停在无解释的 `{}` 上(真 Error 的 message 由上报链路单独取用)。
return safe && safe !== '{}' ? safe : fallback;
}
export async function captureClientError(
@@ -2553,10 +2553,12 @@ export function registerHomeProjectCreationTests() {
});
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
// 工作区已经建好,就必须如实说「已创建」,不能覆盖成「创建未完成」;宿主原始文本
// (这里是夹具里的英文原因)不上屏,只作为证据写进 application.log。
// 工作区已经建好,就必须如实说「已创建」,不能覆盖成「创建未完成」;宿主原因保留脱敏后的
// 正文(URL / 路径已替换),完整原文仍只作为证据写进 application.log。
expect(
await screen.findByText(HOME_CREATION_AFTER_PROJECT_FAILURE),
await screen.findByText((content) =>
content.startsWith(HOME_CREATION_AFTER_PROJECT_FAILURE),
),
).not.toBeNull();
expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull();
await waitFor(() => {
@@ -75,6 +75,7 @@ function renderWorkbench() {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'get_local_game_preview_status') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath: PROJECT_PATH,
@@ -14,7 +14,7 @@ const refreshClientAuthSession = vi.hoisted(() => vi.fn());
vi.mock('../src/services/clientAuth', () => ({
refreshClientAuthSession,
subscribeClientAuthState: () => () => {},
subscribeClientAuthState: async () => () => {},
}));
import {
@@ -12,7 +12,7 @@ const refreshClientAuthSession = vi.hoisted(() => vi.fn());
vi.mock('../src/services/clientAuth', () => ({
refreshClientAuthSession,
subscribeClientAuthState: () => () => {},
subscribeClientAuthState: async () => () => {},
}));
import {
@@ -1103,7 +1103,9 @@ describe('project resource live canvas integration', () => {
within(retryPanel).getByRole('button', { name: 'AI 润色' }),
);
expect(
await within(retryPanel).findByText('AI 润色失败,可重试'),
await within(retryPanel).findByText(
'AI 润色失败,可重试:polish unavailable in this fixture',
),
).not.toBeNull();
expect(
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
@@ -297,7 +297,9 @@ describe('生成素材弹窗的提示词润色', () => {
await user.type(prompt, '一段片头动画,镜头缓慢推进');
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull();
expect(
await screen.findByText('AI 润色失败,可重试:platform llm timeout'),
).not.toBeNull();
expect(prompt.value).toBe('一段片头动画,镜头缓慢推进');
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
});
@@ -790,7 +790,7 @@ describe('版本级资源替换', () => {
await waitFor(() =>
expect(
screen.getByText('当前版本已不存在,请刷新项目后重试'),
screen.getByText(/当前版本已不存在,请刷新项目后重试/u),
).not.toBeNull(),
);
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
@@ -1359,7 +1359,7 @@ describe('版本级资源替换', () => {
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
await pickResourceCard('late.png');
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
await waitFor(() => expect(reason()).toMatch(/^替换素材未登记或已被删除/u));
// ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。
await openResourceBookCategory('场景与环境');
@@ -1379,7 +1379,7 @@ describe('版本级资源替换', () => {
observer?.triggerVisible();
});
await pickResourceCard('草稿.png');
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
await waitFor(() => expect(reason()).toMatch(/^替换素材未登记或已被删除/u));
expect(replacementWrites(invoke)).toHaveLength(0);
});