合并最新master到编译警告清理分支
Project CI / AI game creator shell Rust crates (pull_request) Failing after 1m18s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Backend tests (pull_request) Successful in 4m44s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / Frontend tests (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m40s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m57s
Project CI / Repository checks (pull_request) Successful in 2m22s

同步远端master最新变更并保留编译警告清理成果
This commit is contained in:
2026-09-23 12:41:36 +00:00
76 changed files with 4343 additions and 576 deletions
+13
View File
@@ -218,6 +218,18 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用名**:
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
**引用候选枚举**:
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
**引用粘贴解析**:
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
@@ -248,6 +260,7 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
+7
View File
@@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload {
export interface AdminDashboardMetricsPayload {
generatedAssets: number;
/** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */
consumedMudPoints: number;
/** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */
refundedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
@@ -955,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload {
productTitle: string;
productKind: string;
amountCents: number;
/** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */
paidAmountCents: number;
status: string;
paymentChannel: string;
paidAtMicros?: number | null;
@@ -994,6 +999,8 @@ export interface AdminUserDetailResponse {
phoneBound: boolean;
wechatBound: boolean;
historicalConsumedPoints: number;
/** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */
cumulativeRechargedCents?: number | null;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
@@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = {
phoneBound: true,
wechatBound: true,
historicalConsumedPoints: 1234,
cumulativeRechargedCents: 128800,
canReconcileConsumption: true,
wallet,
rechargeOrders: [
@@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('¥1288.00')).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() =>
@@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
test('累计充值读取不到时展示未知,不用订单列表近似', async () => {
vi.mocked(getAdminUserDetail).mockResolvedValue({
...detail,
cumulativeRechargedCents: null,
});
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.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe(
'未知',
);
});
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
const user = userEvent.setup();
render(
@@ -364,6 +364,7 @@ export function AdminUserDetailDialog({
<th></th>
<th></th>
<th></th>
<th></th>
<th>退</th>
<th></th>
</tr>
@@ -377,11 +378,13 @@ export function AdminUserDetailDialog({
</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>{order.productTitle || order.productId}</td>
<td>
{order.productTitle || order.productId}
<small> {order.pointsDelta} </small>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付'}
</td>
<td>{formatMoney(order.amountCents)}</td>
<td>{order.pointsDelta} </td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small> {order.unrecoveredPoints} </small>
@@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
<dt></dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
: '未知'}
</dd>
</div>
<div>
<dt></dt>
<dd>
@@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = {
metrics: {
generatedAssets: 12,
consumedMudPoints: 88,
refundedMudPoints: 24,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
newUserPaymentConversion: {
@@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(await screen.findByText('总计数据')).toBeTruthy();
expect(screen.getByText('时段数据')).toBeTruthy();
expect(screen.getByText('本日生产素材数')).toBeTruthy();
expect(screen.getByText('本日消耗泥点数')).toBeTruthy();
expect(screen.getByText('本日退还泥点数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
@@ -128,6 +128,12 @@ export function AdminDashboardPage({
value: metrics?.consumedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'refunded-mud-points',
label: `${rangePrefix(granularity)}退还泥点数`,
value: metrics?.refundedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'new-registered-users',
label: `${rangePrefix(granularity)}新增用户数`,
@@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -129,6 +130,40 @@ beforeEach(() => {
);
});
test('未支付订单不显示实付金额,发放泥点单独成列', async () => {
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
entries: [
{
...baseOrder,
orderId: 'order-pending',
status: 'pending',
paidAtMicros: null,
paidAmountCents: 0,
pointsDelta: 0,
},
{ ...baseOrder, orderId: 'order-paid' },
],
});
renderPage();
expect(
await screen.findByRole('columnheader', { name: '实付' }),
).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
const unpaidRow = (await screen.findByText('order-pending')).closest(
'tr',
) as HTMLElement;
const unpaidCells = within(unpaidRow).getAllByRole('cell');
expect(unpaidCells[3]?.textContent).toContain('未支付');
expect(unpaidCells[4]?.textContent).toBe('0 泥点');
const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement;
const paidCells = within(paidRow).getAllByRole('cell');
expect(paidCells[3]?.textContent).toBe('¥6.00');
expect(paidCells[4]?.textContent).toBe('60 泥点');
});
test('充值订单查询传递全部筛选字段', async () => {
const user = userEvent.setup();
renderPage();
@@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({
<th></th>
<th></th>
<th></th>
<th> / </th>
<th></th>
<th></th>
<th>退</th>
<th></th>
<th></th>
@@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatMoney(refundOrder.amountCents)}
value={formatMoney(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
@@ -1118,8 +1119,13 @@ function RechargeOrderRow({
</small>
</td>
<td>
<strong>{formatMoney(order.amountCents)}</strong>
<small> {order.pointsDelta} </small>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small> {formatMoney(order.amountCents)}</small>
)}
</td>
<td>
<span>{order.pointsDelta} </span>
</td>
<td>
<span> {formatMoney(order.cumulativeSuccessRefundCents)}</span>
@@ -1312,6 +1318,13 @@ function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付';
}
function formatCentsInput(cents: number) {
return (cents / 100).toFixed(2);
}
@@ -217,7 +217,7 @@ export function AdminRedeemCodePage({
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<span></span>
<input
min={1}
step={1}
@@ -319,7 +319,7 @@ export function AdminRedeemCodePage({
<thead>
<tr>
<th>Code</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -337,7 +337,7 @@ export function AdminRedeemCodePage({
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.rewardPoints} </td>
<td>
<span
className={`admin-status ${redeemValidityClass(entry)}`}
@@ -373,6 +373,24 @@ export function buildTauriBuildArguments(
];
}
/**
* 基线 client 窗口契约:渠道配置只允许覆盖标题,其余字段必须逐字沿用。
*
* `tauri build --config` 走 JSON Merge Patchtauri-utils 用 `json_patch::merge`):
* 对象递归合并,**数组整体替换**。只下发 `{ title }` 会让
* `label` / `decorations` / 尺寸全部回落到 Tauri 默认值(label=main、
* decorations=true、800x600),结果是原生系统标题栏重新出现,并且按 label
* 绑定的 capability(平台 HTTP 权限等)一起失效。
*/
function readBaseClientWindow() {
const base = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
const clientWindow = base.app?.windows?.[0];
if (!clientWindow || typeof clientWindow.label !== 'string') {
throw new Error('AGC 基线配置缺少 client 主窗口,渠道配置无法安全合并');
}
return clientWindow;
}
/**
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
@@ -381,11 +399,15 @@ export function buildTauriBuildArguments(
export function createChannelConfig(
channel = resolveReleaseChannel(),
target = defaultTarget(),
baseClientWindow = readBaseClientWindow(),
) {
const { productName, identifier } = resolveChannelInstallIdentity(channel);
return {
productName,
identifier,
app: {
windows: [{ ...baseClientWindow, title: productName }],
},
plugins: {
updater: {
endpoints: [updateManifestUrl(channel, target)],
@@ -452,6 +474,8 @@ export function runTauriBuild(
// Vite embeds the platform API origin in the packaged renderer. The
// release channel and updater channel therefore cannot drift apart.
VITE_AGC_PLATFORM_CHANNEL: channel,
VITE_AGC_PRODUCT_NAME:
resolveChannelInstallIdentity(channel).productName,
},
},
);
@@ -1,5 +1,11 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
@@ -177,8 +183,22 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
productName: AGC_PRODUCT_NAME,
productName: `${AGC_PRODUCT_NAME}开发版`,
identifier: AGC_APP_IDENTIFIER,
app: {
windows: [
{
label: 'client',
title: `${AGC_PRODUCT_NAME}开发版`,
url: 'index.html',
width: 1280,
height: 800,
decorations: false,
minWidth: 1280,
minHeight: 720,
},
],
},
plugins: {
updater: {
endpoints: [
@@ -202,7 +222,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
productName: AGC_PRODUCT_NAME,
productName: `${AGC_PRODUCT_NAME}开发版`,
identifier: AGC_APP_IDENTIFIER,
});
assert.deepEqual(resolveChannelInstallIdentity('release'), {
@@ -242,6 +262,78 @@ test('channel install identity is baked into the same build-time config as the e
});
});
/**
* RFC 7386tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。
* 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。
*/
function applyJsonMergePatch(base, patch) {
if (Array.isArray(patch) || typeof patch !== 'object' || patch === null) {
return patch;
}
const merged =
typeof base === 'object' && base !== null && !Array.isArray(base)
? { ...base }
: {};
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete merged[key];
else merged[key] = applyJsonMergePatch(merged[key], value);
}
return merged;
}
function readBaseTauriConfig() {
return JSON.parse(
readFileSync(
new URL('../src-tauri/tauri.conf.json', import.meta.url),
'utf8',
),
);
}
test('channel config keeps the client window contract across the Tauri config merge', () => {
const base = readBaseTauriConfig();
const merged = applyJsonMergePatch(base, {
...createChannelConfig('release', windowsTarget),
version: base.version,
});
const [clientWindow] = merged.app.windows;
assert.deepEqual(clientWindow, {
...base.app.windows[0],
title: '陶泥儿 Release',
});
// 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效,
// 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。
assert.equal(clientWindow.label, 'client');
assert.equal(clientWindow.decorations, false);
assert.equal(clientWindow.width, 1280);
assert.equal(clientWindow.height, 800);
assert.equal(clientWindow.minWidth, 1280);
assert.equal(clientWindow.minHeight, 720);
const capabilitiesDirectory = new URL(
'../src-tauri/capabilities/',
import.meta.url,
);
const capabilities = readdirSync(capabilitiesDirectory)
.filter((name) => name.endsWith('.json'))
.map((name) =>
JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')),
);
const httpCapability = capabilities.find((capability) =>
(capability.permissions ?? []).some(
(permission) =>
permission === 'http:default' ||
(typeof permission === 'object' &&
permission?.identifier === 'http:default'),
),
);
assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability');
assert.ok(
(httpCapability.windows ?? []).includes(clientWindow.label),
`平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`,
);
});
test('channel products keep first-install selection working under the channel product name', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
try {
@@ -277,6 +369,10 @@ test('packaged renderer receives the same channel as the updater manifest', () =
},
});
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
assert.equal(
spawnOptions?.env?.VITE_AGC_PRODUCT_NAME,
`${AGC_PRODUCT_NAME} Release`,
);
});
test('macOS manifests advertise exactly the architectures actually built', () => {
@@ -10,7 +10,7 @@
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
* 本地项目与运行锁。
*
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
* 默认渠道 `dev` 保持已发布客户端标识不变:升级链路与既有安装不能断;展示名显式标记为开发版
*/
export const AGC_DEFAULT_CHANNEL = 'dev';
@@ -46,8 +46,9 @@ export function resolveReleaseChannel(env = process.env) {
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
}
/** 安装身份里的展示后缀:`release` → `Release``beta-2` → `Beta-2`。 */
/** 安装身份里的展示后缀:`dev` → `开发版``release` → `Release``beta-2` → `Beta-2`。 */
export function channelDisplaySuffix(channel) {
if (channel === AGC_DEFAULT_CHANNEL) return '开发版';
return validateReleaseChannel(channel)
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
@@ -55,19 +56,19 @@ export function channelDisplaySuffix(channel) {
}
/**
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀
* 保证同一台设备上不同渠道互不覆盖
* 渠道对应的安装身份。默认渠道保持既有 identifier 以兼容已安装客户端
* 但展示名明确标记为开发版;其它渠道派生独立 identifier,保证同一台设备上并存
*/
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
validateReleaseChannel(channel);
if (channel === AGC_DEFAULT_CHANNEL) {
return Object.freeze({
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
});
}
return Object.freeze({
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
productName:
channel === AGC_DEFAULT_CHANNEL
? `${AGC_PRODUCT_NAME}开发版`
: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
identifier:
channel === AGC_DEFAULT_CHANNEL
? AGC_APP_IDENTIFIER
: `${AGC_APP_IDENTIFIER}.${channel}`,
});
}
@@ -25,7 +25,6 @@ execFileSync(
import {
AGC_APP_IDENTIFIER,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.mjs';
import {
@@ -1313,7 +1312,7 @@ if (
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
// 的升级链路与既有安装目录都会断开。
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
if (tauriConfig.productName !== defaultChannelIdentity.productName) {
throw new Error('AI game creator shell productName drifted');
}
@@ -1486,7 +1485,7 @@ if (
!Array.isArray(tauriConfig.app?.windows) ||
tauriConfig.app.windows.length !== 1 ||
tauriConfig.app.windows[0]?.label !== 'client' ||
tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
tauriConfig.app.windows[0]?.title !== defaultChannelIdentity.productName ||
tauriConfig.app.windows[0]?.url !== 'index.html'
) {
throw new Error(
@@ -1568,6 +1567,14 @@ if (
);
}
// 窗口外壳由前端 WindowChrome 自绘:基线配置一旦放开 decorations
// 打包产物会出现系统标题栏与自绘标题栏并存。
if (clientWindow.decorations !== false) {
throw new Error(
'AI game creator shell client window must keep native decorations disabled',
);
}
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
throw new Error(
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
@@ -5894,14 +5894,81 @@ pub(crate) async fn export_local_project_package(
export_local_project_package_for_publish_at(root).await
}
/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。
///
/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过
/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。
#[tauri::command]
pub(crate) fn read_local_project_export_package(
pub(crate) fn prepare_local_project_game_package(
app: tauri::AppHandle,
project_path: String,
package_relative_path: String,
) -> Result<LocalProjectExportPackagePayload, String> {
) -> Result<crate::game_package_upload::StagedGamePackage, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
read_local_project_export_package_at(root, package_relative_path.trim())
let payload = read_local_project_export_package_at(root, package_relative_path.trim())?;
let staging_dir = game_package_upload_staging_dir(&app)?;
let mut staged = crate::game_package_upload::stage_game_package_bytes(
&staging_dir,
&payload.package_sha256,
&payload.package_bytes,
)?;
staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX);
Ok(staged)
}
/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。
#[tauri::command]
pub(crate) async fn upload_local_project_game_package(
app: tauri::AppHandle,
staging_path: String,
version_id: String,
api_base_url: String,
access_token: String,
idempotency_key: String,
) -> Result<crate::game_package_upload::GamePackageUploadOutcome, String> {
let staging_dir = game_package_upload_staging_dir(&app)?;
let resolved_path =
crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?;
let client = reqwest::Client::builder()
.build()
.map_err(|error| format!("创建上传客户端失败:{error}"))?;
let version_id = version_id.trim().to_string();
if version_id.is_empty() {
return Err("缺少发行版本标识".to_string());
}
let emit_handle = app.clone();
let progress_version_id = version_id.clone();
crate::game_package_upload::upload_staged_game_package(
&client,
crate::game_package_upload::GamePackageUploadRequest {
staging_path: &resolved_path,
version_id: &version_id,
api_base_url: api_base_url.trim(),
access_token: access_token.trim(),
idempotency_key: idempotency_key.trim(),
},
move |received_bytes, total_bytes| {
let _ = emit_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&progress_version_id,
received_bytes,
total_bytes,
),
);
},
)
.await
}
fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
crate::game_package_upload::game_package_upload_staging_dir(&app_data_root)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
#[tauri::command]
File diff suppressed because it is too large Load Diff
@@ -131,6 +131,7 @@ mod editor_adapter;
mod editor_adapters;
mod environment_check;
pub mod error_report;
mod game_package_upload;
mod git_inspect;
mod goal;
mod http_client;
@@ -2755,7 +2756,8 @@ fn main() {
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
prepare_local_project_game_package,
upload_local_project_game_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "陶泥儿",
"productName": "陶泥儿开发版",
"version": "0.1.67",
"identifier": "world.genarrative.ai-game-creator",
"build": {
@@ -14,7 +14,7 @@
"windows": [
{
"label": "client",
"title": "陶泥儿",
"title": "陶泥儿开发版",
"url": "index.html",
"width": 1280,
"height": 800,
+76 -177
View File
@@ -116,6 +116,7 @@ import {
type DirectProjectInitialTurn,
} from './view/project-development/chat/DirectProjectChatView';
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
@@ -366,23 +367,31 @@ export function App({
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
const [projectChatError, setProjectChatError] = useState('');
const [designAgentTransientReply, setDesignAgentTransientReplyVisible] =
useState('');
const designAgentTransientReplyTargetRef = useRef('');
const designAgentVisibleReplyRef = useRef('');
const designAgentPendingViewRef = useRef<{
clientTurnId: string;
projectPath: string;
view: DesignView;
} | null>(null);
const designReplyAnimation = useDesignReplyAnimation();
const [designAgentStatus, setDesignAgentStatus] = useState('');
const [designAgentReasoning, setDesignAgentReasoning] = useState('');
function setDesignAgentTransientReplyTarget(next: string) {
designAgentTransientReplyTargetRef.current = next;
if (!next) {
designAgentVisibleReplyRef.current = '';
setDesignAgentTransientReplyVisible('');
}
function isCurrentDesignTurn(projectPath: string, clientTurnId: string) {
const tracked = designAgentTurnRef.current;
return (
localProjectPathRef.current === projectPath &&
tracked?.projectPath === projectPath &&
tracked.clientTurnId === clientTurnId
);
}
function beginDesignTurn(projectPath: string, clientTurnId: string) {
designAgentTurnRef.current = { projectPath, clientTurnId };
designAgentReasoningTurnRef.current = { projectPath, clientTurnId };
designReplyAnimation.reset(
latestMessagesRef.current.flatMap((message) =>
message.messageId ? [message.messageId] : [],
),
);
setDesignAgentStatus('');
setDesignAgentReasoning('');
setProjectChatError('');
setChatAgentBusy(true);
}
function designAgentEventSubscriptionReady() {
@@ -407,35 +416,12 @@ export function App({
designAgentEventSubscriptionResolveRef.current = null;
}
useEffect(() => {
const timer = window.setInterval(() => {
const target = designAgentTransientReplyTargetRef.current;
setDesignAgentTransientReplyVisible((current) => {
if (!target) {
designAgentVisibleReplyRef.current = '';
return '';
}
const prefix = target.startsWith(current) ? current : '';
if (prefix === target) {
designAgentVisibleReplyRef.current = target;
return target;
}
const remaining = target.length - prefix.length;
const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1;
const next = target.slice(0, prefix.length + step);
designAgentVisibleReplyRef.current = next;
return next;
});
}, 50);
return () => window.clearInterval(timer);
}, []);
/**
* 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。
*
* 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()`
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合
* 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。正文和视图严格匹配活动回合
* reasoning 另按原回合接收迟到补充,不能让过期视图重播正文
*/
useEffect(() => {
const ready = createDesignAgentEventSubscriptionReady();
@@ -452,19 +438,7 @@ export function App({
let disposed = false;
void subscribeTauriEvent<DesignEvent>('design-agent-update', (event) => {
const payload = event.payload;
const tracked = designAgentTurnRef.current;
if (
payload.projectPath !== localProjectPathRef.current ||
(tracked && payload.clientTurnId !== tracked.clientTurnId)
) {
return;
}
if (
(payload.kind === 'text' || payload.kind === 'tool') &&
payload.text
) {
setDesignAgentTransientReplyTarget(payload.text);
}
if (payload.projectPath !== localProjectPathRef.current) return;
if (payload.reasoningText != null) {
const reasoningTurn = designAgentReasoningTurnRef.current;
if (
@@ -474,8 +448,21 @@ export function App({
setDesignAgentReasoning(payload.reasoningText);
}
}
if (!isCurrentDesignTurn(payload.projectPath, payload.clientTurnId))
return;
if (
payload.kind === 'text' &&
payload.messageId &&
payload.text != null
) {
designReplyAnimation.receiveText(payload.messageId, payload.text);
if (payload.text) setDesignAgentStatus('');
}
if (payload.kind === 'tool' && payload.text != null) {
setDesignAgentStatus(payload.text);
}
if (payload.view) {
applyDesignAgentViewAfterTransient(
applyDesignAgentTurnView(
payload.view,
payload.projectPath,
payload.clientTurnId,
@@ -557,67 +544,18 @@ export function App({
latestMessagesRef.current = conversation;
}
function commitDesignAgentView(view: DesignView, projectPath: string) {
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
designAgentPendingViewRef.current = null;
applyDesignView(view, projectPath);
setDesignAgentReasoning('');
setDesignAgentTransientReplyTarget('');
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
designAgentTurnRef.current = null;
}
}
function applyDesignAgentViewAfterTransient(
function applyDesignAgentTurnView(
view: DesignView,
projectPath: string,
clientTurnId: string,
) {
let target = designAgentTransientReplyTargetRef.current;
const tracked = designAgentTurnRef.current;
if (!target.trim() && !view.running) {
const latestAssistantText = [...view.messages]
.reverse()
.find((message) => message.role !== 'user' && message.text.trim())
?.text.trim();
if (latestAssistantText) {
setDesignAgentTransientReplyTarget(latestAssistantText);
target = latestAssistantText;
}
}
if (
!view.running &&
tracked?.clientTurnId === clientTurnId &&
target.trim() &&
designAgentVisibleReplyRef.current !== target
) {
designAgentPendingViewRef.current = {
clientTurnId,
projectPath,
view,
};
return;
}
commitDesignAgentView(view, projectPath);
if (!isCurrentDesignTurn(projectPath, clientTurnId)) return;
designReplyAnimation.receiveView(view);
applyDesignView(view, projectPath);
setDesignAgentReasoning('');
if (!view.running) setDesignAgentStatus('');
}
useEffect(() => {
const timer = window.setInterval(() => {
const pending = designAgentPendingViewRef.current;
if (!pending) {
return;
}
const target = designAgentTransientReplyTargetRef.current;
if (target && designAgentVisibleReplyRef.current !== target) {
return;
}
commitDesignAgentView(pending.view, pending.projectPath);
}, 50);
return () => window.clearInterval(timer);
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function hydrateDesignAgentSession(nextProjectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath.trim()) {
@@ -647,32 +585,18 @@ export function App({
setProjectChatError('需要在 Tauri App 内运行。');
return;
}
designAgentTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentReasoningTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentPendingViewRef.current = null;
beginDesignTurn(nextProjectPath, clientTurnId);
await designAgentEventSubscriptionReady();
setChatAgentBusy(true);
setProjectChatError('');
setDesignAgentTransientReplyTarget('');
setDesignAgentReasoning('');
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return;
try {
const view = await invoke<DesignView>('continue_design_agent_session', {
projectPath: nextProjectPath,
clientTurnId,
input,
});
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
applyDesignAgentTurnView(view, nextProjectPath, clientTurnId);
} catch (error) {
if (localProjectPathRef.current !== nextProjectPath) {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) {
return;
}
const message = error instanceof Error ? error.message : String(error);
@@ -680,12 +604,13 @@ export function App({
requestRuntimeConfigOpen();
}
setProjectChatError(message);
designReplyAnimation.discardUnpersisted();
} finally {
if (!designAgentPendingViewRef.current) {
if (isCurrentDesignTurn(nextProjectPath, clientTurnId)) {
designAgentTurnRef.current = null;
setDesignAgentTransientReplyTarget('');
setDesignAgentStatus('');
setChatAgentBusy(false);
}
setChatAgentBusy(false);
}
}
@@ -876,7 +801,8 @@ export function App({
}, [
messages,
projectChatError,
designAgentTransientReply,
designReplyAnimation.replies,
designAgentStatus,
designAgentReasoning,
designAgentView,
pendingUiConfirmation,
@@ -1366,11 +1292,12 @@ export function App({
* 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。
*/
function resetChatState() {
setChatAgentBusy(false);
setChatFilesImporting(false);
setChatFileImportNotice('');
setProjectChatError('');
setDesignAgentTransientReplyTarget('');
designAgentPendingViewRef.current = null;
designReplyAnimation.reset();
setDesignAgentStatus('');
setDesignAgentReasoning('');
setDesignAgentActive(planningStartMode);
designAgentActiveRef.current = planningStartMode;
@@ -2295,7 +2222,8 @@ export function App({
pendingConfirmation={pendingUiConfirmation}
projectPath={localProject?.projectPath ?? projectPath}
conversationMessages={messages}
transientReply={designAgentTransientReply}
replyAnimations={designReplyAnimation.replies}
designStatus={designAgentStatus}
showDesignReasoning={designAgentActive}
designReasoning={designAgentReasoning}
designReasoningEntries={
@@ -2313,66 +2241,37 @@ export function App({
return;
}
const clientTurnId = createAgentChatRunId('design-agent-turn');
designAgentTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentReasoningTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentPendingViewRef.current = null;
setDesignAgentTransientReplyTarget('');
setDesignAgentReasoning('');
setChatAgentBusy(true);
beginDesignTurn(nextProjectPath, clientTurnId);
void designAgentEventSubscriptionReady()
.then(() =>
invoke<DesignView>('decide_design_phase', {
.then(() => {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return null;
return invoke<DesignView>('decide_design_phase', {
projectPath: nextProjectPath,
clientTurnId,
requestId,
approved,
}),
)
});
})
.then((view) => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
return;
}
applyDesignAgentViewAfterTransient(
if (!view) return;
applyDesignAgentTurnView(
view,
nextProjectPath,
clientTurnId,
);
})
.catch((error) => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return;
}
setProjectChatError(String(error));
designReplyAnimation.discardUnpersisted();
})
.finally(() => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return;
}
if (!designAgentPendingViewRef.current) {
designAgentTurnRef.current = null;
setDesignAgentTransientReplyTarget('');
}
designAgentTurnRef.current = null;
setDesignAgentStatus('');
setChatAgentBusy(false);
});
}
@@ -1,5 +1,15 @@
import appPackage from '../../package.json';
const DEFAULT_APP_NAME = '陶泥儿开发版';
/**
* 产品名由构建期注入;本地 Vite 开发没有注入时沿用 dev 渠道产品名。
*
* 发布构建可通过 `VITE_AGC_PRODUCT_NAME` 注入渠道产品名,避免 UI 自己
* 根据渠道推导名称,保证标题栏、关于页等前端展示与安装身份保持一致。
*/
const injectedAppName = import.meta.env.VITE_AGC_PRODUCT_NAME?.trim();
/** Product metadata shared by the client UI and release bundle. */
export const APP_NAME = 'Genarrative AI Game Creator';
export const APP_NAME = injectedAppName || DEFAULT_APP_NAME;
export const APP_VERSION = appPackage.version;
+19 -25
View File
@@ -459,11 +459,7 @@ export interface AgentRuntimeResult {
}
export type AgentRuntimeResponseStreamStatus =
| 'streaming'
| 'ready'
| 'committed'
| 'discarded'
| 'failed';
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
export interface AgentRuntimeResponseStream {
schemaVersion: string;
@@ -569,22 +565,13 @@ export interface GameCreatorAgentLlmConfigStatus {
}
export type GameCreatorLlmApiKind =
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
'openai_responses' | 'openai_chat' | 'anthropic';
export type GameCreatorAgentMode =
| 'codex_app_server'
| 'codex_cli'
| 'provider';
'codex_app_server' | 'codex_cli' | 'provider';
export type RuntimeLlmProviderPresetId =
| 'custom'
| 'openai'
| 'deepseek'
| 'anthropic'
| 'ark';
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
export type RuntimeAgentLlmProviderPresetId =
| 'inherit'
| RuntimeLlmProviderPresetId;
'inherit' | RuntimeLlmProviderPresetId;
export interface GameCreatorLlmConfig {
customEnabled?: boolean;
@@ -796,12 +783,21 @@ export interface LocalProjectExportPackageFileDigest {
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
/**
* 已暂存的归一化发行包:发布链路只传递这个摘要与路径,整包字节留在原生进程里,
* 不再经过 WebView IPC。
*/
export interface StagedGamePackage {
stagingPath: string;
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
packageFileCount: number;
}
export interface GamePackageUploadOutcome {
versionId: string;
status: string;
uploadedBytes: number;
}
export interface LocalProjectExportPackageSummary {
@@ -935,9 +931,7 @@ export type GameCreatorDirectToolCallKind =
| 'other';
export type GameCreatorDirectToolCallStatus =
| 'running'
| 'completed'
| 'failed';
'running' | 'completed' | 'failed';
export interface GameCreatorDirectToolCallChange {
path: string;
@@ -9,6 +9,7 @@ import {
} from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { APP_NAME } from '../app/appMetadata';
import { appUpdateCheckEnabled } from '../app/featureFlags';
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
@@ -148,14 +149,21 @@ export function WindowChrome({ children }: WindowChromeProps) {
<WindowChromeContext.Provider value={contextValue}>
<div className="window-chrome">
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
<header className="window-chrome__bar" aria-label="窗口标题栏">
<header
className="window-chrome__bar"
data-window-chrome-bar
aria-label="窗口标题栏"
>
<div className="window-chrome__leading">
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
<div
className="window-chrome__brand"
aria-label={`${APP_NAME} GameAgent`}
>
<span className="window-chrome__brand-mark">
<img src={brandIcon} alt="" />
</span>
<span className="window-chrome__brand-copy">
<strong></strong>
<strong>{APP_NAME}</strong>
<span>GameAgent</span>
</span>
</div>
@@ -4,6 +4,23 @@ import { createPortal } from 'react-dom';
type ThemedModalTheme = 'light' | 'dark';
/**
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
*
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
* 挡在模态之外,点空白处不会误触底层界面。
*/
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
function isWindowChromeBarTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
);
}
export type ThemedModalProps = {
open: boolean;
ariaLabel: string;
@@ -55,6 +72,7 @@ export function ThemedModal({
escapeDeactivates: false,
fallbackFocus: () => panelRef.current!,
returnFocusOnDeactivate: true,
allowOutsideClick: (event) => isWindowChromeBarTarget(event.target),
}}
>
<div
@@ -16,10 +16,13 @@ import {
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type LexicalNode,
PASTE_COMMAND,
PASTE_TAG,
type TextNode,
} from 'lexical';
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
@@ -53,6 +56,7 @@ import {
ResourceReferenceNode,
} from './ResourceReferenceNode';
import {
buildContentFromPastedText,
buildContentFromTextTokens,
type ChatComposerDraft,
type ChatReference,
@@ -60,11 +64,18 @@ import {
chatReferenceKey,
chatReferenceMentionToken,
chatReferenceToContentPart,
contentPartText,
dedupeChatReferences,
joinMentionText,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
/**
* Lexical 自己的剪贴板负载(导入优先级最高的一条):带着它复制粘贴时,真 chip 会被原样还原,
* 所以粘贴解析要让位给默认导入。
*/
const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor';
type ResourceReferenceInputProps = {
onChange?: (draft: ChatComposerDraft) => void;
onEditorStateChange?: (editorState: EditorState) => void;
@@ -226,6 +237,20 @@ function mentionTokenFromPart(
return null;
}
/**
* part 落进正文时用的文本:provider 的 `mentionToken`,它答不出来(provider 契约被破坏)时退到
* `contentPartText` 的通用文本形态。文本 part 自己就是文本,返回 `null`。
*
* 粘贴插入、润色回写的候选扫描与落点都走这一条:只要 part 不是文本,就一定有一段正常文本可落,
* 「认不出的引用走文本、绝不静默丢」在几个入口是同一份实现,不是各写一遍兜底。
*/
function mentionTokenOrText(
providers: readonly ReferenceProvider[],
part: DirectCodexUserContentPart,
): string | null {
return mentionTokenFromPart(providers, part) ?? contentPartText(part);
}
/**
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
*
@@ -256,8 +281,10 @@ function applyPolishedTextToRoot(
) {
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
// provider 答不出 token 的 part 也照样进候选(token 退到 `mentionTokenOrText` 的通用文本形态):
// 它在回包里没被提到时会作为末尾孤儿补回来,而不是从这门翻译里直接消失。
const candidates = current.content.flatMap((part) => {
const token = mentionTokenFromPart(providers, part);
const token = mentionTokenOrText(providers, part);
return token ? [{ token, part }] : [];
});
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
@@ -286,8 +313,81 @@ function applyContentToRoot(
const reference = referenceFromPart(providers, part);
if (reference) {
paragraph.append($createResourceReferenceNode(reference));
return;
}
// 解析不出引用的 part 落它的文本(与粘贴同一条兜底链),整根替换同样不许把内容吃掉。
const token = mentionTokenOrText(providers, part);
if (token) paragraph.append($createTextNode(token));
});
}
/**
* 取当前可用的选区:选区缺失、或指向已被重建掉的节点时(跨会话恢复草稿后就是这种),
* 统一回落到草稿末尾,避免把内容插到一个已经不存在的位置。取不到时返回 `null`。
*/
function $selectionOrRootEnd() {
let selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
return $isRangeSelection(selection) ? selection : null;
}
/**
* 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。
*
* 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围:
* 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成
* chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。
*
* 认不出的引用 partprovider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的
* `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证
* 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。
*
* 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行
* 编辑器的默认粘贴,否则这段文字两边都不管。
*/
function $insertContentAtSelection(
content: readonly DirectCodexUserContentPart[],
providers: readonly ReferenceProvider[],
): boolean {
// 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。
if (!$selectionOrRootEnd()) return false;
let inserted = false;
content.forEach((part) => {
if (part.type === 'input_text') {
part.text.split('\n').forEach((line, index) => {
if (index > 0) {
$selectionOrRootEnd()?.insertParagraph();
inserted = true;
}
if (line) {
$selectionOrRootEnd()?.insertText(line);
inserted = true;
}
});
return;
}
const reference = referenceFromPart(providers, part);
if (reference) {
$selectionOrRootEnd()?.insertNodes([
$createResourceReferenceNode(reference),
]);
inserted = true;
return;
}
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
const token = mentionTokenOrText(providers, part);
if (token) {
$selectionOrRootEnd()?.insertText(token);
inserted = true;
}
});
return inserted;
}
/**
@@ -361,24 +461,14 @@ function ResourceReferenceEditor({
(nextReferences: ChatReference[]) => {
if (nextReferences.length === 0) return;
editor.update(() => {
let selection = $getSelection();
// 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾,
// 避免把引用插到一个已经不存在的位置。
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
selection.insertNodes(
nextReferences.flatMap((reference) => [
$createResourceReferenceNode(reference),
$createTextNode(' '),
]),
);
}
const selection = $selectionOrRootEnd();
if (!selection) return;
selection.insertNodes(
nextReferences.flatMap((reference) => [
$createResourceReferenceNode(reference),
$createTextNode(' '),
]),
);
});
editor.focus();
},
@@ -390,19 +480,11 @@ function ResourceReferenceEditor({
const insert = text.replace(/\s+$/u, '');
if (!insert.trim()) return;
editor.update(() => {
let selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(insert);
}
const selection = $selectionOrRootEnd();
if (!selection) return;
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(insert);
});
editor.focus();
},
@@ -522,6 +604,53 @@ function ResourceReferenceEditor({
);
}, [editor, multiline]);
// 粘贴解析:只有「粘贴文本里真的解析出了引用 token」且「这一整段真的插进了正文」时才接管,
// 其余一律返回 false 放行 Lexical 的默认导入——同 namespace 复制出来的 Lexical payload
// 本来就能还原真 chip,不含 token 的纯文本、图片文件粘贴也都保持原行为。
//
// 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体
// 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。
useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
const clipboardData = (event as ClipboardEvent | null)?.clipboardData;
if (!clipboardData || typeof clipboardData.getData !== 'function') {
return false;
}
// 同 namespace 的 Lexical payload 自带真 chip,不抢它的默认导入。
if (clipboardData.getData(LEXICAL_EDITOR_CLIPBOARD_TYPE)) return false;
const text = clipboardData.getData('text/plain');
if (!text.trim()) return false;
const providers = providersRef.current;
// 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。
const references = providers.flatMap(
(provider) => provider.lookup?.() ?? [],
);
const content = buildContentFromPastedText(text, references);
if (!content) return false;
// 先真的插进去,再决定接管这次粘贴:插入为空(取不到选区)时必须放行默认粘贴,
// 否则这段文字既没进我们的插入、又被 preventDefault 挡掉了默认导入,静默消失。
// 编辑器已经在一次更新里时 `editor.update` 会把回调排队,这时 `ranSync` 仍是 false
// 按原口径先接管,等队列里的那次插入落地。
let ranSync = false;
let inserted = false;
editor.update(
() => {
ranSync = true;
inserted = $insertContentAtSelection(content, providersRef.current);
},
// 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。
{ tag: PASTE_TAG },
);
if (ranSync && !inserted) return false;
event.preventDefault();
return true;
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor]);
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
@@ -796,16 +925,16 @@ function ProviderMentionMenu({
[onOpenChange, trigger],
);
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider`match` 始终保持纯函数,
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider`fuzzyLookup` 始终保持纯函数,
// 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。
useEffect(() => {
provider.onMenuQueryChange?.(query);
}, [provider, query]);
const options = useMemo(() => {
if (query === null || !provider.match) return [];
if (query === null || !provider.fuzzyLookup) return [];
return provider
.match(query)
.fuzzyLookup(query)
.map((reference) => new ReferenceMentionOption(reference));
}, [provider, query]);
@@ -1,5 +1,8 @@
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { ChatReference } from '../resourceReferences';
import {
type ChatReference,
normalizeMentionName,
} from '../resourceReferences';
import type { ReferenceProvider } from './types';
/** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */
@@ -8,7 +11,7 @@ export function attachmentReferenceFromPart(
): ChatReference {
return {
type: 'attachment',
name: part.name,
name: normalizeMentionName(part.name),
mediaType: part.mediaType,
size: part.size,
localPath: part.localPath,
@@ -32,7 +35,9 @@ export function createAttachmentReferenceProvider(): ReferenceProvider {
refresh: (reference: ChatReference): ChatReference | null =>
reference.type === 'attachment' ? reference : null,
mentionToken: (part) =>
part.type === 'agc_attachment_reference' ? `@${part.name}` : null,
part.type === 'agc_attachment_reference'
? `@${normalizeMentionName(part.name)}`
: null,
};
}
@@ -49,12 +49,14 @@ export function createResourceReferenceProvider({
}): ReferenceProvider {
return {
trigger: '@',
match: (query) =>
fuzzyLookup: (query) =>
resourceProviderData(assets)
.references.filter((reference) =>
resourceReferenceMatchesQuery(reference, query),
)
.slice(0, MENTION_OPTION_LIMIT),
// 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。
lookup: () => resourceProviderData(assets).references,
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
if (part.type !== 'agc_resource_reference') return null;
const asset = resourceProviderData(assets).byId.get(part.resourceId);

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