Compare commits

...

5 Commits

Author SHA1 Message Date
k88936 bd94db73c9 修复资源卡片 Lucide 图标描边宽度
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 18m2s
恢复 CanvasWorld 超采样下资源卡片 SVG 图标的 inverse-scale 描边补偿

新增资源工作台 CSS 回归断言,防止局部图标规则被误删

同步 CanvasWorld 超采样常量与图标描边约定文档
2026-09-12 15:04:08 +08:00
k88936 e50d678411 Merge pull request '修复[dev]下agc启动时长时间白屏' (#333) from boot-time-diagnosis into master
Project CI / Repository checks (push) Successful in 2m47s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 7m46s
Project CI / Native shell tests (push) Successful in 18m17s
Project CI / Repository checks (pull_request) Failing after 10s
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
Reviewed-on: #333
2026-09-12 11:19:25 +08:00
k88936 727794bc95 Merge branch 'master' into boot-time-diagnosis
Project CI / Repository checks (pull_request) Successful in 2m43s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Backend tests (pull_request) Successful in 7m13s
Project CI / Native shell tests (pull_request) Successful in 20m20s
2026-09-12 10:58:33 +08:00
k88936 cc9b95ddef 优化调试配置:提升SHA-256计算性能,避免调试构建阻塞
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 20m20s
2026-09-12 10:54:51 +08:00
kdletters fafe6b63cd 修复AGC模型请求与对话登录态自动续期 (#329)
Project CI / Repository checks (push) Successful in 2m40s
Project CI / Frontend tests (push) Successful in 3m23s
Project CI / Backend tests (push) Successful in 5m46s
Project CI / Native shell tests (push) Successful in 18m30s
## 变更说明

- AGC 客户端配套后端 API 在 401 时自动刷新登录态并重试一次,覆盖模型目录请求。
- DirectProject 对话鉴权失效时刷新平台会话、同步 Rust/Runner 凭据并重试同一 clientTurnId。
- 403 权限拒绝不触发续期;账号切换后不重发旧请求。
- 补充并发续期、失败保留原错误、权限边界和 Direct 对话重试测试。

## 验证

- npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/clientApi.test.ts apps/ai-game-creator-shell/tests/clientHttp.test.ts apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
- npm run agc:typecheck
- npm run check:encoding
- git diff --check

Reviewed-on: #329
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-11 18:09:48 +08:00
9 changed files with 260 additions and 22 deletions
@@ -74,6 +74,14 @@ codegen-units = 256
lto = "off" lto = "off"
incremental = true incremental = true
# Runner 启动阶段会对当前 Debug 可执行文件计算 SHA-256。仅优化密码学依赖,
# 保持业务代码的 Debug 编译速度,同时避免整份 Debug 构建因未优化 hash 热点而阻塞启动。
[profile.dev.package.sha2]
opt-level = 3
[profile.dev.package.digest]
opt-level = 3
[profile.test] [profile.test]
opt-level = 0 opt-level = 0
debug = 1 debug = 1
+47 -4
View File
@@ -251,6 +251,10 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { captureAgentRuntimeError } from './services/errorReporting'; import { captureAgentRuntimeError } from './services/errorReporting';
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
} from './services/platformSession';
import type { HomeCreationType } from './view/home'; import type { HomeCreationType } from './view/home';
import { import {
type ProjectAgentResultSummary, type ProjectAgentResultSummary,
@@ -264,6 +268,35 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX =
'direct-codex-turn-already-running:'; 'direct-codex-turn-already-running:';
function isDirectCodexAuthenticationRequired(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('authentication-required') ||
message.includes('codex-app-server-error:unauthorized') ||
/kind=codex-app-server-unauthorized(?=\s|$)/.test(message) ||
message.includes('登录已失效')
);
}
async function withDirectCodexSessionRefresh<T>(operation: () => Promise<T>) {
const generation = currentPlatformSessionGeneration();
try {
return await operation();
} catch (error) {
if (!isDirectCodexAuthenticationRequired(error)) throw error;
if (currentPlatformSessionGeneration() !== generation) throw error;
const refresh = await requestPlatformSessionRefresh();
if (refresh.status === 'failed') throw error;
if (
refresh.status !== 'refreshed' ||
currentPlatformSessionGeneration() !== refresh.generation
) {
throw new Error('登录账号已变化,原对话请求已停止');
}
return operation();
}
}
const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([
'accepted', 'accepted',
'running', 'running',
@@ -5933,10 +5966,20 @@ export function App({
if (attachments?.length) { if (attachments?.length) {
directTurnInput.attachments = attachments; directTurnInput.attachments = attachments;
} }
const reply = await directInvoke<string>( const reply = await withDirectCodexSessionRefresh(() => {
'chat_with_game_creator_direct_codex', // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。
directTurnInput, activeDirectCodexTurnRef.current = {
); projectPath: directProjectPath,
turnId: clientTurnId,
lastSequence: -1,
receivedDirectUpdate: false,
};
setDirectCodexStatus('accepted');
return directInvoke<string>(
'chat_with_game_creator_direct_codex',
directTurnInput,
);
});
// Rust already persisted the complete raw response items. Invalidate // Rust already persisted the complete raw response items. Invalidate
// any history snapshot captured before the turn completed. // any history snapshot captured before the turn completed.
if (localProjectPathRef.current === directProjectPath) { if (localProjectPathRef.current === directProjectPath) {
@@ -10,6 +10,10 @@ import {
} from '../../../../packages/shared/src'; } from '../../../../packages/shared/src';
import { fetchClientHttp } from './clientHttp'; import { fetchClientHttp } from './clientHttp';
import { captureClientError } from './errorReporting'; import { captureClientError } from './errorReporting';
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
} from './platformSession';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
@@ -84,23 +88,40 @@ export async function requestClientApi<T>(
fallbackMessage: string, fallbackMessage: string,
options: { skipAuth?: boolean } = {}, options: { skipAuth?: boolean } = {},
) { ) {
const headers = new Headers(init.headers); const generation = currentPlatformSessionGeneration();
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); const request = async () => {
if (!options.skipAuth) { const headers = new Headers(init.headers);
const token = getStoredAuthAccessToken(); headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (token) { if (!options.skipAuth) {
headers.set('Authorization', `Bearer ${token}`); const token = getStoredAuthAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
try {
return await fetchClientHttp(url, {
...init,
credentials: 'same-origin',
headers,
});
} catch (error) {
throw apiNetworkError(url, error);
}
};
let response = await request();
// Access tokens are short lived. Refresh the cookie-backed session once and
// retry the original request so callers do not need to handle token expiry.
if (!options.skipAuth && response.status === 401) {
if (currentPlatformSessionGeneration() === generation) {
const refresh = await requestPlatformSessionRefresh();
if (
refresh.status === 'refreshed' &&
currentPlatformSessionGeneration() === refresh.generation
) {
response = await request();
}
} }
}
let response: Response;
try {
response = await fetchClientHttp(url, {
...init,
credentials: 'same-origin',
headers,
});
} catch (error) {
throw apiNetworkError(url, error);
} }
if (!response.ok) { if (!response.ok) {
captureApiErrorStatus(url, response); captureApiErrorStatus(url, response);
@@ -5983,6 +5983,13 @@ iframe.preview-frame {
transform-origin: bottom right; transform-origin: bottom right;
} }
/* CanvasWorld 使用 CANVAS_RENDER_SUPERSAMPLE 超采样;这些 Lucide 卡片图标需保持屏幕级描边宽度。 */
.game-resource-card-placeholder > svg *,
.game-resource-card-version-visual > svg *,
.game-resource-card-audio-visual > svg * {
stroke-width: calc(2px * var(--genarrative-image-canvas-inverse-scale, 1));
}
.game-resource-card-open { .game-resource-card-open {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
@@ -1989,6 +1989,9 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch( expect(styles).toMatch(
/\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s, /\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s,
); );
expect(styles).toMatch(
/\.game-resource-card-placeholder\s*>\s*svg\s*\*,\s*\.game-resource-card-version-visual\s*>\s*svg\s*\*,\s*\.game-resource-card-audio-visual\s*>\s*svg\s*\*\s*\{[^}]*stroke-width:\s*calc\(2px\s*\*\s*var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)/s,
);
expect(styles).toMatch( expect(styles).toMatch(
/\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s, /\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s,
); );
@@ -0,0 +1,146 @@
/** @vitest-environment jsdom */
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
requestClientApi,
setStoredAuthAccessToken,
} from '../src/services/clientApi';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
resetPlatformSessionStateForTests,
} from '../src/services/platformSession';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
const user = { id: 'session-user' } as AuthUser;
const nativeInvoke = vi.fn(async () => null);
const catalog = { models: [{ id: 'quality', displayName: '高质量' }] };
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status });
beforeEach(async () => {
resetPlatformSessionStateForTests();
window.localStorage.clear();
nativeInvoke.mockClear();
window.__TAURI__ = { core: { invoke: nativeInvoke } };
setStoredAuthAccessToken('expired-token');
await commitAuthenticatedPlatformSession(
user,
currentPlatformSessionGeneration(),
);
nativeInvoke.mockClear();
});
afterEach(() => {
resetPlatformSessionStateForTests();
window.localStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
});
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
let refreshCalls = 0;
let modelCalls = 0;
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, init) => {
if (input === '/api/auth/refresh') {
refreshCalls += 1;
return json({ token: 'fresh-token' });
}
if (input === '/api/auth/me') return json({ user });
modelCalls += 1;
const token = new Headers(init?.headers).get('Authorization');
if (token === 'Bearer expired-token') return json({}, 401);
expect(token).toBe('Bearer fresh-token');
expect(nativeInvoke).toHaveBeenCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'fresh-token',
userId: user.id,
}),
);
return json(catalog);
});
const results = await Promise.all([
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
]);
expect(results).toEqual([catalog, catalog]);
expect(refreshCalls).toBe(1);
expect(modelCalls).toBe(4);
expect(fetch).toHaveBeenCalledTimes(6);
});
it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json({}, status))
.mockResolvedValueOnce(json({}, 401));
await expect(
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
).rejects.toMatchObject({ status });
expect(fetch).toHaveBeenCalledTimes(2);
});
it('跳过鉴权的请求不触发续期', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401));
await expect(
requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }),
).rejects.toMatchObject({ status: 401 });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('403 权限拒绝不触发续期或重发写请求', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403));
await expect(
requestClientApi(
'/api/example',
{ method: 'POST', body: '{}' },
'权限不足',
),
).rejects.toMatchObject({ status: 403 });
expect(fetch).toHaveBeenCalledTimes(1);
expect(nativeInvoke).not.toHaveBeenCalled();
});
it('续期成功后的再次未授权不循环重试', async () => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({ token: 'fresh-token' }))
.mockResolvedValueOnce(json({ user }))
.mockResolvedValueOnce(json({}, 401));
await expect(
requestClientApi('/api/llm/models', {}, '读取失败'),
).rejects.toMatchObject({ status: 401 });
expect(fetch).toHaveBeenCalledTimes(4);
});
it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => {
let finish!: (response: Response) => void;
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
const pending = requestClientApi('/api/llm/models', {}, '读取失败');
const rejection = expect(pending).rejects.toMatchObject({ status: 401 });
const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('other-token');
await commitAuthenticatedPlatformSession(
{ ...user, id: 'other-user' },
generation,
);
finish(json({}, 401));
await rejection;
expect(fetch).toHaveBeenCalledTimes(1);
});
@@ -8055,7 +8055,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移 ## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移
- 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一。 - 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一。
- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated` - 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算;超采样比例统一由共享包的 `CANVAS_RENDER_SUPERSAMPLE` 常量定义。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。资源依赖关系线继续使用几何 overlay 的非缩放描边;资源卡片中的 Lucide 等静态 SVG 图标使用局部 inverse-scale `stroke-width` 补偿,保持屏幕级线宽,不把该规则扩展为全局 SVG 规则;位图维持浏览器默认插值,不全局启用 `pixelated`
- 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。 - 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。
- 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding``git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸。 - 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding``git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸。
@@ -5036,6 +5036,11 @@
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。 - 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。 - 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
## AGC 登录态续期必须同步本地运行时
- 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。
- 普通 API 仅在 `401` 时续期并至多重试一次;`403` 权限拒绝不重发。对话续期失败保留原机器可读鉴权错误,避免用户提示退化为普通执行失败;账号代次变化时停止旧请求。
## AGC 前端等待超时与 worker 端口冲突 ## AGC 前端等待超时与 worker 端口冲突
- `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。 - `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。
@@ -582,7 +582,7 @@ game-project/
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。 - 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
- `@genarrative/image-canvas-react``CanvasWorld` 接收逻辑 `viewport``viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线几何 overlayLucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。 - `@genarrative/image-canvas-react``CanvasWorld` 接收逻辑 `viewport``viewport.scale` 表示最终视觉比例;渲染细节由共享包封装,超采样比例统一由共享包的 `CANVAS_RENDER_SUPERSAMPLE` 常量定义。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。资源依赖关系线继续使用几何 overlay 的非缩放描边;Lucide 等静态卡片图标在对应卡片 SVG 子元素上使用局部 inverse-scale `stroke-width` 补偿,保持屏幕级线宽,不扩展为全局 SVG 规则;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
#### 资源管理串行改造:本体卡、分区缩放与依赖聚类 #### 资源管理串行改造:本体卡、分区缩放与依赖聚类
@@ -1350,3 +1350,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted``waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。 - 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted``waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。
- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery``direct_tool_bridge``project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 - 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery``direct_tool_bridge``project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。
- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 - 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。
## 2026-09-11 AGC 登录态自动续期
- AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。
- DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。