Compare commits

..

9 Commits

Author SHA1 Message Date
k88936 a3d83b2d06 staged 2026-07-30 15:43:22 +08:00
kdletters 3a64411e8c 统一角色原图与透明图交付尺寸
Project CI / Repository checks (push) Successful in 51s
Project CI / Frontend tests (push) Successful in 1m44s
Project CI / Native shell tests (push) Successful in 2m7s
Project CI / Backend tests (push) Successful in 3m16s
角色原图持久化前归一到业务交付尺寸
拒绝错比例透明蒙版并降级保留原图
合并尺寸降级与后处理失败告警
补齐全模型全尺寸真实像素回归
同步角色图片尺寸边界文档
2026-07-29 16:10:54 +08:00
k88936 6ee33dc561 fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切 (#111)
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 2m38s
Project CI / Backend tests (push) Successful in 3m23s
- 封面使用真实画布尺寸及最终 viewport.x/y/scale。
- 320×240 封面改为居中 cover 裁切,不再从左上角取景。
- 正确绘制画布背景色。

in canva:
![shotmd-1784874081.jpg](/attachments/4ac128cc-64c8-4a78-9e75-c35902049d3e)
![shotmd-1784876202.jpg](/attachments/6197259b-887e-4fd6-95b5-b722ec670a02)

in home:
![shotmd-1784874090.jpg](/attachments/241c7cd3-1eee-4f7b-96f2-4ca0268d2bee)
![shotmd-1784876211.jpg](/attachments/aa4200b6-bef0-4122-a797-ebc082b27a17)

因为原本的布局持久化可能有一些bug,改动不相关,所以我回退到最开始的一版:
- 不额外监听画布尺寸变化,给最小尺寸兜底,(大了比小了好)
- 封面不要求强一致性,生成/上传时机仍然按照原来随布局持久化

优化了封面生成:
- 改用webp格式
- 尽量利用图片缓存

最终生成封面从700ms加速到150ms以内, 加上必须的上传和确认150ms 和 布局持久化的时间相差不多,不是特别离谱的手速都能保证两者一致

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/111
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-29 16:09:30 +08:00
kdletters edc99e55e4 修复跨设备画布旧快照覆盖
Project CI / Frontend tests (push) Failing after 43s
Project CI / Repository checks (push) Successful in 47s
Project CI / Native shell tests (push) Successful in 2m47s
Project CI / Backend tests (push) Successful in 3m13s
按用户隔离画布缓存并等待权威 revision 后开放保存
固化保存重试版本并安全处理认证重载与冲突恢复
合并迟到资源图层且隔离跨账号异步请求
强制 Web 与 External 画布写入使用 expectedRevision CAS
补充竞态回归测试并同步 OpenAPI、Skill 与技术文档
2026-07-29 13:51:49 +08:00
kdletters 44f47e5db1 修复图集素材标签持久化与拆分校验
Project CI / Native shell tests (push) Successful in 2m40s
Project CI / Backend tests (push) Successful in 3m16s
Project CI / Repository checks (push) Successful in 1m0s
Project CI / Frontend tests (push) Successful in 2m5s
项目资源复用增加素材类型匹配,避免同源旧类型资源回流
标签保存期间禁用拆分并在服务端确认后回写资源引用
补齐乱序请求、快照刷新、失败回滚和撤销历史保护
新增前后端回归测试并同步图集入口设计文档
2026-07-29 11:06:56 +08:00
kdletters d7f0c45164 抽取画布 Agent 公共执行框架
Project CI / Repository checks (push) Successful in 1m3s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / Native shell tests (push) Successful in 2m27s
Project CI / Backend tests (push) Successful in 3m5s
新增业务中立的 function-calling harness 与公共提示词协议

统一画布 Agent 工具路由约束和待确认控制语义

保留失败前输出并事务化收口 memory、deadline 与取消状态

迁移画布 Agent 和 API 编排并同步架构文档与项目记忆
2026-07-28 21:34:34 +08:00
kdletters 271d5d7bd3 持久化用户历史消费泥点投影
Project CI / Native shell tests (push) Successful in 2m28s
Project CI / Repository checks (push) Successful in 59s
Project CI / Frontend tests (push) Successful in 1m58s
Project CI / Backend tests (push) Successful in 3m5s
后台用户详情展示历史花费并保留手动对账
消费落账原子更新投影并提供存量初始化
为手动对账增加独立管理员操作权限
更新SpacetimeDB绑定、文档和测试
2026-07-28 16:20:43 +08:00
k88936 66a8b7f597 画布agent 完善上下文 (#101)
Project CI / Repository checks (push) Successful in 53s
Project CI / Frontend tests (push) Successful in 2m34s
Project CI / Native shell tests (push) Successful in 2m42s
Project CI / Backend tests (push) Successful in 3m28s
* 补全完整的工具参数
* 调整提示词应对一些bad case
* 把attachment的名称/描述纳入上下文

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/101
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-28 15:44:05 +08:00
k88936 78e6959ca4 画布agent交互优化 (#105)
Project CI / Repository checks (push) Successful in 1m10s
Project CI / Frontend tests (push) Successful in 1m27s
Project CI / Native shell tests (push) Successful in 2m45s
Project CI / Backend tests (push) Successful in 3m47s
输入框尺寸根据输入内容调整高度,达到最大高度后再出现翻页条。
before:
![shotmd-1784699696.jpg](/attachments/20491333-efe9-44f3-ba6c-1d84579473b2)
after:
![shotmd-1784699381.jpg](/attachments/6018e4e1-6ccc-4de7-92de-5d3f5fb43846)
![shotmd-1784699431.jpg](/attachments/8e91351e-29a5-4220-a7e2-e09554b00935)

调整对话切换角标样式
before:
![shotmd-1784699673.jpg](/attachments/7c3470ab-6f7d-4aa8-9660-4db8e942bd28)
after:
![shotmd-1784699465.jpg](/attachments/e8342741-c1a4-4669-9ad4-c8b34cfeaf30)

新建对话按钮禁用 当已在一个新对话中

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/105
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-28 15:22:49 +08:00
141 changed files with 13128 additions and 2855 deletions
@@ -36,7 +36,7 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
| Intent | Method and path | Required fields |
| --- | --- | --- |
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
@@ -53,7 +53,7 @@ Ask a follow-up only when two routes could both be correct and produce different
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
@@ -270,11 +270,21 @@ class GenarrativeExternalClient:
fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材"
return fields
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
def save_canvas(
self,
project_id: str,
viewport: dict[str, Any],
layers: dict[str, Any],
expected_revision: int,
) -> Any:
return self.request_json(
"PATCH",
f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas",
{"viewport": viewport, "layers": layers},
{
"viewport": viewport,
"layers": layers,
"expectedRevision": expected_revision,
},
)
def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str:
+40 -1
View File
@@ -6,6 +6,7 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
@@ -16,7 +17,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
@@ -31,11 +32,13 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
actionPermissions: ['profile-wallet-consumption-reconcile'],
enabled: true,
});
await updateAdminAccount('owner-token', 'member/1', {
displayName: '运营二组',
tabPermissions: ['tracking'],
actionPermissions: [],
enabled: false,
});
@@ -44,6 +47,14 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
body: JSON.stringify({
username: 'operator',
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
actionPermissions: ['profile-wallet-consumption-reconcile'],
enabled: true,
}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
@@ -53,6 +64,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
body: JSON.stringify({
displayName: '运营二组',
tabPermissions: ['tracking'],
actionPermissions: [],
enabled: false,
}),
}),
@@ -238,6 +250,33 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
});
});
test('历史花费手动对账使用独立管理员写接口', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
userId: 'user-1',
historicalConsumedPoints: 1300,
changed: true,
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
await reconcileAdminUserConsumption('token-1', { userId: 'user-1' });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
'/admin/api/profile/users/reconcile-consumption',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer token-1' }),
body: JSON.stringify({ userId: 'user-1' }),
}),
);
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
+12
View File
@@ -48,6 +48,8 @@ import type {
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
AdminUpsertProfileWalletConfigRequest,
AdminUserConsumptionReconcileRequest,
AdminUserConsumptionReconcileResponse,
AdminUserDetailQuery,
AdminUserDetailResponse,
AdminWalletRestrictionRequest,
@@ -577,6 +579,16 @@ export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
);
}
export function reconcileAdminUserConsumption(
token: string,
payload: AdminUserConsumptionReconcileRequest,
) {
return request<AdminUserConsumptionReconcileResponse>(
'/admin/api/profile/users/reconcile-consumption',
{ method: 'POST', token, body: payload },
);
}
export function previewAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundPreviewRequest,
+18
View File
@@ -38,6 +38,7 @@ export interface AdminSessionPayload {
roles: string[];
accountRole: 'owner' | 'member';
tabPermissions: string[];
actionPermissions: string[];
issuedAt: string;
expiresAt: string;
}
@@ -48,6 +49,7 @@ export interface AdminAccountPayload {
displayName: string;
accountRole: 'owner' | 'member';
tabPermissions: string[];
actionPermissions: string[];
enabled: boolean;
tokenVersion: number;
createdBy: string;
@@ -65,6 +67,7 @@ export interface AdminCreateAccountRequest {
displayName: string;
password: string;
tabPermissions: string[];
actionPermissions: string[];
enabled: boolean;
}
@@ -76,6 +79,7 @@ export interface AdminUpdateAccountRequest {
displayName: string;
password?: string;
tabPermissions: string[];
actionPermissions: string[];
enabled: boolean;
}
@@ -819,10 +823,24 @@ export interface AdminUserDetailResponse {
bindingStatus: string;
phoneBound: boolean;
wechatBound: boolean;
historicalConsumedPoints: number;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
}
export interface AdminUserConsumptionReconcileRequest {
userId: string;
}
export interface AdminUserConsumptionReconcileResponse {
userId: string;
previousHistoricalConsumedPoints?: number | null;
historicalConsumedPoints: number;
changed: boolean;
reconciledAtMicros: number;
}
export interface AdminRechargeRefundPreviewRequest {
orderId: string;
refundAmountCents: number;
@@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminUserDetail,
reconcileAdminUserConsumption,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
@@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({
),
getAdminUserDetail: vi.fn(),
isAdminApiError: vi.fn(() => false),
reconcileAdminUserConsumption: vi.fn(),
updateAdminWalletRestriction: vi.fn(),
}));
@@ -48,6 +50,8 @@ const detail: AdminUserDetailResponse = {
bindingStatus: 'bound',
phoneBound: true,
wechatBound: true,
historicalConsumedPoints: 1234,
canReconcileConsumption: true,
wallet,
rechargeOrders: [
{
@@ -82,6 +86,13 @@ const detail: AdminUserDetailResponse = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
vi.mocked(reconcileAdminUserConsumption).mockResolvedValue({
userId: 'user-1',
previousHistoricalConsumedPoints: 1234,
historicalConsumedPoints: 1300,
changed: true,
reconciledAtMicros: 1_720_000_000_000_000,
});
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
});
@@ -111,6 +122,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('138****5678')).toBeTruthy();
expect(screen.getByText('退款欠账限制')).toBeTruthy();
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
await user.keyboard('{Escape}');
@@ -136,6 +149,50 @@ test('只有陶泥号时按 publicUserCode 查询用户', async () => {
});
});
test('历史花费支持手动对账并用权威结果校准展示', async () => {
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await screen.findByText('陶泥用户');
await user.click(screen.getByRole('button', {name: '手动对账历史花费'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(reconcileAdminUserConsumption).toHaveBeenCalledWith('admin-token', {
userId: 'user-1',
});
});
expect(await screen.findByText('对账完成,历史花费已校准')).toBeTruthy();
expect(screen.getByText('1300', {selector: 'strong'})).toBeTruthy();
});
test('没有独立操作权限时不显示历史花费对账按钮', async () => {
vi.mocked(getAdminUserDetail).mockResolvedValue({
...detail,
canReconcileConsumption: false,
});
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await screen.findByText('陶泥用户');
expect(screen.queryByRole('button', {name: '手动对账历史花费'})).toBeNull();
});
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
const user = userEvent.setup();
const manuallyFrozenWallet: AdminProfileWalletPayload = {
@@ -6,6 +6,7 @@ import {
formatAdminApiError,
getAdminUserDetail,
isAdminApiError,
reconcileAdminUserConsumption,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
@@ -34,6 +35,8 @@ export function AdminUserDetailDialog({
const [errorMessage, setErrorMessage] = useState('');
const [restrictionReason, setRestrictionReason] = useState('');
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
const [isReconcilingConsumption, setIsReconcilingConsumption] = useState(false);
const [reconcileMessage, setReconcileMessage] = useState('');
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const requestVersionRef = useRef(0);
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
@@ -60,7 +63,12 @@ export function AdminUserDetailDialog({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) {
if (
event.key === 'Escape' &&
!isSavingRestriction &&
!isReconcilingConsumption &&
!isConfirming
) {
event.preventDefault();
onClose();
}
@@ -69,13 +77,14 @@ export function AdminUserDetailDialog({
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isConfirming, isSavingRestriction, onClose]);
}, [isConfirming, isReconcilingConsumption, isSavingRestriction, onClose]);
async function loadDetail() {
const requestVersion = requestVersionRef.current + 1;
requestVersionRef.current = requestVersion;
setIsLoading(true);
setErrorMessage('');
setReconcileMessage('');
try {
const response = await getAdminUserDetail(token, {
userId: userId?.trim() || undefined,
@@ -144,6 +153,47 @@ export function AdminUserDetailDialog({
}
}
async function handleConsumptionReconcile() {
if (!detail || isReconcilingConsumption) {
return;
}
const confirmed = await confirmWrite({
action: '手动对账历史花费',
target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`,
});
if (!confirmed) {
return;
}
setIsReconcilingConsumption(true);
setErrorMessage('');
setReconcileMessage('');
try {
const response = await reconcileAdminUserConsumption(token, {
userId: detail.userId,
});
setDetail((current) =>
current
? {
...current,
historicalConsumedPoints: response.historicalConsumedPoints,
}
: current,
);
setReconcileMessage(
response.changed ? '对账完成,历史花费已校准' : '对账完成,数据一致',
);
} catch (error: unknown) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
} else {
setErrorMessage(formatAdminApiError(error));
}
} finally {
setIsReconcilingConsumption(false);
}
}
if (typeof document === 'undefined') {
return null;
}
@@ -158,6 +208,7 @@ export function AdminUserDetailDialog({
if (
event.target === event.currentTarget &&
!isSavingRestriction &&
!isReconcilingConsumption &&
!isConfirming
) {
onClose();
@@ -174,7 +225,7 @@ export function AdminUserDetailDialog({
<button
aria-label="刷新用户信息"
className="admin-ghost-button"
disabled={isLoading}
disabled={isLoading || isReconcilingConsumption}
title="刷新"
type="button"
onClick={() => void loadDetail()}
@@ -185,7 +236,7 @@ export function AdminUserDetailDialog({
ref={closeButtonRef}
aria-label="关闭用户详情"
className="admin-ghost-button"
disabled={isSavingRestriction}
disabled={isSavingRestriction || isReconcilingConsumption}
title="关闭"
type="button"
onClick={onClose}
@@ -222,7 +273,15 @@ export function AdminUserDetailDialog({
{errorMessage}
</div>
) : null}
<WalletSection wallet={detail.wallet} />
<WalletSection
wallet={detail.wallet}
historicalConsumedPoints={detail.historicalConsumedPoints}
canReconcileConsumption={detail.canReconcileConsumption}
isBusy={isReconcilingConsumption || isSavingRestriction}
isReconciling={isReconcilingConsumption}
reconcileMessage={reconcileMessage}
onReconcile={() => void handleConsumptionReconcile()}
/>
<section className="admin-user-restriction-section">
<div className="admin-panel-heading">
@@ -251,7 +310,7 @@ export function AdminUserDetailDialog({
<span></span>
<input
aria-label="人工冻结操作原因"
disabled={isSavingRestriction}
disabled={isSavingRestriction || isReconcilingConsumption}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
/>
@@ -262,7 +321,11 @@ export function AdminUserDetailDialog({
? 'admin-secondary-button'
: 'admin-danger-button'
}
disabled={isSavingRestriction || !restrictionReason.trim()}
disabled={
isSavingRestriction ||
isReconcilingConsumption ||
!restrictionReason.trim()
}
type="button"
onClick={() => void handleRestrictionChange()}
>
@@ -369,7 +432,23 @@ function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
);
}
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
function WalletSection({
wallet,
historicalConsumedPoints,
canReconcileConsumption,
isBusy,
isReconciling,
reconcileMessage,
onReconcile,
}: {
wallet: AdminProfileWalletPayload;
historicalConsumedPoints: number;
canReconcileConsumption: boolean;
isBusy: boolean;
isReconciling: boolean;
reconcileMessage: string;
onReconcile: () => void;
}) {
const metrics = [
['总余额', wallet.totalBalance],
['可消费', wallet.spendableBalance],
@@ -378,19 +457,41 @@ function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
['会员限时', wallet.membershipLimitedPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
['历史花费', historicalConsumedPoints],
] as const;
return (
<section className="admin-user-wallet-section">
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
<div className="admin-detail-actions">
{canReconcileConsumption ? (
<button
aria-label="手动对账历史花费"
className="admin-ghost-button admin-user-wallet-reconcile-button"
disabled={isBusy}
type="button"
onClick={onReconcile}
>
<RefreshCcw size={15} aria-hidden="true" />
<span>{isReconciling ? '对账中' : '手动对账'}</span>
</button>
) : null}
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok"></span> : null}
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
) : null}
{!wallet.walletFrozen ? (
<span className="admin-status admin-status-ok"></span>
) : null}
</div>
</div>
</div>
{reconcileMessage ? (
<div className="admin-alert" role="status">
{reconcileMessage}
</div>
) : null}
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
@@ -17,6 +17,7 @@ interface AdminAccountsPageProps {
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
const consumptionReconcilePermission = 'profile-wallet-consumption-reconcile';
export function AdminAccountsPage({
token,
@@ -29,6 +30,7 @@ export function AdminAccountsPage({
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [actionPermissions, setActionPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
@@ -65,6 +67,7 @@ export function AdminAccountsPage({
setPassword('');
setEnabled(true);
setTabPermissions([]);
setActionPermissions([]);
setErrorMessage('');
}
@@ -75,6 +78,7 @@ export function AdminAccountsPage({
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setActionPermissions(account.actionPermissions ?? []);
setErrorMessage('');
}
@@ -127,6 +131,7 @@ export function AdminAccountsPage({
displayName: normalizedDisplayName,
...(password ? {password} : {}),
tabPermissions,
actionPermissions,
enabled,
})
: await createAdminAccount(token, {
@@ -134,6 +139,7 @@ export function AdminAccountsPage({
displayName: normalizedDisplayName,
password,
tabPermissions,
actionPermissions,
enabled,
});
setAccounts((current) => {
@@ -289,6 +295,28 @@ export function AdminAccountsPage({
</div>
</fieldset>
<fieldset className="admin-permission-fieldset">
<legend></legend>
<div className="admin-permission-grid">
<label>
<input
checked={actionPermissions.includes(
consumptionReconcilePermission,
)}
type="checkbox"
onChange={(event) =>
setActionPermissions(
event.target.checked
? [consumptionReconcilePermission]
: [],
)
}
/>
<span></span>
</label>
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
+7
View File
@@ -1270,6 +1270,13 @@ button:disabled {
background: #f8efe7;
}
.admin-ghost-button.admin-user-wallet-reconcile-button {
width: auto;
min-width: 92px;
padding: 0 10px;
gap: 6px;
}
.admin-ghost-button.admin-query-reset-button {
width: auto;
min-width: 76px;
@@ -1761,7 +1761,8 @@
"type": "object",
"required": [
"viewport",
"layers"
"layers",
"expectedRevision"
],
"properties": {
"viewport": {
@@ -1778,7 +1779,7 @@
"expectedRevision": {
"type": "integer",
"minimum": 0,
"description": "可选的画布 revision CAS;不匹配时返回 409。"
"description": "必填的画布 revision CAS;不匹配时返回 409。"
}
},
"additionalProperties": false
@@ -16,6 +16,37 @@
---
## 2026-07-29 画布 Agent 工具生命周期区分确认任务与即时执行
- 背景:原 `EditorAgentTool` 默认要求每个工具都实现定价、确认展示和 external job payload,无法表达不调用生成模型、零收费且无需确认的确定性拆图;同时角色动作已有正式 worker 能力但尚未进入 Agent 工具面。
- 决策:工具统一声明 `ConfirmedExternalJob``Immediate` 生命周期。收费生成工具仅在产生待确认输出后读取运行时定价,确认后复用既有 external job`generate-character-animation` 复用 `editor_character_animation_generation``split-elements` 只接受当前画布项目中的 `ui-design` / `icon-spritesheet`,在规划回合内直接复用连通域切片、项目资源 / 素材库持久化和画布回填,成功消息直接写 `completed`,不读取定价、不展示确认卡、不创建零价队列任务。同一规划运行内按源资源复用即时拆图结果,避免模型重复调用造成重复切片。
- 边界:不修改 SpacetimeDB schema、外部 OpenAPI、钱包规则或现有生成 job;直接拆图仍受现有图片尺寸、像素数、切片数量、资源归属和画布写回门禁约束。
- 影响范围:`platform-editor-agent` 工具定义与 prompt、`api-server/src/editor_agent` 生命周期和结果持久化、既有图集拆分 helper、Agent 前端完成态刷新及编辑器专题文档。
- 验证方式:运行 `cargo test -p platform-editor-agent --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_agent --manifest-path server-rs/Cargo.toml`、即时 UI 拆图来源类型测试、Agent 前端 hook 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck`、编码和 diff 检查。
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-29 角色带背景原图与透明图统一交付尺寸
- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。
- 决策:角色分支必须在持久化带纯色背景原图和调用 BgFilter 之前,先按统一业务像素矩阵执行一次尺寸归一;该原图和透明派生图始终使用同一实际像素尺寸,1K 的长边为 `1024`。若 provider 回图任意一边小于业务目标或比例偏差过大,仍禁止放大或大幅裁切;此时两张图一同保留 provider 实际尺寸并返回通用 `warning`,不允许只改透明图。BgFilter 回图尺寸漂移时只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并回贴到该原图;蒙版比例超限、回贴失败或尺寸验证失败时必须改用原图单产物降级,不持久化尺寸或比例不一致的透明图。若尺寸降级和后处理降级同时发生,同一条 `warning.reason` 必须同时保留两个原因。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色生成、原图持久化、BgFilter 输入、项目资源尺寸与画布图层 Resolution;不改变前端请求 DTO、扣费、素材类型或多产物布局。
- 验证方式:后端定向测试覆盖角色全尺寸矩阵:`nanobanana2``0.5K / 1K / 2K``gpt-image-2``1K / 2K`,每档均覆盖 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`,30 个组合全部构造大于目标尺寸的真实 PNG provider 回图并执行像素恢复,不只校验字符串映射;另覆盖欠尺寸禁止放大、比例超限、BgFilter 错比例 alpha 蒙版拒绝和组合告警。同时从函数调用顺序上固定“尺寸归一 → 持久化带背景原图 → BgFilter”。运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-23 画布 Agent 工具生命周期统一经 object-safe trait 分派
- 背景:画布 Agent 八类工具的参数规范化、确认展示、计价与 worker payload、完成结果格式化和媒体投影分别在 `tool_args.rs``display_args.rs``api.rs``reconcile.rs` 重复按工具名分派;新增或调整工具时容易漏改其中一处。
- 决策:api-server 以 object-safe `EditorAgentTool: ToolDyn` 取代仅承载计价的 `EditorAgentPricedTool`。trait 的所有动态方法统一接收 `serde_json::Value`;每个具体工具实现自行反序列化为真实 Args / 结果,`validate_args``format_execute_message` 显式转发到 `platform-editor-agent` 已有强类型实现,再把规范 Args、展示投影、job payload、完成文本或媒体引用擦除回公共类型。`editor_agent_tool(toolName, context)` 绑定当前 `EditorToolContext` 并作为唯一八分支工具名分派;规划、确认和回填不得再维护平行 switch。LLM builder 的工具注册列表保持独立显式维护。
- 边界:不改变工具名、LLM schema、OSS 消息文档、`displayArgs`、模型定价、job kind / payload、dedupe key、worker、计费、完成消息或图片 / 视频 / 音频引用契约,不涉及前端、SpacetimeDB schema 或迁移。
- 影响范围:`server-rs/crates/api-server/src/editor_agent` 的工具 trait、参数规范化、确认入队与终态回填,以及画布 Agent 专题文档。
- 验证方式:覆盖八类 factory 与 dyn validation / pricing / display / job / formatter / media projection 的 api-server 定向测试,运行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check`
---
## 2026-07-23 BgFilter 失败审计使用硬上限与独立 tracking outbox
- 背景:BgFilter worker 每个已发出的失败 provider attempt 都会启动 detached 审计任务;专用 worker 又关闭了 tracking outbox,使任务逐条等待 SpacetimeDB。`Q` 只约束内部 HTTP 请求生命周期,响应结束后无法限制仍在等待数据库的审计任务,部分失败、预算截短 timeout、重试恢复和熔断重置场景下可能持续堆积。
@@ -113,8 +144,8 @@
## 2026-07-10 画布 Agent 工具确认分离执行参数与展示投影
- 背景:画布 Agent 已在实际生成前进入 `pending_confirmation`,但 `EditorAgentToolCall.args` 只保存工具私有 JSON,其中图片参数是保护真实 data key 的 SHA-256 opaque ID。前端直接解析 raw args 只能显示内部哈希或图片数量,无法向用户准确展示即将使用的目标图、参考图和完整参数;若直接把图片 URL 或对象塞回 raw args,又会破坏确认执行反序列化和 LLM 不可见真实 data key 的安全边界。
- 决策:`EditorAgentToolCall.args` 继续作为确认执行唯一真相,不允许前端改写或回传替代参数;新增必填 `displayArgs` 只读展示投影,内含 `stringArgs``imageArgs``extras.priceMudPoints``stringArgs` 承载提示词与规格等用户可见字段,`imageArgs.refs` 承载 `imageId` 及后端解析出的 `objectKey``imageSrc`、可选缩略图、标签和尺寸;`extras.priceMudPoints` 由 api-server 在创建待确认消息时使用后端运行时模型定价快照计算,前端只显示“预计消耗 N泥点”,不自行计算或回传价格。api-server 必须按已注册 tool 白名单,从已校验 args 与 OSS 会话文档的附件 / 历史生成结果构建该投影;前端只渲染投影,以 `ResolvedAssetImage` 换签显示图片,不解析 tool 私有 schema、不展示 SHA-256 ID。展示价格不参与确认执行或实际扣费,确认后仍由既有生成 BFF 按后端运行时定价预扣费。删除只重复 `args` 且没有稳定语义的 `EditorAgentToolCall.summary`。模块尚未上线,不保留缺少 `displayArgs` 时读取 raw `args` 的旧消息降级路径。
- 背景:画布 Agent 已在实际生成前进入 `pending_confirmation`,但 `EditorAgentToolCall.args` 只保存工具私有的规范参数 JSON,其中图片参数是保护真实 data key 的 SHA-256 opaque ID。前端直接解析 `args` 只能显示内部哈希或图片数量,无法向用户准确展示即将使用的目标图、参考图和完整参数;若直接把图片 URL 或对象塞回 `args`,又会破坏确认执行反序列化和 LLM 不可见真实 data key 的安全边界。
- 决策:LLM 返回的原始工具参数只作为 api-server 本次处理的瞬时输入;后端按已注册 ToolArgs 反序列化、补齐默认值、删除未知 / 退役字段、完成工具参数校验并重新序列化后,才把结果写入 `EditorAgentToolCall.args`。校验失败的调用不得持久化为待确认消息。该规范 `args`确认执行唯一真相,不允许前端改写或回传替代参数;新增必填 `displayArgs` 只读展示投影,内含 `stringArgs``imageArgs``extras.priceMudPoints``stringArgs` 承载提示词与规格等用户可见字段,`imageArgs.refs` 承载规范 `args` 中的 `imageId` 及后端解析出的 `objectKey``imageSrc`、可选缩略图、标签和尺寸;`extras.priceMudPoints` 由 api-server 在创建待确认消息时使用后端运行时模型定价快照计算,前端只显示“预计消耗 N泥点”,不自行计算或回传价格。api-server 必须按已注册 tool 白名单,从规范 `args` 与 OSS 会话文档的附件 / 历史生成结果构建该投影;前端只渲染投影,以 `ResolvedAssetImage` 换签显示图片,不解析 tool 私有 schema、不展示 SHA-256 ID。展示价格不参与确认执行或实际扣费,确认后仍由既有生成 BFF 按后端运行时定价预扣费。删除只重复 `args` 且没有稳定语义的 `EditorAgentToolCall.summary`。模块尚未上线,不保留缺少 `displayArgs` 时读取 `args` 的旧消息降级路径。
- 影响范围:`shared-contracts` / `packages/shared``editorAgent` DTO、`api-server/src/editor_agent/api.rs` 的待确认消息构建、画布 Agent 待确认卡、OSS 会话消息文档与相关测试。
- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent``cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent``npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md``docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`
@@ -238,7 +269,7 @@
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version`Tab 权限权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航过滤和页面挂载门禁只负责体验,正式授权由 api-server 的 API-to-Tab 矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 常规权限粒度固定为后台 15 个一级 Tab,“账号管理”只允许 owner 且不可授予 member2026-07-24 起,历史花费手动对账作为独立高风险操作权限 `profile-wallet-consumption-reconcile`,不随任意 Tab 自动授予。member 每次请求重新读取当前账号并校验启停、`token_version`Tab 权限和独立操作权限;任一权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航和操作按钮过滤只负责体验,正式授权由 api-server 路由权限矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。
- 影响范围:`admin_account`、SpacetimeDB typed procedures / client facade、后台 JWT 与 session DTO、`/admin/api/accounts*`、后台路由权限中间件、admin-web 导航和账号管理页。
- 验证方式:SpacetimeDB schema / client / API 定向测试、`npm run check:admin-account-procedures` 隔离 procedure smoke、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。
- 关联文档:`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`
@@ -546,7 +577,9 @@
## 2026-06-24 图片画布项目封面使用静态快照资源
- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。
- 2026-07-29 补充:常规编辑仍沿用防抖保存;用户从画布返回项目页时必须取消待执行 timer,以最新权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存和正式项目资源后再导航。当前视口存在图层但全部位于取景外时仍生成纯背景封面,不沿用旧缩略图。
- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts``src/components/project/ProjectCanvasCover.tsx``src/components/project/ProjectGalleryView.tsx``src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。
- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -4240,8 +4273,8 @@
- 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`
- 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`。未单独配置 `GENARRATIVE_LLM_API_KEY` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。
- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image`引用该结果;不能因为本轮没有手动附件而退回 `generate_image`
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`
- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit-image``latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得生成工具 schema 中不存在的 `source_image_id`,也不能因为本轮没有手动附件而退回 `generate-image`
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate-image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate-image`,只有实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`
- 影响范围:`server-rs/crates/platform-agent``server-rs/crates/api-server/src/config.rs``src/services/llmClient.ts``.env.example``deploy/env/api-server.env.example``scripts/test-ve-llm.mjs`
- 验证方式:`npm run test -- src/services/llmClient.test.ts``cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model``cargo test -p platform-agent --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
@@ -4492,3 +4525,19 @@
- 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。
- 微信边界:小程序客户端仍只上传 `wechatPhoneCode``platform-auth` 必须要求微信成功响应中的 `phoneNumber``countryCode``purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。
- 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。
## 2026-07-24 后台用户详情展示历史花费泥点
- 口径:`historicalConsumedPoints` 表示用户历史总消费,只累计 `profile_wallet_ledger.source_type = asset_operation_consume``amount_delta < 0` 的绝对值;`asset_operation_refund` 不冲减,充值退款追回、余额重置、赠送和退款 hold 均不计入。
- 投影边界:新增 `profile_wallet_consumption_total`,已有投影时消费流水成功落账在同一 SpacetimeDB 事务内按主键 O(1) 原子累加;退款不回减。首次上线在停止业务写入的维护窗口由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户建立存量投影,成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费和 runtime service identity 受限的 `admin_get_profile_wallet_detail_and_return` 都可按用户索引兜底重建一次;消费事务重建已包含当前流水,不重复加本次金额。不得用最近 50 条流水列表近似,也不得把全量流水扫描塞进充值订单每行复用的通用钱包快照。
- 对账边界:保留管理员显式手动对账。owner 始终可用;member 必须单独持有 `profile-wallet-consumption-reconcile` 独立操作权限,任意 Tab 都不隐式授予。`POST /admin/api/profile/users/reconcile-consumption` 经二次确认后调用 runtime service identity 受限 procedure,扫描该用户全部权威流水、比较并校准投影,记录管理员与对账时间。
- 展示边界:现有共享“用户详情”弹窗的钱包区增加“历史花费”,前端只展示 BFF 顶层字段,不自行汇总账单;只有 BFF 返回 `canReconcileConsumption=true` 时展示手动对账按钮。
- 验证方式:SpacetimeDB 钱包聚合测试、api-server / admin-web 定向测试、`npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run admin-web:typecheck``npm run check:encoding``git diff --check`
## 2026-07-28 画布 Agent 的通用 function-calling harness 与画布 prompt 分层
- 背景:画布 Agent 的 JSON 输出协议、tool schema 注入、memory / hook、轮次保护和“全部工具待确认即结束回合”原先位于 `platform-editor-agent/src/framework`,与规范展板、已有图编辑路由、模型超时和画布工具混在同一 crate;八类工具还重复携带待确认控制话术。旧 `platform-agent` 已随 Creative Agent 退役,不能作为新公共层复活。
- 决策:新增无旧玩法依赖的现役 `platform-agent-harness`,只承载业务中立的 function-calling 执行协议;`platform-editor-agent` 通过兼容 re-export 复用该 crate,并继续承载画布 LLM profile、角色 prompt、公共美术工具路由策略、图片上下文和工具实现。无工具场景同样注入 JSON 响应格式;prompt 不再宣称工具并发执行;request 级 system prompt 必须真实进入本轮请求。画布对话额外注入最近一条已完成图片的有界 `latestGeneratedImage` 元数据,后续编辑仍只用 SHA-256 `imageId` 选图。
- 执行与失败决策:prompt 每轮通过 `AgentMemory::begin_staged` 使用与调用方 memory 行为等价、写入隔离的 `StagedAgentMemory` 事务;成功或已有工具活动时显式 `commit()`,直接 drop 表示回滚。无工具活动失败时回滚本轮 staged 增量,已发生工具活动后失败时提交已发生工具事实并追加 terminal error closure。外部 future drop / abort 若发生在工具完成后,提交工具结果与取消闭环;若发生在工具执行中,提交“已启动、结果未知”与取消闭环,后续先 reconcile,不能假装副作用未发生。harness 通过 `PromptRunError { error, partial_outputs }` 显式返回终态错误和失败前输出;结构化工具失败还必须向调用方保留 `ToolFailure.kind/retryable/fatal` 与原始 `output`,不在 harness 内压成单一字符串。api-server 的 18 分钟总 deadline 以 runtime future 下沉到 runnercompletion 可被 deadline 终止,工具在开始前检查、开始后等待返回、返回后携带结果收口;禁止外层 timeout drop prompt 或中途取消 effectful tool 后伪造空 partial。
- 保留边界:会话幂等、OSS 消息、120 秒前端软提示、20 分钟 transport、18 分钟 handler 总 deadline、1024 tokens、8 分钟 provider attempt、泥点计费、确认入队和 external job 懒回填均不进入公共 harness。SpacetimeDB schema、前端 wire DTO 和侧边栏 UI 不变。
- 验证方式:`cargo test -p platform-agent-harness``cargo test -p platform-editor-agent``cargo test -p api-server editor_agent``cargo check -p api-server --locked`、DDD 边界检查、Rustfmt、编码检查和 `git diff --check`
+32 -6
View File
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## 工具 JSON Schema 的条件约束必须覆盖运行时默认值
- 现象:LLM 按工具 schema 生成的参数可以通过结构约束,但参数补默认值后被运行时校验拒绝,白白消耗一次工具修复轮次。例如固定 `gpt-image-2` 的 UI 工具仍暴露 `0.5K`,或视频调用省略 `model` 时 schema 允许 `1080p`,运行时却默认成 `seedance2.0-fast` 后拒绝。
- 原因:通用枚举 schema 被固定模型工具直接复用;JSON Schema 的 `if` 又用 `required: ["model"]` 排除了字段缺失场景,而 Serde 默认值只在 schema 校验之后生效。description 只能提示 LLM,不能替代 `enum` / `if` / `then` 的结构约束。
- 处理:固定模型工具使用与该模型能力一致的专用枚举;可切换模型的图片工具在对象层复用共享 `model + image_size` 条件约束。条件字段有运行时默认值时,省略字段必须落入默认模型对应的 schema 分支:默认 nanobanana2 的图片工具只在显式选择 `gpt-image-2` 时收紧尺寸,所以条件保留 `required: ["model"]`;默认 fast 的视频工具则利用字段缺失时 `properties.model.const` 条件成立的语义,不额外要求 `model` 存在。运行时校验仍保留为最终防线。
- 验证:锁定 `generate-ui-design.image_size = ["1K", "2K"]`,三个可切换图片模型的工具都接入共享 `gpt-image-2 -> image_size = ["1K", "2K"]` 条件,以及视频 fast 条件没有内层 `required`、其 `then.resolution = ["480p", "720p"]`;同时保留运行时拒绝 `gpt-image-2 + 0.5K``seedance2.0-fast + 1080p` 的测试。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/tools/image_generation_options.rs``server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs``server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs``docs/【编辑器】画布Agent对话面板-2026-07-03.md`
## 图片生成的 K 档不能靠回图后缩放实现
- 现象:用户选择 2K 时占位框看起来是 2K,最终资源元数据也显示为 2K,但模型请求实际仍是固定 1K 或竖版回落尺寸;画面只是后端放大后的低分辨率结果。
@@ -50,7 +58,7 @@
- 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。
- 原因:同步接口 worker 化时把原请求整体序列化到 `external_generation_job.request_payload_json`,而前端又把已有 `objectKey` 下载成 Data URL 提交。任务表也是正式持久化边界;列表 procedure 若先收集完整任务行再截断,还会把 request/result 大字段在 SpacetimeDB、SDK mapper 和 BFF 多次持有。
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / runningdry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id``owner_user_id``completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。运维 plan 可以保留人工契约字段,但组装 CLI procedure input 时必须按生成 schema 的 wire 名称映射;例如 Rust 字段 `expected_canvas_layout_sha256` 的 CLI wire 名为 `expected_canvas_layout_sha_256`,不能直接透传 plan key。
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / runningdry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id``owner_user_id``completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。
- 发布门禁:生产发布入口必须固定 `--delete-data=never` 与 scoped `--yes=migrate,break-clients`,普通 Jenkins 参数不得暴露清库开关;需要删数据的 schema 冲突必须直接阻断并重新检查 artifact/schema,不能靠裸 `--yes` 放行。
- 验证:构造嵌套 Data URL、Blob URL 和超限 JSON 确认入队失败;检查正式 UI procedure / client record 不含 request/result payload;用 dry-run 和 apply 测试确认活动任务不变、终态普通提示词保留且内联媒体被替换;至少带一次非空 `--cursor-job-id``--completed-before-micros` 验证 CLI Option 编码,而不是只测首批空 cursor。
- 关联:`server-rs/crates/api-server/src/editor_generation_queue.rs``server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/api-server/src/external_generation.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -341,7 +349,7 @@
- 现象:`/api/editor/projects*`、素材库、项目资源或 layout payload 里出现数 MB 的 `data:image/*``data:video/*``data:audio/*`,刷新恢复变慢,发布入口可能 OOM / 413,素材库缩略图还可能只显示文件名。
- 原因:生成、规范图、角色图、图标 / UI spritesheet、音视频或动画帧如果直接把 Data URL / signed URL 写入 `editor_project_resource``editor_asset``editor_canvas.layers_json`,就把媒体本体塞进了项目快照;signed URL 还会过期,素材库也无法稳定换签。
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage``objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,不能在后端快照回来前自动保存。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage``objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,必须按用户隔离,而且不能在后端快照回来前自动保存。认证状态变化重跑加载 effect 时,要同步用 ref 关闭写门禁并清除 revision、pending save 和 timer;不能只等 `isProjectReady=false` 的下一次 render,否则旧 effect 会先消费 skip 标记,再把公司浏览器的旧缓存无版本 PATCH 到服务端,覆盖另一台设备的新画布布局。现役 Web 与 External layout PATCH 的 `expectedRevision` 都必填,三层门禁分别放在 autosave effect、queue 和真正发送前;session cache 即使带 revision 也只有显示权。异步 project resource 创建必须把未发请求队列按用户 / 项目隔离,并记录发起时已接受的权威快照序号;若资源响应前发生认证重载、409 恢复或生成完成快照替换,只把新资源对应图层合并进当前权威布局,禁止用历史 `snapshotLayers` 整体覆盖。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
@@ -358,7 +366,9 @@
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
- 封面是展示派生物,不是 layout 真相。常规编辑只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独增加保存频率;但用户主动返回项目页时必须先 flush 最新权威 layout,并等待同一视口封面写入 IndexedDB 和正式项目资源后再导航。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。画布存在 drawable 图层但当前取景全部离屏时,要保存纯背景封面,不能因相交列表为空而保留旧缩略图。
- 封面生成不要为同一 OSS 对象发起另一套换签缓存维度:图片、序列帧和 poster 分别复用主画布预览的 refresh key,保证封面取得相同 signed URL,由浏览器合并 in-flight 请求或命中 HTTP 缓存。通用素材上传里的 `bypassCache: true` 只用于上传后立即预览;项目封面不消费该 `src`,应在 confirm 后直接使用 object-only 结果创建项目资源。
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存、小视口居中扩大到 `1280x960`以及大视口不缩小;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
- 关联:`src/services/image-editor/editorProjectCoverCache.ts``src/components/project/ProjectGalleryView.tsx``src/components/project/ProjectCanvasCover.tsx``src/components/image-editor/useImageCanvasProjectPersistence.ts`
## 图片画布框选预览要复用源图换签缓存
@@ -3264,8 +3274,17 @@
- 现象:画布 Agent 已生成有效工具规划,却最终只保存 `ERROR max turns reached: 3`,助手文本和待确认工具卡都消失。
- 原因:八类画布工具的 `call()` 只返回待用户确认的规划结果,但 function-calling runner 在成功工具后仍继续请求 LLM,只靠 prompt 要求模型不再重试;模型连续返回工具调用直到上限后,错误结果又丢弃此前累积的输出。
- 处理:工具通过框架契约显式声明 `requires_user_confirmation`;当本批全部工具都成功且等待确认时,runner 在处理完整批次后立即返回已有助手文本和工具结果。未知工具、参数错误、hook skip、普通连续工具和不可解析响应仍继续受 `max_turns` 门禁保护。不要用单纯提高轮次上限掩盖终止条件缺失。
- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”。
- 关联:`server-rs/crates/platform-editor-agent/src/framework/run.rs``server-rs/crates/platform-editor-agent/src/framework/tool.rs``server-rs/crates/platform-editor-agent/src/agent/tools/`
- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”“多工具按数组顺序执行”“request 级 system prompt 真实进入请求”;公共 prompt 在无工具时仍必须包含 runner 所需的 JSON 响应格式,且不得宣称并发执行
- 关联:`server-rs/crates/platform-agent-harness/src/run.rs``server-rs/crates/platform-agent-harness/src/tool.rs``server-rs/crates/platform-editor-agent/src/agent/tools/`
## Agent 终态失败不能吞掉已发生的工具事实
- 现象:同一轮 prompt 中前面工具已经成功生成待确认结果,但后续工具、hook、completion 或 `max_turns` 失败后,API 只保存最后一条 `ERROR `,已执行工具和用户本轮语义从会话历史中消失。
- 原因:runner 只返回单一 `PromptError`,或者直接向 committed memory 逐步写入,无法区分“尚未发生外部工具事实,整轮可回滚”与“已发生工具事实,只能提交并闭合错误”。工具失败若被压成字符串,调用方还会丢失 `kind``retryable``fatal` 和原始 `output`
- 处理:用 `PromptRunError { error, partial_outputs }` 保留失败前输出,并将本轮 memory 先写入 staged buffer。无工具活动失败时整体回滚 staged 增量;有成功或失败工具活动时提交已发生事实,并追加 terminal error closure。api-server 按 `partial_outputs` 顺序先持久化成功工具的 `not_completed` 待确认消息,再追加 `ERROR ` 终态消息;`ToolFailed` 保留给调用方做诊断和流程决策,不伪装成成功确认卡。
- 取消边界:不能在 prompt future 内对 `agent.memory.take()` 后跨 await 持有,也不能用统一 `VecMemory` staging 绕过自定义 memory 的限长、摘要或脱敏规则。`AgentMemory::begin_staged` 必须产生行为等价、写入隔离的 `StagedAgentMemory`,成功或已有工具活动时显式 `commit()`,直接 drop 才表示回滚。外部 drop 若发生在工具完成后,guard 必须提交结果与取消闭环;若工具仍在执行,至少提交“已启动、结果未知”事实,供后续 reconcile。正式总 deadline 应作为 runner 内部 future 终止 completion;工具开始前检查 deadline,一旦开始则不能中途 drop,必须等待结果后再携带 partial outputs 收口。外层 timeout 只适合作为进程级最后保险,不能承担业务收口。
- 验证:至少覆盖“无工具 completion 失败回滚 staged 用户消息”“非 fatal 工具失败对调用方暴露 `kind/retryable/fatal/output`”“成功工具后终态失败保留 partial tool output”“有工具活动时 committed memory 末尾存在 error closure”以及“API 增量中待确认工具位于 terminal `ERROR ` 之前”。上一张图继续编辑的 prompt 测试必须断言 `latestGeneratedImage.imageId -> edit-image.object_image_id`,且最终 prompt 不含 `source_image_id`
- 关联:`server-rs/crates/platform-agent-harness/src/run.rs``server-rs/crates/platform-agent-harness/src/tool.rs``server-rs/crates/platform-editor-agent/src/agent/prompt.rs``server-rs/crates/api-server/src/editor_agent/api.rs`
## 画布 Agent 的规划请求不能关闭瞬时失败重试
@@ -3273,7 +3292,7 @@
- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client``max_retries` 硬编码为 0VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs``server-rs/crates/platform-editor-agent/src/framework/error.rs``server-rs/crates/api-server/src/state.rs``src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts``src/components/image-editor/EditorAgentConversation/MessageBubble.tsx``src/services/image-editor/editorAgentClient.ts`
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs``server-rs/crates/platform-agent-harness/src/error.rs``server-rs/crates/api-server/src/state.rs``src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts``src/components/image-editor/EditorAgentConversation/MessageBubble.tsx``src/services/image-editor/editorAgentClient.ts`
## 前端退役目录不能只靠扫描和 ignore 隔离
@@ -3355,3 +3374,10 @@
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
- 验证:`adminRoutes` 必须包含 `gray-release`admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/app/adminRoutes.ts``server-rs/crates/api-server/src/modules/admin.rs``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
## 历史钱包消费不能从最近流水或通用订单快照推算
- 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
- 原因:最近流水会低估历史总额;通用钱包快照又会被订单列表反复构造,把一次按用户聚合放大为 `订单数 × 流水数` 的重复扫描。
- 处理:历史花费只累计 `asset_operation_consume` 负向流水绝对值,退款不冲减;通过 `profile_wallet_consumption_total` 在已有投影时按主键 O(1) 累加。首次上线必须在停写维护窗口由 owner 执行全量初始化,为每个已有钱包流水的用户建立投影,不能让所有存量用户的首次正常消费各自扫描历史;维护遗漏或新用户缺行时才在首次消费或详情读取中按用户索引兜底重建一次。手动对账扫描是独立高风险操作,member 必须单独持有 `profile-wallet-consumption-reconcile`,不能因为能打开共享用户详情就自动获得。
- 验证:构造消费、退款、充值退款追回和赠送混合流水,断言只累计消费;维护初始化后正常消费只按主键累加;重复详情读取不得重复扫描或重复累计;任意 Tab 权限不能调用手动对账,同时确认充值订单列表的通用钱包快照没有新增历史流水扫描。
File diff suppressed because one or more lines are too long
@@ -1,12 +1,12 @@
# 后台管理多账号与 Tab 访问权限方案
更新时间:`2026-07-23`
更新时间:`2026-07-24`
## 1. 文档定位
本文定义陶泥儿后台从单一环境变量管理员扩展为“1 个 owner 引导账号 + 多个 member 持久账号”的编码契约,并为每个一级 Tab 建立前后端一致的访问权限。
本次只增加后台管理员账号与整页访问权限,不引入页面内按钮级、字段级或只读权限。正式实现必须同时完成前端导航过滤和后端 API 鉴权;前端过滤只改善体验,不能作为安全边界。
后台权限默认仍以整页 Tab 为粒度;只有会触发权威钱包全量扫描的“手动对账用户历史花费”作为明确例外,使用独立操作权限,不随任何 Tab 自动授予。正式实现必须同时完成前端按钮过滤和后端 API 鉴权;前端过滤只改善体验,不能作为安全边界。
## 2. 当前基线与目标
@@ -17,8 +17,8 @@
1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。
2. owner 始终拥有全部 15 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。
3. owner 可以创建、修改、启停 membermember 保存在 SpacetimeDB 私有表 `admin_account`
4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限
5. member JWT 每次请求都重新读取当前账号并校验 `enabled``token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。
4. member 按一级 Tab 分配常规权限;获得一个 Tab 权限即获得该页面内常规读写能力。历史花费手动对账必须另行授予 `profile-wallet-consumption-reconcile`,任何 Tab 都不隐式包含
5. member JWT 每次请求都重新读取当前账号并校验 `enabled``token_version`、实时 Tab 权限和独立操作权限,权限、密码或启停变更应立即让旧 JWT 失效。
## 3. 角色与不可变规则
@@ -26,18 +26,18 @@
- owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME``GENARRATIVE_ADMIN_PASSWORD`
- owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。
- owner 始终拥有本文列出的全部 15 个可分配权限,不能在前端取消,也不从数据库加载权限。
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`
- owner 始终拥有本文列出的全部 15 个 Tab 权限和全部独立操作权限,不能在前端取消,也不从数据库加载权限。
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS``ADMIN_ACTION_PERMISSIONS`,不能写入 member 的权限 JSON
- owner 会话返回 `accountRole = "owner"``roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。
- owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。
### 3.2 member
- member 只来自 `admin_account`,不新增第二套环境变量账号。
- member 会话返回 `accountRole = "member"``roles = ["admin", "member"]`当前实时 `tabPermissions`
- member 会话返回 `accountRole = "member"``roles = ["admin", "member"]`当前实时 `tabPermissions``actionPermissions`
- member 永远不能访问账号管理页面或账号管理 API,也不能给自己或他人分配 `accounts`
- member 的一个一级 Tab 权限覆盖该页面的查询、创建、修改、启停、退款等全部现有操作,不拆成 `read` / `write`
- 页面内二级 Tab、筛选视图、抽屉、弹窗和共享详情弹窗继承触发它的一级 Tab 权限,不另设 permission id
- member 的一个一级 Tab 权限覆盖该页面的常规查询、创建、修改、启停、退款等操作,不拆成通用 `read` / `write`
- 页面内二级 Tab、筛选视图、抽屉、弹窗和共享详情弹窗默认继承触发它的一级 Tab 权限;历史花费手动对账是唯一独立高风险操作例外,无权限时共享用户详情不展示按钮,直接请求仍由后端返回 403
## 4. 权限标识
@@ -61,9 +61,15 @@
| `editor-showcase` | 精选审核 | `#editor-showcase` |
| `editor-assets` | 素材查询 | `#editor-assets` |
权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。
Tab 权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。
后续新增一级 Tab 时,必须在同一次改动中更新
`ADMIN_ACTION_PERMISSIONS` 是独立操作权限闭合集合,当前只有
| permission id | 操作 | 授权边界 |
| --- | --- | --- |
| `profile-wallet-consumption-reconcile` | 手动对账用户历史花费 | owner 默认拥有;member 必须在账号管理中单独勾选,不要求同时持有特定 Tab |
独立操作权限保存在 `action_permissions_json`,响应为 `actionPermissions`;未知值必须拒绝。后续新增一级 Tab 时,必须在同一次改动中更新:
- shared-contracts 的 `ADMIN_TAB_PERMISSIONS`
- admin-web 的路由定义、权限标签和第一可访问项顺序。
@@ -80,13 +86,14 @@
| `username` | `String` | `unique`;登录名,创建后不可修改;按 `trim + ASCII lowercase` 规范化 |
| `display_name` | `String` | 展示名,去除首尾空白后 1 至 64 字符 |
| `password_hash` | `String` | Argon2id PHC 字符串;只在内部登录查询中返回给 api-server,永不进入 HTTP DTO、日志或前端状态 |
| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` |
| `tab_permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` |
| `enabled` | `bool` | 是否允许登录和继续使用现有 JWT |
| `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` |
| `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject |
| `updated_by` | `String` | 最近更新者后台 subject;当前只能是 owner subject |
| `created_at` | `Timestamp` | 创建时间,使用 `ctx.timestamp` |
| `updated_at` | `Timestamp` | 最近更新时间,使用 `ctx.timestamp` |
| `action_permissions_json` | `Option<String>` | 既有表末尾追加;旧行默认 `None` 并按 `[]` 读取,只允许第 4 节独立操作权限 |
账号规则:
@@ -94,7 +101,7 @@
- owner 用户名属于保留名称。创建 member 时必须同时与当前规范化后的 owner 用户名比较并拒绝冲突,不能只依赖 `admin_account.username` 唯一索引。
- 密码明文只存在于登录、创建和改密请求生命周期内;限制为 6 至 128 个字符,并复用 `platform-auth` 的 Argon2id 哈希与校验能力。Argon2id 必须在 blocking 任务中执行,api-server 通过有界信号量限制同时 hash / verify 数量,不得占用 Tokio worker 或无界堆积高成本任务。
- 不提供物理删除 API。离职或停用通过 `enabled = false` 完成,以保留 `created_by``updated_by` 和账号标识。
- `display_name` 单独变化只更新 `updated_by``updated_at`,不要求递增 `token_version`;权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。
- `display_name` 单独变化只更新 `updated_by``updated_at`,不要求递增 `token_version`Tab 权限、独立操作权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。
- `u64` 版本到达上限时更新失败关闭,不能回绕。
## 6. SpacetimeDB 与 facade 边界
@@ -158,9 +165,10 @@ owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或
```text
accountRole: "owner" | "member"
tabPermissions: string[]
actionPermissions: string[]
```
owner 返回全部 15 个 permission idmember 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
owner 返回全部 15 个 Tab permission id 和全部独立操作权限member 返回数据库中的两组实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航和操作按钮
后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-<uuid>`api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。
@@ -168,14 +176,15 @@ owner 返回全部 15 个 permission idmember 返回数据库中的实时规
在统一 `require_admin_auth` 之后增加可复用的权限守卫,支持:
- `require_admin_permission(permission)`owner 自动通过;member 必须包含该 permission。
- `require_any_admin_permission([permission...])`owner 自动通过;member 至少包含一个,用于共享 API。
- `require_admin_tab_permission(permission)`owner 自动通过;member 必须包含该 Tab permission。
- `require_any_admin_tab_permission([permission...])`owner 自动通过;member 至少包含一个,用于共享读取 API。
- `require_admin_action_permission(permission)`owner 自动通过;member 必须包含该独立操作 permission。
- `require_admin_owner`:只接受服务端确认的 owner。
返回语义统一如下:
- `401 Unauthorized`:token 缺失、无效、过期,member 不存在、被停用或 `token_version` 过期。
- `403 Forbidden`:会话有效但缺少目标 Tab 权限,或 member 请求 owner-only API。
- `403 Forbidden`:会话有效但缺少目标 Tab / 独立操作权限,或 member 请求 owner-only API。
- 前端收到 `401` 清除本地 token 并回到登录页;收到 `403` 不应伪装成掉线,应刷新 `/me` 权限并跳转到第一可访问项或零权限空态。
## 9. API-to-Tab 权限矩阵
@@ -223,6 +232,8 @@ owner 返回全部 15 个 permission idmember 返回数据库中的实时规
| `POST` | `/admin/api/profile/recharge-refunds/register` | `recharge-orders` |
| `POST` | `/admin/api/profile/recharge-refunds/manual-review/resolve` | `recharge-orders` |
| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets` |
| `POST` | `/admin/api/profile/users/reconcile-consumption` | 独立操作权限 `profile-wallet-consumption-reconcile` |
| `POST` | `/admin/api/profile/users/initialize-consumption-projections` | owner-only 维护窗口操作 |
| `POST` | `/admin/api/profile/wallet-restriction` | `recharge-orders` |
| `GET` | `/admin/api/accounts` | owner-only |
| `POST` | `/admin/api/accounts` | owner-only |
@@ -249,6 +260,7 @@ accounts: Array<{
username,
displayName,
tabPermissions,
actionPermissions,
enabled,
tokenVersion,
createdBy,
@@ -270,6 +282,7 @@ accounts: Array<{
displayName: string,
password: string,
tabPermissions: string[],
actionPermissions: string[],
enabled?: boolean
}
```
@@ -285,11 +298,12 @@ accounts: Array<{
displayName: string,
password?: string,
tabPermissions: string[],
actionPermissions: string[],
enabled: boolean
}
```
`username``account_id` 不可修改。更新请求完整提交显示名称、Tab 权限和启停状态;密码省略表示不修改,空字符串密码作为非法参数拒绝。api-server 只在提供新密码时生成新 hash。procedure 比较有效变化,在权限、密码或启停任一变化时只递增一次 `token_version`,并在同一事务写入账号字段、`updated_by``updated_at`。响应仍不返回密码或 hash。
`username``account_id` 不可修改。更新请求完整提交显示名称、Tab 权限、独立操作权限和启停状态;密码省略表示不修改,空字符串密码作为非法参数拒绝。api-server 只在提供新密码时生成新 hash。procedure 比较有效变化,在权限、密码或启停任一变化时只递增一次 `token_version`,并在同一事务写入账号字段、`updated_by``updated_at`。响应仍不返回密码或 hash。
## 11. admin-web 行为
@@ -313,7 +327,7 @@ accounts: Array<{
### 11.3 账号管理页
- 权限编辑器展示 15 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`
- 权限编辑器分为 15 个 Tab checkbox 和独立操作权限区;当前独立区只显示“手动对账用户历史花费”。不能展示或提交 `accounts`
- 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。
- 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。
- 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。
@@ -323,17 +337,17 @@ accounts: Array<{
建议按以下边界落地,避免在前端或 `api-server` 重新发明持久化规则:
- `shared-contracts``ADMIN_TAB_PERMISSIONS`、扩展后的 `AdminSessionPayload`、账号管理 request/response DTO。
- `shared-contracts``ADMIN_TAB_PERMISSIONS``ADMIN_ACTION_PERMISSIONS`扩展后的 `AdminSessionPayload`、账号管理 request/response DTO。
- `spacetime-module`:私有表、输入类型、typed procedures、唯一性与版本递增事务。
- `spacetime-client`:生成绑定、row mapper、登录查询与账号管理 facade。
- `api-server`owner/member 登录编排、Argon2id、逐请求账号解析、权限 middleware、账号管理 handlers。
- `apps/admin-web`:权限感知路由、hash 回落、零权限空态、owner-only 账号管理页。
不能将 `permissions_json` 的解析与授权只放在前端;不能让 admin-web 直连 SpacetimeDB;不能用进程内 member 列表替代 `admin_account`
不能将 Tab / 独立操作权限 JSON 的解析与授权只放在前端;不能让 admin-web 直连 SpacetimeDB;不能用进程内 member 列表替代 `admin_account`
## 13. 迁移、绑定与发布顺序
`admin_account` 是新增私有表,没有旧数据回填。原环境变量 owner 不入表,因此迁移不创建 owner 行
`admin_account` 已是私有表;本次只在表结构体最后追加带 `None` 默认值的 `action_permissions_json`,旧 member 自动按空独立权限读取。原环境变量 owner 不入表,并始终由 api-server 合成全部权限
实现 schema 后必须:
@@ -380,16 +394,17 @@ spacetime publish <database> \
- member 私表不能被普通 SpacetimeDB identity 查询或调用 procedure;只有 runtime service identity 可读写。
- 创建重复规范化用户名、owner 保留用户名、未知权限或 `accounts` 权限均失败。
- GET/POST/PUT 账号 API 任何响应和日志都不包含明文密码或 `password_hash`
- 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
- Tab 权限、独立操作权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
- member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。
- API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。
- 两个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。
- owner-only 账号 API 对任意 member 返回 403,即使其 `permissions_json` 被污染为包含 `accounts`
- 历史花费手动对账对仅持有任意 Tab 的 member 返回 403;只持有独立操作权限时允许调用;全量投影初始化始终 owner-only
- owner-only 账号 API 对任意 member 都返回 403,即使其 Tab 或独立权限 JSON 被污染为包含 `accounts`
### 14.2 前端
- owner 看到 15 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。
- 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射
- 常规二级 Tab、弹窗和写操作继承一级权限;历史花费对账按钮只在用户详情返回 `canReconcileConsumption=true` 时显示
- 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。
- 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。
- 零权限 member 登录后显示空态,不回落 Dashboard、不发送 Dashboard 或其它业务请求,并可正常退出。
@@ -201,6 +201,8 @@ controller 配置:
- `editor_video_generation`:画布视频生成和视频素材快速编辑。
- `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。
画布 Agent 的 `generate-character-animation` 确认后复用 `editor_character_animation_generation`,保持既有定价、dedupe key、lease、worker、退款和画布回填语义。`split-elements` 不调用外部 provider,直接复用站内连通域切片及资源持久化,不创建零价 `external_generation_job`;它的完成事实直接写入 Agent OSS 消息和项目快照。
画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset``editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。
角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。
@@ -59,7 +59,7 @@
- SpacetimeDB schema guard 比较当前工作树与基线提交时,两侧都必须分别读取各自 `Cargo.toml``lib.path`,再沿 `mod` / `#[path]` 只扫描该快照 crate root 可达的 schema;不得递归扫描整个 `src/`,否则原位保留的旧源码会与现役历史数据壳产生假 accessor 重复。
- `module-runtime` 仍是账号、钱包、公共设置、追踪和 feature gate 的现役领域 crate;其混合源码中的 `CreationEntry*`、旧公开作品、旧存档 / 浏览历史 / 游玩统计 DTO、command、mapper 和规则必须以编译条件退出,且不再依赖只为旧创作契约存在的 `shared-contracts`。历史 schema 只继续编译 `RuntimeBrowseHistoryThemeMode` 六个变体和完整保序的 `RuntimeProfileWalletLedgerSourceType` 等持久化 ABI,不保留围绕这些类型的旧业务实现。
- 纯模板 crate 和专属运行态 crate 不属于 workspace members、default members 或任何在运 crate 的依赖图;源码目录保持原样。
- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。
- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。后续抽出的 `platform-agent-harness` 是无旧玩法依赖的通用 JSON function-calling 底座,不得依赖、复用或重新挂回本条退役 crate。
- `platform-auth` 不再编译 runtime guest token`platform-wechat` 不再编译旧生成结果订阅服务,只保留现役认证和支付协议。
## 验收
@@ -24,7 +24,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
- HTTP 与运维入口:`api-server``pingora-gateway``server-manager-panel`
- 现役领域模块:`module-ai``module-assets``module-auth``module-editor-agent``module-runtime``module-runtime` 继续承载账号、钱包、公共设置、追踪、功能门禁等现役平台领域能力;该 crate 名称不代表旧玩法 runtime 路由仍在运行。
- 平台副作用:`platform-agent``platform-auth``platform-audio``platform-hyper3d``platform-image``platform-llm``platform-matting``platform-oss``platform-speech``platform-wechat`
- 平台副作用:`platform-agent-harness``platform-editor-agent``platform-auth``platform-audio``platform-hyper3d``platform-image``platform-llm``platform-matting``platform-oss``platform-speech``platform-wechat`已退役 Creative Agent 的旧 `platform-agent` 只保留历史源码,不属于现役 workspace 或依赖图。
- 共享层:`shared-contracts``shared-kernel``shared-logging`
- SpacetimeDB`spacetime-client``spacetime-module`
- 测试支撑:`tests-support`
@@ -76,11 +76,16 @@ npm run check:server-rs-ddd
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId``x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。
- `platform-agent-harness` 只承载与具体业务无关的 JSON function-calling 协议、工具 schema 注入、memory、hook、typed tool、轮次保护和待用户确认终止语义;`platform-editor-agent` 在其上叠加画布专属 LLM profile、system prompt、跨工具路由规则、图片上下文与八类生成工具。公共 harness 不依赖画布 DTO、计费、OSS、Axum 或 external job,也不得复用已退役的旧 `platform-agent`。harness 失败契约固定为 `PromptRunError { error, partial_outputs }``partial_outputs` 显式携带失败前已产生的助手文本、成功工具和结构化失败工具输出。`ToolFailure``kind``retryable``fatal` 及工具原始 `output` 必须对 harness 调用方可见,不得在公共层压成字符串或擅自丢弃。
- prompt runner 对每个调用通过 `AgentMemory::begin_staged` 创建行为等价、写入隔离的 `StagedAgentMemory` 事务:限长、摘要、脱敏或持久化 memory 的 append 语义必须在本轮模型请求前生效,不得统一降级成 `VecMemory`。成功结束或已发生工具活动时必须显式调用 staged `commit()`,直接 drop 表示回滚。无工具活动的 completion、hook、解析或 `max_turns` 失败丢弃 staged transaction;已有成功或失败工具活动时在末尾追加 terminal error closure 后提交。外部 future drop / abort 若发生在工具完成后,必须提交工具结果与取消闭环;若工具仍在执行,则提交“已启动、结果未知”事实与取消闭环,供后续 reconcile,不能假装工具没有发生。
- 画布 handler 的 18 分钟总 deadline 通过 runtime 提供的 deadline future 下沉到公共 runnercompletion await 可被 deadline 终止;effectful tool 在开始前检查 deadline,开始后不被中途 drop,返回后再携带结果收口为 `PromptRunError`。禁止用外层 timeout 直接 drop 整个 prompt future 并伪造空 `partial_outputs`
- `spacetime-module``editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return``list_editor_agent_conversations_and_return``get_editor_agent_conversation_and_return``touch_editor_agent_conversation_and_return``delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 `max_tokens`。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408``429``5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
- planning prompt 注入的 `latestGeneratedImage.imageId` 只能映射到 `edit-image.object_image_id``source_image_id` 不是现役 `edit-image` schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`
- api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。
- `/messages/{messageId}/confirm``/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
- 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。
- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource``editor_asset`、结构化画布表或 `external_generation_job` 的业务真相。未激活结构化存储的 canvas 才继续以 `editor_canvas.layers_json` 作为 legacy 布局真相。
@@ -216,7 +221,7 @@ npm run check:server-rs-ddd
22. 后台主动退款只支持 `wechat_mp``wechat_jsapi``wechat_h5``wechat_native` 普通 V3 泥点订单。`api-server` 必须先按正式支付渠道和商品类型拦截不支持的订单,再做微信支付订单查单预检,然后调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。`wechat_mp_virtual`、历史非正式渠道值、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须在调用普通 V3 provider 前 fail-closed。
23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。重试只有在订单、`out_refund_no`、退款金额、管理员和归一化原因全部与原 hold 一致时才可复用,任一不一致都按幂等内容冲突 fail-closed,不能用新原因调用微信后保留旧审计。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。
24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。
25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
25. 管理员充值订单、用户详情、历史花费手动对账、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。用户详情中的历史花费泥点数读取 `profile_wallet_consumption_total` 投影;已有投影时,每次 `asset_operation_consume` 负向流水落账在同一事务内按主键 O(1) 原子累加,退款不回减,充值退款追回、余额重置、赠送和 hold 均不计入。首次上线必须在停止业务写入的维护窗口内,由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户初始化存量投影;接口成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费按该用户索引一次性重建(当前消费流水已经在同一事务中,不能重复加本次金额);钱包详情首次读取也保留同一按用户兜底。`POST /admin/api/profile/users/reconcile-consumption` 是显式手动对账入口:owner 始终可用,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限,任何一级 Tab 都不自动附带;用户详情只在后端返回 `canReconcileConsumption=true` 时展示按钮。操作经二次确认后扫描该用户全部权威钱包流水、比较并校准投影,同时记录管理员和对账时间。退款人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement钱包冻结或消费累计。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
## 创作入口泥点扣费契约
@@ -518,8 +523,8 @@ npm run check:server-rs-ddd
- Rust 结构体:`AdminAccount`
- 源码:`server-rs/crates/spacetime-module/src/admin_account_storage.rs`
- 说明:后台 member 私有账号表,保存规范化用户名、展示名、Argon2id 密码摘要、一级 Tab 权限 JSON、启停状态、会话版本和创建 / 更新审计字段。原环境变量管理员作为虚拟 owner,不写入该表。账号查询与写入 procedure 只接受 runtime service identityHTTP 列表和写响应不返回密码摘要。
- 索引:`account_id` 为主键,`username` 为唯一登录名;权限、密码或启停状态发生变化时在同一事务递增 `token_version`,使旧 JWT 下一次请求立即失效。
- 说明:后台 member 私有账号表,保存规范化用户名、展示名、Argon2id 密码摘要、一级 Tab 权限 JSON、独立操作权限 JSON、启停状态、会话版本和创建 / 更新审计字段。`action_permissions_json` 是既有表末尾新增的可选字段,旧行默认空数组语义;当前唯一独立操作权限为 `profile-wallet-consumption-reconcile`原环境变量管理员作为虚拟 owner,不写入该表。账号查询与写入 procedure 只接受 runtime service identityHTTP 列表和写响应不返回密码摘要。
- 索引:`account_id` 为主键,`username` 为唯一登录名;Tab 权限、独立操作权限、密码或启停状态发生变化时在同一事务递增 `token_version`,使旧 JWT 下一次请求立即失效。
### `editor_agent_conversation`
@@ -907,6 +912,12 @@ npm run check:server-rs-ddd
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 说明:账号钱包流水表。`created_at` 表示钱包事务实际结算时间,列表先按当前余额反向校验 `balance_after - amount_delta` 的结算链,再以该时间倒序兜底,避免支付回调或退款重放延迟时出现余额顺序倒置;支付平台确认时间继续保存在充值订单 `paid_at``metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`
### `profile_wallet_consumption_total`
- Rust 结构体:`ProfileWalletConsumptionTotal`
- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`
- 作用:用户历史消费泥点累计投影。已有投影时,只在 `asset_operation_consume` 负向流水成功落账时同事务按主键 O(1) 累加,退款不回减;用户详情按 `user_id` 主键读取。首次上线在停写维护窗口由 owner 管理接口调用 `admin_initialize_profile_wallet_consumption_projections_and_return`,全表扫描一次,为所有已有钱包流水的用户建立存量投影。维护遗漏或新用户缺行时,首次消费与管理员钱包详情读取都可按 `profile_wallet_ledger.user_id` 索引兜底重建一次;消费事务中的重建结果已包含当前流水,不再额外叠加当前金额。持有独立操作权限的管理员显式触发手动对账时,`admin_reconcile_profile_wallet_consumption_and_return` 扫描该用户权威流水并覆盖校准投影,记录 `last_reconciled_by_admin_user_id``last_reconciled_at`
### `asset_operation_wallet_settlement`
- Rust 结构体:`AssetOperationWalletSettlement`
@@ -145,7 +145,17 @@ spacetime sql <database> "SELECT * FROM profile_recharge_refund_bill_checkpoint"
核对规则:部分退款时原订单保持 `paid`;累计退款等于订单金额后才为 `refunded`,但 `paid_at` 继续保留,因此不会恢复首充资格。泥点退款只回收普通永久泥点;每日免费泥点和会员周期泥点不动。永久泥点不足时 `recovery_status=shortfall``unrecovered_points>0``wallet_frozen=true`,正式钱包消费在欠款清零前 fail-closed;会员订单统一为 `manual_review`,不得自动缩短有效期或扣周期泥点。
后台充值订单退款必须通过管理员鉴权接口执行,不得从数据库页面直接改表:列表 `GET /admin/api/profile/recharge-orders`、用户详情 `GET /admin/api/profile/users/detail`、预检 `POST /admin/api/profile/recharge-refunds/preview`、执行 `POST /admin/api/profile/recharge-refunds/execute`、应急退款号登记 `POST /admin/api/profile/recharge-refunds/register`、人工冻结/解冻 `POST /admin/api/profile/wallet-restriction`。预检返回微信支付状态、本地累计退款、剩余可退金额、预计追回泥点、钱包总额、可消费余额、活动占用和退款欠账;只有预检允许且二次确认后才提交退款。提交使用稳定 `requestId`,接口超时后重试必须复用同一值。若返回“退款处理中”,先查同一 `out_refund_no`,不要换号再次发起。商户平台应急退款完成后,在后台登记原 `out_refund_no` 触发验签查单;微信已退款但缺少退款号时等待 T+1 账单,不得凭截图或支付订单 `REFUND` 状态直接手写退款事实。
后台充值订单退款必须通过管理员鉴权接口执行,不得从数据库页面直接改表:列表 `GET /admin/api/profile/recharge-orders`、用户详情 `GET /admin/api/profile/users/detail`历史花费手动对账 `POST /admin/api/profile/users/reconcile-consumption`、存量消费投影初始化 `POST /admin/api/profile/users/initialize-consumption-projections`预检 `POST /admin/api/profile/recharge-refunds/preview`、执行 `POST /admin/api/profile/recharge-refunds/execute`、应急退款号登记 `POST /admin/api/profile/recharge-refunds/register`、人工冻结/解冻 `POST /admin/api/profile/wallet-restriction`用户详情返回的 `historicalConsumedPoints` 来自 `profile_wallet_consumption_total`,表示退款不冲减的历史总消费;已有投影的正常消费只做按主键 O(1) 原子累加,缺行时按该用户钱包流水索引兜底重建一次,不得用只返回最近 50 条的钱包流水列表在 BFF 或前端重算。手动对账是独立操作权限:owner 始终拥有,member 必须在账号管理中单独勾选“手动对账用户历史花费”,任意 Tab 权限都不隐式授予;接口经二次确认后才扫描该用户全部权威流水、校准投影并记录管理员和时间。投影初始化接口只允许 owner,必须在首次上线的停写维护窗口执行并成功后再恢复业务流量;它扫描全部权威钱包流水并初始化或校准全部消费投影。维护遗漏的存量缺行仍由用户详情首次读取按用户索引兜底回填。预检返回微信支付状态、本地累计退款、剩余可退金额、预计追回泥点、钱包总额、可消费余额、活动占用和退款欠账;只有预检允许且二次确认后才提交退款。提交使用稳定 `requestId`,接口超时后重试必须复用同一值。若返回“退款处理中”,先查同一 `out_refund_no`,不要换号再次发起。商户平台应急退款完成后,在后台登记原 `out_refund_no` 触发验签查单;微信已退款但缺少退款号时等待 T+1 账单,不得凭截图或支付订单 `REFUND` 状态直接手写退款事实。
首次发布消费投影时,先停止业务写入并确认 api-server 与新 SpacetimeDB module 已就绪,再使用当前 owner 登录获得的短期 token 执行:
```bash
curl -fsS -X POST \
-H "Authorization: Bearer ${ADMIN_BEARER_TOKEN}" \
"https://<API 域名>/admin/api/profile/users/initialize-consumption-projections"
```
响应中的 `scannedLedgerCount` 是扫描流水数,`projectedUserCount` 是已存在钱包流水并完成投影的用户数;只有请求成功且返回 `ok=true` 后才恢复业务流量。该接口允许幂等重跑,但每次都会全表扫描,只能在维护窗口由 owner 执行,不得加入普通定时任务或页面自动请求。
正式落账上线前已经被旧 debug handler 返回成功的退款回调不会因部署新版本自动重放。已知 `out_refund_no` 的历史退款应由具备真实商户凭据的受控服务端操作先调用单笔退款查询,验签后写入同一 observation 事务;未知的商户平台退款等待次日交易账单发现。自动账单按分片补扫微信 API 可查询的近 90 天,超出窗口的历史退款需从商户平台导出核对后逐笔受控查单补录,不能直接把商户平台截图或 CSV 行当作退款终态,也不能开放匿名或普通用户补录 / 退款入口。
@@ -115,7 +115,7 @@ V3 退款入口在正式落账的同时保留可用于真实联调的安全诊
4. 已经发生的外部退款没有 hold 时沿用现有结算:回收当前未被其他 hold 占用的永久泥点,余额不足部分继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 作为唯一退款欠账真相,状态为 `shortfall` 并限制消费。后续永久泥点到账后在同一钱包事务内按最早退款单自动继续追回;每日免费和会员周期泥点不参与。不得再建一张平行 debt 表重复累计欠账。
5. 人工钱包冻结单独使用 `profile_wallet_manual_restriction`,保存当前是否冻结、原因、操作管理员和操作时间。普通消费同时检查人工冻结、退款欠账和活动 hold;解除人工冻结不能解除仍存在的退款欠账。
6. 后台 API 统一位于管理员鉴权下:充值订单列表与详情、用户详情、退款预检、退款执行、应急 `out_refund_no` 登记、钱包人工冻结/解冻。任何接口都不得返回原始手机号、商户私钥、APIv3 Key、微信签名、回调密文或账单下载 URL。
7. 通用用户详情通过内部 `user_id` 或陶泥号解析同一认证用户,展示头像、昵称、陶泥号、内部 ID、脱敏手机号、登录/微信绑定状态、钱包总额、可消费余额、活动占用、退款欠账、冻结原因和最近充值订单。后台语义明确的用户 ID 或陶泥号旁统一使用图标按钮打开同一个弹窗,不复制页面级用户查询逻辑。
7. 通用用户详情通过内部 `user_id` 或陶泥号解析同一认证用户,展示头像、昵称、陶泥号、内部 ID、脱敏手机号、登录/微信绑定状态、钱包总额、可消费余额、活动占用、退款欠账、历史花费、冻结原因和最近充值订单。历史花费读取 `profile_wallet_consumption_total`:首次上线在停写维护窗口由 owner 全量初始化存量投影,已有投影的消费落账按主键 O(1) 原子累加,退款不冲减;维护遗漏或新用户缺行时,在首次消费或详情读取中按用户全部流水兜底回填一次。不得用最近 50 条账单列表近似。手动对账不继承任意 Tab,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限;无权限时用户详情不展示对账按钮。后台语义明确的用户 ID 或陶泥号旁统一使用图标按钮打开同一个弹窗,不复制页面级用户查询逻辑。
真实联调时显式开启该模块的 debug 日志:
@@ -45,7 +45,7 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP
所有用户布局写入必须携带读取快照时获得的 `expectedRevision`。procedure 在事务中校验 canvas 当前 revision;不一致返回 `409`,前端应重载后端最新快照,不能只换上新 revision 就原样重放冲突前的整包布局。
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后权威快照暂时加载失败时保留 pending save 并定时重新进入冲突恢复,不能等待用户再次拖动画布才恢复保存
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409`冲突布局立即作废,权威快照暂时加载失败时保留冲突恢复状态并定时只重试 GET,不能把旧布局换上新 revision 后重放,也不能等待用户再次拖动画布才恢复。
本次结构化 V1 先保留旧 `{ viewport, layers }` PATCH 作为兼容输入。legacy canvas 即使携带 `expectedRevision` 也只做 CAS legacy 保存,不允许用户写入绕过 migration operator 直接激活 structured;只有已完成 backfill / activate、且 active 迁移记录的 revision / hash / 数量 / 资源引用校验均通过时,后端才在单个事务内把兼容输入拆成 layer / dialog 行并递增一次 revision。旧无 CAS procedure 不得写 structured canvas。V1 快照从 typed 列重组,`item_json / dialog_json` 只保留最大 512 KiB 的未结构化扩展字段。自包含本地图片序列在 active canvas 中只能继续保存已回填且 `layerId / resourceId / sourceType / item_json` 语义完全一致的原行;允许修改几何、层级、分组、显隐等 typed 布局字段。前端序列化按正常资源真相边界省略 `assetKind / generationInputs` 时,后端只从既有结构化行恢复这两个冻结字段再校验;显式修改仍拒绝。active 路径不再经过 legacy 元数据清洗,拒绝新增缺资源序列或改写既有帧、预览、prompt 和生成扩展。后续将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation;在此之前 2 MiB 仍是兼容整包入口的上限。
@@ -73,7 +73,7 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
若全量 backfill 审计发现普通图层缺少 `editor_project_resource`,先使用定向资源修复入口:`npm run spacetime:editor-canvas-resources:repair -- --database <db> --server <server> --plan-file </absolute/outside-repo/repair-plan.json>`。plan 必须是仓库外、当前用户持有、权限严格为 `0600` 的普通文件;每个 canvas 绑定 owner / project、expected revision、`editor_canvas.layers_json` 与 legacy `editor_project.layers_json` 两份原始字节 SHA-256,以及精确 layer/resource/sourceResourceId 或 asset_object 修复动作。默认逐 canvas dry-runapply 必须追加 `--apply --confirm-plan-sha256 <dry-run 输出>`,成功后脚本自动以同一 plan 再 dry-run,并要求全部返回 `already_repaired`
`repair_editor_canvas_resources_and_return` 只允许 migration operator 调用,并且只修尚无迁移记录的 legacy canvas。图片动作仅在缺失旧 resourceId、精确旧 sourceResourceId、同工程唯一现存资源、layout 资源元数据与 private asset_object owner/objectKey/task 谱系全部一致时,替换 `resourceId` 并删除重复 `sourceResourceId`plan 中 `expected_layout_source_resource_id` 为字符串时必须与 layout 原值完全一致,显式为 `null` 时则要求 layout 中该字段明确存在且值为 JSON `null`,不得把字段缺失与 `null` 混同。音频动作仅在原 resourceId 全局不存在、图层与 private asset_object 的 owner/objectKey/source job/entity/content type/长度全部匹配时,恢复 `420x120``sound-effect` / `background-music` 项目资源行;资源 `asset_kind` 分别映射 asset_object 的 `editor_sound_effect` / `editor_background_music`。apply 在一个事务内插入资源、同步两份 legacy layout、递增一次 canvas revision;任一 guard 失败整画布回滚。plan、脚本输出、测试和文档均不得包含生产真实 ID。
`repair_editor_canvas_resources_and_return` 只允许 migration operator 调用,并且只修尚无迁移记录的 legacy canvas。图片动作仅在缺失旧 resourceId、精确旧 sourceResourceId、同工程唯一现存资源、layout 资源元数据与 private asset_object owner/objectKey/task 谱系全部一致时,替换 `resourceId` 并删除重复 `sourceResourceId`。音频动作仅在原 resourceId 全局不存在、图层与 private asset_object 的 owner/objectKey/source job/entity/content type/长度全部匹配时,恢复 `420x120``sound-effect` / `background-music` 项目资源行;资源 `asset_kind` 分别映射 asset_object 的 `editor_sound_effect` / `editor_background_music`。apply 在一个事务内插入资源、同步两份 legacy layout、递增一次 canvas revision;任一 guard 失败整画布回滚。plan、脚本输出、测试和文档均不得包含生产真实 ID。
## 5. 兼容与回滚
@@ -1,6 +1,6 @@
# 画布Agent对话面板
日期:`2026-07-20`
日期:`2026-07-29`
## 定位与边界
@@ -10,15 +10,17 @@
## 能力范围
对话 Agent 可通过 function-calling 触发以下类工具,全部复用既有计费收口接口
对话 Agent 可通过 function-calling 触发以下类工具;生成类工具复用既有计费收口,确定性拆图即时执行且不计费
| 工具 | 后端接口 |
| --- | --- |
| 生成图片 | `POST /api/editor/images/generations` |
| 修改图片(基于附件/画布素材) | `POST /api/editor/images/edits` |
| 生成角色形象 | 既有角色形象生成入口对应接口 |
| 基于角色图片生成角色动作 | `POST /api/editor/character-animations/generations` |
| 生成图标素材 | `POST /api/editor/icon-spritesheets/generations` |
| 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 |
| 拆分已有 UI / 图集元素 | 复用 `POST /api/editor/icon-spritesheets/slices` 的确定性连通域拆分逻辑 |
| 生成视频 | `POST /api/editor/videos/generations` |
| 生成游戏音效 | `POST /api/editor/audios/sound-effects/generations` |
| 生成背景音乐 | `POST /api/editor/audios/background-music/generations` |
@@ -28,13 +30,15 @@
- 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。
- 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`
- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。
- `split-elements` 只接受当前画布项目中的 `ui-design``icon-spritesheet` 资源,直接执行既有确定性切片、项目资源 / 素材库持久化和画布回填;不调用生成模型、不进入定价、不展示确认卡,也不创建 `external_generation_job`
- function-calling 的 JSON Schema 必须与参数默认值和运行时校验保持一致,不能只在 description 中提示会被运行时拒绝的组合。`generate-ui-design` 固定 `gpt-image-2`,因此 `image_size` 只暴露 `1K / 2K`;其它可切换图片模型的工具通过共享条件 schema 在显式选择 `gpt-image-2` 时同样把 `image_size` 限制为 `1K / 2K`,省略模型时仍按默认 nanobanana2 允许 `0.5K``generate-video` 省略 `model` 时按默认 `seedance2.0-fast` 约束 `resolution``480p / 720p`,显式选择其它模型时仍使用其现有分辨率范围。运行时强类型校验继续作为最终防线。
- 图层操作及其他未注册的画板功能第一期不进入对话工具面,仍走现有面板。
## 当前分支落地状态
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及九类收费图片 / 音视频工具和一类免费即时拆图工具
- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。
- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用
- 未落地:附件弹窗末尾上传格。收费生成继续以 `external_generation_job` 作为后台任务队列真相;即时拆图在当前消息请求内完成并把 `status=completed` 与轻量图片引用直接写入 OSS 对话消息
## 会话与持久化
@@ -59,8 +63,13 @@
- 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。
- 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。
- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。
- 底部消息输入框随输入内容从单行高度自动增长,最大高度为
128px;输入框及其 Enter 提交、原生自适应和兼容降级统一封装在独立 `EditorAgentDraftTextarea` 组件中。支持 `field-sizing: content` 的浏览器使用原生内容尺寸自适应,不支持该属性的旧 Safari / iOS WebView 使用前端测量降级,并在宽度变化时重新计算换行高度。内容超过最大高度后停止增长并启用内部纵向滚动,内容缩短或清空后同步收缩。内部滚动条使用浅灰窄滑块和透明轨道,上下留白不得溢出输入框圆角边界;输入框在窄屏下允许收缩且不产生横向滚动。
- Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择
- 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿
- 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。
- 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。
- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。
- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
@@ -74,36 +83,44 @@
- 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme``UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明。
- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。
- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`
- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。引用历史消息附件时先保留消息中的展示快照,最终发送前再按 `source + referenceId` 从当前画布和素材库选项刷新,避免提前刷新后又被失败恢复的旧快照覆盖。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃;同一 `source + referenceId` 冲突时保留等待期间的当前草稿快照,失败请求快照只补充缺失 identity。合并后超过 9 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用
- 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-``_``.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围
- 前端在创建画布、素材库和粘贴上传附件引用,以及把历史消息附件重新引用到输入区时,先执行上述归一化;原 label 无有效内容时依次归一化并使用调用方提供的 fallback(默认 `referenceId`)和固定文案「图片」。后端不能信任前端结果:校验当前工程 / 当前账号归属和 `objectKey` 后,必须用同一套字符规则、同一 24 码点上限再次归一化并重建权威附件;素材库附件的提交 label 缺失或过滤为空时,才回退到同样归一化后的素材库 label。前后端常量和规则必须保持同步。
- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。引用历史消息附件时,原消息继续保留存量展示快照;进入输入区的新引用先归一化 label 并保留其他展示快照字段,最终发送前再按 `source + referenceId` 从当前画布和素材库选项刷新,避免提前刷新后又被失败恢复的旧快照覆盖。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃;同一 `source + referenceId` 冲突时保留等待期间的当前草稿快照,失败请求快照只补充缺失 identity。合并后超过 9 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。
## 工具调用确认展示契约
- api-server 的 `EditorAgentToolExecutionMode` 明确区分 `ConfirmedExternalJob``Immediate`:前者才允许读取定价、构建确认展示和 worker payload;后者由 function-calling runner 当场执行,成功消息直接持久化为 `completed`,不得经过确认接口或伪造零价外部任务。
- Agent 规划出生成或编辑工具后,先把 `status=not_completed` 且没有 `externalJobId` 的工具消息持久化为待确认记录;确认卡必须在真正调用生成 provider 前展示本次提示词、规格参数、目标图和参考图缩略图,用户确认后才执行,取消后保留同一条 `status=cancelled` 记录。内部 system 文本和图片哈希 ID 不直接展示给用户。
- 工具消息只保存 `status``not_completed` / `completed` / `failed` / `cancelled`)和可选 `externalJobId``external_generation_job` 仍是队列、执行、lease 与计费结算真相;OSS status 仅表示该条对话消息是否已经回填完成结果或失败,不复制 queued / running。
- 确认接口必须先把工具参数转换为既有编辑器 worker payload,再使用 `editor-agent:{conversationId}:{messageId}:{toolName}` 稳定 dedupe key 入队;同一确认的请求重试只能得到同一个 external job。入队成功后把返回的 job id 写回同一条 OSS 工具消息,不新增 Agent 工具执行关联表。
- 前端根据 `externalJobId` 查询通用 external-generation job 状态;worker 继续通过 `canvasCompletion` 把生成结果写回工程与素材库。浏览器断线、刷新或 api-server 重启不得导致确认接口重新扣费或重新提交 provider。
- `GET /conversation` 会在同一个 conversation lock 内扫描 `status=not_completed` 且已有 `externalJobId` 的工具消息:只对这些消息按 job id 定向读取主任务;任务完成后复用对应工具的 `format_execute_message` 替换 system text、回填轻量图片 / 视频 / 音频引用并写为 `completed`,任务失败则回填 `error` 并写为 `failed`。任务结果读取或 completed payload 解析 / formatter 回填失败时,必须在同一次 GET 内完成首次尝试及最多 3 次重试,三次重试各间隔 100ms 并重新读取任务结果;仍失败才把该工具消息写为 `failed` 并保存最后错误。该重试不依赖前端再次刷新。排队和执行中都保持 `not_completed`,整轮扫描结果一次性写回 OSS。
- `EditorAgentToolCall.args` 保留为工具返回的原始 JSON,是确认接口重新序列化并执行工具的唯一参数真相。图片参数继续只保存由真实 data key 计算出的 opaque SHA-256 `imageId`;不得为了前端预览把 `args` 中的图片 ID 改写成 `objectKey`、URL 或展示对象,也不得由前端重组或回传一份新的执行参数。
- `EditorAgentToolCall.args` 的正式持久化契约是**校验后的规范参数 JSON**,不是 LLM 返回的原始 JSON。api-server 收到工具调用后,必须先按已注册的 ToolArgs 反序列化、补齐字段默认值、删除未进入 ToolArgs 的未知 / 退役字段、执行工具参数校验,再重新序列化并写入 `args`;校验失败的调用不得持久化为待确认消息。所有有明确默认值的工具标量参数在强类型 ToolArgs 中必须使用非 `Option` 字段:调用方省略字段或把顶层字段显式传为 `null` 时,统一在 ToolArgs 反序列化前视为未提供,由 Serde 补齐默认值,并把具体默认值写入规范 `args`;没有默认值的必填字段显式传为 `null` 时同样按缺失处理.(for compatibility) 后续计价、确认展示和 job payload 不得再次使用 `unwrap_or` 补同一默认值。LLM 原始参数只作为本次规范化的瞬时输入,不作为执行或审计真相;确认、取消、任务回填与后续上下文统一读取同一条消息中的规范 `args`。图片参数继续只保存由真实 data key 计算出的 opaque SHA-256 `imageId`;不得为了前端预览把 `args` 中的图片 ID 改写成 `objectKey`、URL 或展示对象,也不得由前端重组或回传一份新的执行参数。
- api-server 内画布 Agent 工具统一实现 object-safe `EditorAgentTool: ToolDyn``validate_args``format_execute_message` 和结果媒体投影使用统一 JSON 边界;只有 `ConfirmedExternalJob` 工具实现计价、确认展示和 worker job 构建,`Immediate` 工具不得被这些方法调用。每个具体工具负责把 JSON 反序列化为自己的强类型 Args / 结果,并把校验与完成消息格式化转发到 `platform-editor-agent` 中既有的 typed `validate_args` / `format_execute_message`,不得在调用方复制工具规则。`editor_agent_tool(toolName, context)` 是唯一按工具名分派的位置,规划、确认和任务回填只调用返回的 dyn tool;新增工具必须补齐同一个 trait 实现和该工厂分支。LLM builder 的 `.tool(...)` 注册列表仍是独立显式清单。framework runner 必须在 `ToolCallOutput` 中保留工具返回的结构化 outputrunner 写入 LLM memory 与 api-server 使用规范参数持久化 system text 时统一调用公开的 `format_tool_call_message`,不得丢弃 `TOOL_CALL_PENDING_MESSAGE` 后自行拼另一套“等待确认”输出。
- `EditorAgentToolCall.displayArgs` 是必填、只读的用户确认展示投影,与 `args` 分离:
- `stringArgs` 保存提示词、比例、清晰度、模型、时长等可展示参数的稳定名称、用户可见标题和值;
- `imageArgs` 按“目标图片 / 参考图片”等参数分组,每个 `refs` 项包含与原始参数对应的 `imageId`,以及后端从已校验会话上下文解析出的 `objectKey``imageSrc`、可选 `thumbnailSrc` / `label` / `width` / `height`
- `stringArgs` 保存提示词、比例、清晰度、模型、时长等可展示参数的稳定名称、用户可见标题和值;前端渲染模型字段时复用图片编辑器公共展示名映射,`gemini-3.1-flash-image-preview` 显示为 `nanobanana2``audio1.0` 显示为 `Vidu``chirp-v5` 显示为 `Suno`,视频模型显示现有产品标签,不得改写后端参数真相;
- `imageArgs` 按“目标图片 / 参考图片”等参数分组,每个 `refs` 项包含与规范参数对应的 `imageId`,以及后端从已校验会话上下文解析出的 `objectKey``imageSrc`、可选 `thumbnailSrc` / `label` / `width` / `height`
- `extras.priceMudPoints` 保存创建待确认消息时按后端运行时模型定价快照计算的预计泥点消耗;前端统一展示为“预计消耗 N泥点”,不自行计算价格。
- `displayArgs` 只能由 api-server 按已注册 tool 白名单,基于已经通过 ToolArgs 校验的 `args` 和当前 OSS 会话文档中的附件 / 历史生成结果构建;不能信任 LLM 自报的展示地址、标题或素材元数据。展示投影不参与确认执行,确认接口仍只读取同一条持久化 tool call 的 `args`,避免“看到的素材”和“实际执行的素材”分叉。
- `displayArgs` 只能由 api-server 按已注册 tool 白名单,基于已经通过 ToolArgs 校验的 `args` 和当前请求开始时从 OSS 会话文档一次性构建的 `EditorToolContext` 生成;该 context 必须按 opaque `ImageId` 同时保存执行所需的 `dataKey` 与展示所需的图片地址、Object Key、缩略图、label、宽高,参数校验、确认展示和 job payload 统一查同一份 context。不能信任 LLM 自报的展示地址、标题或素材元数据。展示投影不参与确认执行,确认接口仍只读取同一条持久化 tool call 的 `args`,避免“看到的素材”和“实际执行的素材”分叉。
- `extras.priceMudPoints` 同样只属于展示投影,不作为扣费输入;确认后仍由既有生成 BFF 按后端运行时定价执行预扣费,因此该字段表达用户确认时看到的价格快照,而不是前端可提交或覆盖的计费真相。
- `EditorAgentToolCall.summary` 只是 `args` 的重复字符串且没有稳定语义,当前契约删除该字段,不再作为展示或执行输入。
- 前端待确认卡只消费必填 `displayArgs`,不解析各 tool 私有的 snake_case / camelCase schema,也不把 `sha256:*` ID 当标题或图片地址。图片统一通过 `ResolvedAssetImage` 使用 `objectKey` 换签后显示,签名 URL 不进入消息文档。模块尚未上线,不保留缺少 `displayArgs` 时读取 raw `args` 的旧消息降级路径。
## LLM 与计费
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 注册类工具。
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 显式注册类工具。
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408``429``5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit-image`,并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得构造工具 schema 中不存在的 `source_image_id`除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate-image`
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate-image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408``429``5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;九类收费生成工具只做同步参数校验并返回待确认,`split-elements` 则在该 deadline 内完成确定性拆图。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点
- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试
- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind``retryable``fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
- api-server 收到带 `partial_outputs` 的终态失败时,必须先按原顺序把其中已成功工具转成 `status=not_completed` 待确认消息并写入同一会话增量,再追加 `ERROR <错误内容>` 终态 system 消息;不得因后续轮次、其它工具或 `max_turns` 失败而吞掉已经执行并返回的工具结果。
- 通用 JSON function-calling 协议、工具 schema 注入、memory / hook、`max_turns` 和“全部工具待确认即结束回合”统一由现役 `platform-agent-harness` 承载;无工具时也必须输出同一 JSON 响应格式。画布角色 prompt、规范展板 / 已有图路由、模型与超时 profile、八类工具、计费、OSS 会话和 external job 编排继续留在 `platform-editor-agent` / `api-server`,不得回流已退役的旧 `platform-agent`
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点;确定性 `split-elements` 免费。
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
## Lovart 参照做/不做清单(验收标准)
@@ -143,7 +160,7 @@
- `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建);
- `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删);
- `POST /api/editor/agent-conversations/{conversationId}/messages`JSON);
- Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。
- 通用 function-calling harness 放 `platform-agent-harness`;画布 Agent profile 与工具放 `platform-editor-agent`;会话、计费、OSS 和工具内部生成执行链路 api-server 编排层承接,不复用 `creative_agent.rs` 内存会话。
- `shared-contracts` + `packages/shared``editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation``deltaMessages` 和可选 `errorMessage`
## 实施顺序
@@ -152,7 +169,7 @@
2. 存储层(spacetime-module 表 + procedure + migration + spacetime-client + schema check);
3. api-server 会话 CRUD + 消息 OSS 读写 + JSON 回显桩(不接 LLM,先保证会话链路端到端真实落库);
4. 前端最小纵切(对话框、会话管理、消息流、附件弹窗、侧边栏/小地图默认值)——可与 3 并行:3 只碰 `server-rs/`4 只碰 `src/` 且先以契约 mock 客户端联调,汇合点在 4 末接真实 API;
5. LLM 编排 + 类工具接入 + 生成落画板 + 请求等待 / 错误态(替换回显桩这一个点);
5. LLM 编排 + 类工具接入 + 生成 / 拆图落画板 + 请求等待 / 错误态(替换回显桩这一个点);
6. 验证与文档:定向测试、类型检查、`npm run check:encoding``git diff --check``npm run check:spacetime-schema`、api-server smoke `/healthz`;同步 `CONTEXT.md` 与相关文档。
## 第一阶段验收补充
@@ -14,6 +14,7 @@
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
- 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"``generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
## 面板结构
@@ -81,4 +82,5 @@
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints``nanobanana2 1K` 应为 `12``gpt-image-2 1K` 应为 `3``gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
+65 -4
View File
@@ -1,18 +1,69 @@
// 画布Agent对话契约:会话元数据存 SpacetimeDB,消息正文整体存 OSSeditor-agent/{conversationId}.json)。
export const EDITOR_AGENT_MAX_ATTACHMENTS = 9;
export const EDITOR_AGENT_ATTACHMENT_LABEL_MAX_CODE_POINTS = 24;
export const EDITOR_AGENT_ERROR_MESSAGE_PREFIX = 'ERROR ';
export type EditorAgentMessageRole = 'user' | 'assistant' | 'system';
export type EditorAgentToolCallStatus =
| 'not_completed'
| 'completed'
| 'failed'
| 'cancelled';
'not_completed' | 'completed' | 'failed' | 'cancelled';
export type EditorAgentAttachmentSource = 'canvas_resource' | 'library_asset';
function isUnsafeEditorAgentAttachmentLabelCharacter(character: string) {
const codePoint = character.codePointAt(0) ?? 0;
const isControlCharacter =
codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
const isAsciiPunctuation =
(codePoint >= 0x21 && codePoint <= 0x2f) ||
(codePoint >= 0x3a && codePoint <= 0x40) ||
(codePoint >= 0x5b && codePoint <= 0x60) ||
(codePoint >= 0x7b && codePoint <= 0x7e);
const isUnsafeAsciiPunctuation =
isAsciiPunctuation &&
character !== '-' &&
character !== '_' &&
character !== '.';
return isControlCharacter || isUnsafeAsciiPunctuation;
}
function normalizeEditorAgentAttachmentLabel(
label: string | null | undefined,
fallback: string,
) {
const normalizeCandidate = (candidate: string | null | undefined) => {
const normalized: string[] = [];
let codePoints = 0;
let pendingSpace = false;
for (const character of candidate?.trim() ?? '') {
if (isUnsafeEditorAgentAttachmentLabelCharacter(character)) {
continue;
}
if (/\s/u.test(character)) {
pendingSpace = normalized.length > 0;
continue;
}
if (
pendingSpace &&
codePoints + 1 < EDITOR_AGENT_ATTACHMENT_LABEL_MAX_CODE_POINTS
) {
normalized.push(' ');
codePoints += 1;
}
pendingSpace = false;
if (codePoints >= EDITOR_AGENT_ATTACHMENT_LABEL_MAX_CODE_POINTS) {
break;
}
normalized.push(character);
codePoints += 1;
}
return normalized.join('').trim();
};
return normalizeCandidate(label) || normalizeCandidate(fallback) || '图片';
}
export interface EditorAgentAttachmentRef {
source: EditorAgentAttachmentSource;
referenceId: string;
@@ -24,6 +75,16 @@ export interface EditorAgentAttachmentRef {
height?: number | null;
}
export function createEditorAgentAttachmentRef(
input: EditorAgentAttachmentRef,
fallbackLabel: string = input.referenceId,
) {
return {
...input,
label: normalizeEditorAgentAttachmentLabel(input.label, fallbackLabel),
};
}
export interface EditorAgentGeneratedImage {
resourceId?: string | null;
objectKey?: string | null;
+9 -1
View File
@@ -195,7 +195,7 @@ function mapAccountSnapshot(value) {
return value;
}
assert(
Array.isArray(value) && value.length === 10,
Array.isArray(value) && value.length === 11,
'Invalid admin account snapshot.',
);
return {
@@ -209,6 +209,7 @@ function mapAccountSnapshot(value) {
updated_by: value[7],
created_at_micros: value[8],
updated_at_micros: value[9],
action_permissions_json: value[10],
};
}
@@ -322,6 +323,7 @@ async function runSmoke(serverUrl, bootstrapSecret) {
tab_permissions_json: '["dashboard"]',
enabled: true,
created_by: 'admin-account-smoke',
action_permissions_json: '[]',
};
const updateInput = {
account_id: 'member-1',
@@ -330,6 +332,7 @@ async function runSmoke(serverUrl, bootstrapSecret) {
tab_permissions_json: '["dashboard","tracking"]',
enabled: false,
updated_by: 'admin-account-smoke',
action_permissions_json: '["profile-wallet-consumption-reconcile"]',
};
assertDenied(
@@ -475,6 +478,11 @@ async function runSmoke(serverUrl, bootstrapSecret) {
updated.ok && updated.account?.token_version === 2,
'State update did not increment once.',
);
assert(
updated.account?.action_permissions_json ===
'["profile-wallet-consumption-reconcile"]',
'Action permission was not persisted.',
);
const displayOnly = parseAccountResult(
await callProcedure(

Some files were not shown because too many files have changed in this diff Show More