合并最新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
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:
+13
@@ -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
|
||||
|
||||
@@ -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 Patch(tauri-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 7386(tauri-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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+164
-35
@@ -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 之外的每个字符照原样保留。
|
||||
*
|
||||
* 认不出的引用 part(provider 的 `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]);
|
||||
|
||||
|
||||
+8
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
Reference in New Issue
Block a user