Compare commits

..

1 Commits

Author SHA1 Message Date
kdletters dc8e03988f 修复AGC模型列表刷新无反馈与响应体无限等待
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m20s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m8s
Project CI / AI game creator shell Rust crates (push) Successful in 3m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m4s
Project CI / Repository checks (push) Successful in 3m27s
Project CI / Frontend tests (push) Successful in 4m39s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 8m47s
ConversationModelSelect:手动刷新立即显示「刷新中…」,真实刷新成功后显示「模型列表已刷新」,revision 未变化也有反馈
ConversationModelSelect:刷新失败按 HTTP 状态与超时给出可辨认提示,沿用有效缓存时不报告刷新成功,已选模型与挂载/聚焦语义不变
clientApi:成功与错误响应体统一走 readClientHttpResponseText 的 15 秒上限,响应体卡住时结束等待并释放模型目录在途请求
测试:补齐模型目录响应体超时后保留缓存并可重试、迟到响应不覆盖新目录、手动刷新进行中/同版本成功/失败反馈用例
文档:同步 AGC 模型目录刷新反馈与响应体超时合同,并在共享记忆记录该排障口径
2026-09-15 14:41:57 +08:00
11 changed files with 255 additions and 168 deletions
@@ -811,41 +811,6 @@ pub(crate) async fn pick_local_project_directory(
Ok(Some(path.to_string_lossy().into_owned())) Ok(Some(path.to_string_lossy().into_owned()))
} }
/// Create a user-requested child directory using the current user's normal
/// filesystem rights. This deliberately does not inspect or rewrite ACLs and
/// never attempts elevation.
#[tauri::command]
pub(crate) fn create_local_project_directory(
parent_path: String,
directory_name: String,
) -> Result<String, String> {
let parent = Path::new(parent_path.trim());
if parent.as_os_str().is_empty() || !parent.is_absolute() {
return Err("父目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(parent) {
return Err("父目录不能包含控制字符".to_string());
}
if !parent.is_dir() {
return Err("父目录不存在或不是文件夹".to_string());
}
let name = directory_name.trim();
if name.is_empty() || name == "." || name == ".." || name.chars().any(|c| c == '/' || c == '\\')
{
return Err("目录名称无效".to_string());
}
if name.chars().any(|c| c.is_control()) {
return Err("目录名称不能包含控制字符".to_string());
}
let target = parent.join(name);
fs::create_dir(&target).map_err(|error| match error.kind() {
std::io::ErrorKind::AlreadyExists => "目录已存在".to_string(),
std::io::ErrorKind::PermissionDenied => "没有权限在此位置创建目录".to_string(),
_ => format!("创建目录失败:{error}"),
})?;
Ok(target.to_string_lossy().into_owned())
}
#[tauri::command] #[tauri::command]
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> { pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel(); let (sender, receiver) = tokio::sync::oneshot::channel();
@@ -5399,47 +5364,3 @@ pub(crate) fn write_project_permission_policy(
let _lock = acquire_project_write_lock(root, "project.policy_write")?; let _lock = acquire_project_write_lock(root, "project.policy_write")?;
write_project_permission_policy_at(root, policy) write_project_permission_policy_at(root, policy)
} }
#[cfg(test)]
mod custom_directory_tests {
use super::create_local_project_directory;
use std::fs;
#[test]
fn creates_child_directory_without_touching_parent_contents() {
let root = tempfile::tempdir().expect("temp parent");
let created = create_local_project_directory(
root.path().to_string_lossy().into_owned(),
"new-game".to_string(),
)
.expect("create directory");
let path = std::path::PathBuf::from(created);
assert!(path.is_dir());
assert!(fs::read_dir(root.path())
.expect("read parent")
.next()
.is_some());
}
#[test]
fn rejects_existing_child_and_path_separator() {
let root = tempfile::tempdir().expect("temp parent");
fs::create_dir(root.path().join("existing")).expect("existing");
assert_eq!(
create_local_project_directory(
root.path().to_string_lossy().into_owned(),
"existing".to_string(),
)
.expect_err("duplicate must fail"),
"目录已存在"
);
assert_eq!(
create_local_project_directory(
root.path().to_string_lossy().into_owned(),
"nested/name".to_string(),
)
.expect_err("separator must fail"),
"目录名称无效"
);
}
}
@@ -2636,7 +2636,6 @@ fn main() {
is_local_project_directory_non_empty, is_local_project_directory_non_empty,
inspect_local_project_directory, inspect_local_project_directory,
pick_local_project_directory, pick_local_project_directory,
create_local_project_directory,
rename_local_game_project, rename_local_game_project,
suggest_automatic_project_name, suggest_automatic_project_name,
polish_local_project_prompt, polish_local_project_prompt,
@@ -895,26 +895,14 @@ export function useHomeProjectCreation({
setProjectAction('creating'); setProjectAction('creating');
setStatus('正在选择新项目文件夹'); setStatus('正在选择新项目文件夹');
try { try {
const parentPath = await invoke<string | null>( const selectedPath = await invoke<string | null>(
'pick_local_project_directory', 'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
); );
if (!parentPath) { if (!selectedPath) {
setStatus('已取消'); setStatus('已取消');
return; return;
} }
const directoryName = window.prompt('请输入新目录名称');
if (!directoryName?.trim()) {
setStatus('已取消');
return;
}
const selectedPath = await invoke<string>(
'create_local_project_directory',
{
parentPath,
directoryName: directoryName.trim(),
},
);
setProjectPath(selectedPath); setProjectPath(selectedPath);
projectActionRef.current = null; projectActionRef.current = null;
setProjectAction(null); setProjectAction(null);
@@ -14,6 +14,8 @@ import type {
ClientLlmModel, ClientLlmModel,
ClientLlmModelCatalog, ClientLlmModelCatalog,
} from '../../services/clientApi'; } from '../../services/clientApi';
import { ClientAuthRequestError } from '../../services/clientApi';
import { ClientHttpTimeoutError } from '../../services/clientHttp';
import { import {
cachedLlmModelCatalog, cachedLlmModelCatalog,
refreshLlmModelCatalog, refreshLlmModelCatalog,
@@ -27,6 +29,14 @@ export type ConversationModelSelectHandle = {
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */ /** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
class ModelSelectionConfigError extends Error {} class ModelSelectionConfigError extends Error {}
function modelCatalogErrorMessage(error: unknown) {
if (error instanceof ClientHttpTimeoutError)
return '模型列表请求超时,请重试';
if (error instanceof ClientAuthRequestError && error.status)
return `模型列表加载失败(HTTP ${error.status}`;
return '模型列表加载失败';
}
export function ConversationModelSelect({ export function ConversationModelSelect({
className, className,
disabled, disabled,
@@ -51,6 +61,7 @@ export function ConversationModelSelect({
const [busy, setBusy] = useState(!initialCatalog); const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [manualRefreshBusy, setManualRefreshBusy] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const appliedRevisionRef = useRef<number | null>( const appliedRevisionRef = useRef<number | null>(
@@ -180,7 +191,11 @@ export function ConversationModelSelect({
); );
const syncCatalog = useCallback( const syncCatalog = useCallback(
async (showBusy: boolean) => { async (showBusy: boolean, manualRefresh = false) => {
if (manualRefresh && mountedRef.current) {
setManualRefreshBusy(true);
setNotice('正在刷新模型列表');
}
const busyToken = showBusy const busyToken = showBusy
? ++busyTokenRef.current ? ++busyTokenRef.current
: busyTokenRef.current; : busyTokenRef.current;
@@ -195,12 +210,17 @@ export function ConversationModelSelect({
try { try {
let catalog: ClientLlmModelCatalog; let catalog: ClientLlmModelCatalog;
let usingCachedCatalog = false; let usingCachedCatalog = false;
let catalogError: unknown = null;
try { try {
catalog = await refreshLlmModelCatalog(); catalog = await refreshLlmModelCatalog();
} catch { } catch (error) {
catalogError = error;
const cached = cachedLlmModelCatalog(); const cached = cachedLlmModelCatalog();
if (!cached) { if (!cached) {
if (mountedRef.current) setError('模型列表加载失败'); if (mountedRef.current) {
setError(modelCatalogErrorMessage(error));
setNotice('');
}
markReady(false); markReady(false);
return false; return false;
} }
@@ -209,10 +229,14 @@ export function ConversationModelSelect({
} }
const ready = await applyCatalog(catalog, showBusy, epochAtRequest); const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
if (usingCachedCatalog && mountedRef.current) if (usingCachedCatalog && mountedRef.current)
setError('模型列表加载失败'); setError(modelCatalogErrorMessage(catalogError));
if (manualRefresh && mountedRef.current) {
setNotice(usingCachedCatalog ? '' : '模型列表已刷新');
}
return ready; return ready;
} catch (error) { } catch (error) {
if (mountedRef.current) { if (mountedRef.current) {
setNotice('');
setError( setError(
error instanceof ModelSelectionConfigError error instanceof ModelSelectionConfigError
? error.message ? error.message
@@ -230,6 +254,7 @@ export function ConversationModelSelect({
) { ) {
setBusy(false); setBusy(false);
} }
if (manualRefresh && mountedRef.current) setManualRefreshBusy(false);
} }
}, },
[applyCatalog, markReady], [applyCatalog, markReady],
@@ -375,11 +400,12 @@ export function ConversationModelSelect({
type="button" type="button"
className="conversation-model-menu-refresh" className="conversation-model-menu-refresh"
aria-label="刷新模型列表" aria-label="刷新模型列表"
disabled={disabled || busy} disabled={disabled || busy || manualRefreshBusy}
onClick={() => void syncCatalog(true)} aria-busy={manualRefreshBusy}
onClick={() => void syncCatalog(true, true)}
> >
<RefreshCcw size={13} aria-hidden="true" /> <RefreshCcw size={13} aria-hidden="true" />
<span></span> <span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
</button> </button>
</div> </div>
) : null} ) : null}
@@ -8,7 +8,7 @@ import {
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
unwrapApiResponse, unwrapApiResponse,
} from '../../../../packages/shared/src'; } from '../../../../packages/shared/src';
import { fetchClientHttp } from './clientHttp'; import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
import { captureClientError } from './errorReporting'; import { captureClientError } from './errorReporting';
import { import {
currentPlatformSessionGeneration, currentPlatformSessionGeneration,
@@ -48,8 +48,12 @@ export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
} }
async function readApiErrorMessage(response: Response, fallback: string) { async function readApiErrorMessage(
const text = await response.text(); response: Response,
fallback: string,
url: string,
) {
const text = await readClientHttpResponseText(response, { url });
if (!text.trim()) { if (!text.trim()) {
return fallback; return fallback;
} }
@@ -126,11 +130,11 @@ export async function requestClientApi<T>(
if (!response.ok) { if (!response.ok) {
captureApiErrorStatus(url, response); captureApiErrorStatus(url, response);
throw new ClientAuthRequestError( throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage), await readApiErrorMessage(response, fallbackMessage, url),
{ status: response.status }, { status: response.status },
); );
} }
const text = await response.text(); const text = await readClientHttpResponseText(response, { url });
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T); return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
} }
@@ -163,7 +167,7 @@ export async function requestClientApiBytes(
if (!response.ok) { if (!response.ok) {
captureApiErrorStatus(url, response); captureApiErrorStatus(url, response);
throw new ClientAuthRequestError( throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage), await readApiErrorMessage(response, fallbackMessage, url),
{ status: response.status }, { status: response.status },
); );
} }
@@ -10,6 +10,12 @@ import {
getClientAuthRefreshOperation, getClientAuthRefreshOperation,
refreshClientAuthAccessToken, refreshClientAuthAccessToken,
} from '../src/services/clientAuth'; } from '../src/services/clientAuth';
import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
import {
cachedLlmModelCatalog,
refreshLlmModelCatalog,
resetLlmModelCatalogCacheForTest,
} from '../src/services/llmModelCatalog';
import { import {
beginPlatformSessionTransition, beginPlatformSessionTransition,
commitAuthenticatedPlatformSession, commitAuthenticatedPlatformSession,
@@ -18,6 +24,10 @@ import {
} from '../src/services/platformSession'; } from '../src/services/platformSession';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock(
'../../../packages/shared/src',
() => import('../../../packages/shared/src/http'),
);
vi.mock('../src/services/errorReporting', () => ({ vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(), captureClientError: vi.fn(),
})); }));
@@ -29,6 +39,7 @@ const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status }); new Response(JSON.stringify(value), { status });
beforeEach(async () => { beforeEach(async () => {
resetLlmModelCatalogCacheForTest();
resetPlatformSessionStateForTests(); resetPlatformSessionStateForTests();
window.localStorage.clear(); window.localStorage.clear();
nativeInvoke.mockClear(); nativeInvoke.mockClear();
@@ -42,12 +53,60 @@ beforeEach(async () => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
resetLlmModelCatalogCacheForTest();
resetPlatformSessionStateForTests(); resetPlatformSessionStateForTests();
window.localStorage.clear(); window.localStorage.clear();
delete window.__TAURI__; delete window.__TAURI__;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it.each([200, 503])(
'模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新',
async (status) => {
vi.useFakeTimers();
const previous = { ...catalog, defaultModelId: 'quality', revision: 1 };
const updated = {
defaultModelId: 'fast',
models: [{ id: 'fast', displayName: '快速' }],
revision: 2,
};
let body!: ReadableStreamDefaultController<Uint8Array>;
const stalledResponse = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
body = controller;
},
}),
{ status },
);
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json(previous))
.mockResolvedValueOnce(stalledResponse)
.mockResolvedValueOnce(json(updated));
await expect(refreshLlmModelCatalog()).resolves.toEqual(previous);
let failure: unknown;
const pending = refreshLlmModelCatalog().catch((error: unknown) => {
failure = error;
});
try {
await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS);
expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' });
await pending;
expect(cachedLlmModelCatalog()).toEqual(previous);
await expect(refreshLlmModelCatalog()).resolves.toEqual(updated);
expect(fetch).toHaveBeenCalledTimes(3);
} finally {
// 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。
body.enqueue(new TextEncoder().encode(JSON.stringify(previous)));
body.close();
await pending;
}
expect(cachedLlmModelCatalog()).toEqual(updated);
},
);
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => { it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
let refreshCalls = 0; let refreshCalls = 0;
let modelCalls = 0; let modelCalls = 0;
@@ -16,11 +16,36 @@ import {
ConversationModelSelect, ConversationModelSelect,
type ConversationModelSelectHandle, type ConversationModelSelectHandle,
} from '../src/features/project-workspace/ConversationModelSelect'; } from '../src/features/project-workspace/ConversationModelSelect';
import { loadClientLlmModels } from '../src/services/clientApi'; import {
ClientAuthRequestError,
type ClientLlmModelCatalog,
loadClientLlmModels,
} from '../src/services/clientApi';
import { ClientHttpTimeoutError } from '../src/services/clientHttp';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog'; import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() })); const MockClientAuthRequestError = vi.hoisted(
() =>
class MockClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
super(message);
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
}
},
);
vi.mock('../src/services/clientApi', () => ({
ClientAuthRequestError: MockClientAuthRequestError,
loadClientLlmModels: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
const invoke = vi.fn(); const invoke = vi.fn();
let savedModelId = 'quality'; let savedModelId = 'quality';
let savedModelIsDefault = true; let savedModelIsDefault = true;
@@ -54,6 +79,122 @@ beforeEach(() => {
}); });
afterEach(cleanup); afterEach(cleanup);
async function renderReadyModelMenu() {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
});
return onReady;
}
test('shows manual refresh progress immediately without clearing the selected model', async () => {
const onReady = await renderReadyModelMenu();
let resolveRefresh!: (catalog: ClientLlmModelCatalog) => void;
vi.mocked(loadClientLlmModels).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
const refreshButton = screen.getByRole('button', { name: '刷新模型列表' });
expect(refreshButton.textContent).toBe('刷新中…');
expect(refreshButton).toHaveProperty('disabled', true);
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
expect(onReady).toHaveBeenLastCalledWith(false);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
await act(async () => {
resolveRefresh({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量' }],
revision: 1,
});
});
expect(screen.getByRole('status').textContent).toBe('模型列表已刷新');
expect(savedModelId).toBe('quality');
expect(onReady).toHaveBeenLastCalledWith(true);
});
test('confirms a manual refresh even when the catalog revision is unchanged', async () => {
await renderReadyModelMenu();
expect(screen.queryByRole('status')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
expect(loadClientLlmModels).toHaveBeenCalledTimes(3);
expect(
screen
.getByRole('option', { name: /高质量/ })
.getAttribute('aria-selected'),
).toBe('true');
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
'disabled',
false,
);
});
test.each([
[
'HTTP 404',
new ClientAuthRequestError('private server detail', { status: 404 }),
'模型列表加载失败(HTTP 404',
],
[
'HTTP 401',
new ClientAuthRequestError('private server detail', { status: 401 }),
'模型列表加载失败(HTTP 401',
],
[
'timeout',
new ClientHttpTimeoutError('https://private.example/models', 15000),
'模型列表请求超时,请重试',
],
['unknown', new Error('private server detail'), '模型列表加载失败'],
])(
'reports a safe %s failure with cached models and permits retry without claiming success',
async (_label, failure, message) => {
const onReady = await renderReadyModelMenu();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(failure);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toBe(message),
);
expect(screen.queryByText('模型列表已刷新')).toBeNull();
expect(screen.queryByText('正在刷新模型列表')).toBeNull();
expect(document.body.textContent).not.toContain('private');
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
expect(
screen
.getByRole('option', { name: /高质量/ })
.getAttribute('aria-selected'),
).toBe('true');
expect(savedModelId).toBe('quality');
expect(onReady).toHaveBeenLastCalledWith(true);
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
expect(screen.queryByRole('alert')).toBeNull();
},
);
test('only displays aliases and persists selection through the native command', async () => { test('only displays aliases and persists selection through the native command', async () => {
const onReady = vi.fn(); const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />); render(<ConversationModelSelect disabled={false} onReady={onReady} />);
@@ -1,26 +0,0 @@
# 【实施计划】AGC 首页自定义工作目录
Version: 1
Status: active
Date: 2026-09-15
Parent Spec: 【里程碑】AGC首页自定义工作目录-2026-09-15
## 顺序
1. 定位现有首页创建与 Tauri 命令注册。
2. 增加目录选择/创建/检查的最小 native command 与前端适配。
3. 接入首页创建状态,保留现有 HomeCreationOperation。
4. 补 Rust/前端定向测试,执行编码和差异检查。
## 验证
- AGC web typecheck
- 相关 Tauri cargo test
- AGC appSurface 定向测试
- npm run check:encoding
- git diff --check
## 风险与回滚
- 风险:Windows 权限检查误触发 ACL/UAC;只使用普通文件 API,不调用安全描述符或提升权限。
- 回滚:移除新增 command/UI 适配,保留既有自动建项路径。
@@ -1,33 +0,0 @@
# 【里程碑】AGC 首页自定义工作目录
Version: 1
Status: active
Date: 2026-09-15
Parent Spec: AGC 客户端 AI 游戏创作 App 实施计划
## 范围
- 首页创建入口支持选择已有目录或创建子目录。
- 目录校验包含存在性、目录类型、可读写性和空目录判断。
- 校验通过后复用现有首页建项流程。
- 外部进程在校验后写入目录导致非空,不纳入本里程碑处理。
## 权限边界
- 不修改 ACL,不请求管理员权限,不通过提升权限探测目录。
- 可写性以当前用户在目标目录执行无害临时文件创建/删除为准;失败返回普通错误。
- 选择目录使用系统目录选择器,创建目录使用当前用户可写的父目录。
## 验收
- 选择空目录可继续建项。
- 选择非空目录被阻止并可重新选择。
- 创建目录成功后自动选中并可继续建项。
- 不存在、非目录、不可读写均有稳定错误状态。
- 不触发 UAC,不出现 ACL 权限栈错误泄漏。
## 不做
- 不合并非空目录内容。
- 不监控校验后目录变化。
- 不新增项目格式或公开 API。
@@ -26,6 +26,11 @@ Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\`
- **验证**`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve``starting backend stack``backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。 - **验证**`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve``starting backend stack``backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。
- **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。 - **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。
- **关联**`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts``apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。 - **关联**`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts``apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。
## 2026-09-15 AGC JSON API 的响应体也必须有等待上限
- `fetchClientHttp` 的超时只覆盖请求到响应头返回;随后直接等待 `response.text()` 仍可能无限挂起。模型目录共用一个在途 Promise,响应体卡住会使后续刷新复用同一挂起请求、选择器持续忙碌。
- 成功 JSON 与错误响应体均复用 `readClientHttpResponseText` 的 15 秒上限;超时后保留最后一次有效目录并释放在途请求,手动重试重新发起请求。迟到的响应不得覆盖重试获得的新目录。
- 排查时区分接口未挂载(404)、未授权(401)、网络或响应体超时以及刷新无变化但缺少反馈;不能仅凭客户端启动 IPC 回退警告判断刷新失败原因。
## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖 ## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖
@@ -7,6 +7,8 @@
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
- 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。
- 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。 - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId``selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。 - 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId``selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。 - `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
@@ -19,4 +21,5 @@
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。 - 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。 - 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。 - 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
- 响应体超时保留有效缓存、再次刷新重新请求、迟到响应不覆盖新目录;手动刷新进行中、同版本成功与缓存兜底失败反馈。
- AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。 - AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。