} title="上下文" summary={`${props.nodeCount} 个节点 · ${props.contextScope.length} 个范围`} onClick={() => onOpen("context")} />
} title="长期偏好" summary={profileSummary(props.profileView)} onClick={() => onOpen("profile")} />
+
} title="个人记忆" summary="批准、添加、导出导入;只影响你的会话" onClick={() => onOpen("memories")} />
} title="Skills · 用户技能库" summary={`${props.installedSkills.length} 个已安装 · 本轮启用 ${props.selectedSkillIds.length} 个`} onClick={() => onOpen("skills")} />
} title="工具与连接" summary="画布、技能参考文件、生成任务" onClick={() => onOpen("mcp")} />
} title="预算" summary={`每轮最多 ${props.maxCredits || "未设置"} 积分 · 固定计价模型`} onClick={() => onOpen("budget")} />
@@ -135,6 +138,14 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the
);
}
+function MemoriesWorkspace() {
+ return (
+
+ );
+}
+
function SkillsWorkspace({ props, theme, tab, onTabChange }: { props: AgentSettingsProps; theme: CanvasTheme; tab: "installed" | "market"; onTabChange: (tab: "installed" | "market") => void }) {
const query = props.skillSearch.trim().toLocaleLowerCase();
const source = tab === "installed" ? props.installedSkills.filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(query)) : props.marketSkills;
@@ -302,8 +313,8 @@ function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasThe
function TabButton({ active, label, onClick, theme }: { active: boolean; label: string; onClick: () => void; theme: CanvasTheme }) { return
; }
function SettingLabel({ label, hint }: { label: string; hint?: string }) { return
{label}{hint ? {hint} : null}
; }
function BudgetInput({ label, hint, value, onChange, theme }: { label: string; hint: string; value: string; onChange: (value: string) => void; theme: CanvasTheme }) { return
; }
-function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
-function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
+function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "memories" ? "个人记忆" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
+function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "memories" ? "只属于你,批准后才会注入会话" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; }
diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css
index 640d61f09..38bbe76c8 100644
--- a/web/src/components/canvas/canvas-cloud-agent.css
+++ b/web/src/components/canvas/canvas-cloud-agent.css
@@ -3,28 +3,11 @@
overscroll-behavior: contain;
}
-/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上。 */
+/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上;菜单本身与全站二级选择器同一套。 */
.agent-model-picker-popover {
z-index: calc(var(--z-modal-overlay) + 1000) !important;
}
-.agent-model-picker-popover .canvas-model-picker-menu {
- width: min(340px, calc(100vw - 24px)) !important;
- max-width: min(340px, calc(100vw - 24px)) !important;
-}
-
-.agent-model-picker-popover .canvas-model-picker-two-pane {
- grid-template-columns: minmax(0, 1fr) !important;
-}
-
-.agent-model-picker-popover .canvas-model-picker-brand-rail {
- display: none !important;
-}
-
-.agent-model-picker-popover .canvas-model-picker-model-pane {
- min-width: 0 !important;
-}
-
/* Flex 子页必须允许收缩,长模型名和 Skill 文案不能决定窗口宽度。 */
.canvas-agent-panel > div,
.canvas-agent-settings,
diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx
index 17f6dfd37..b2a450278 100644
--- a/web/src/components/model-picker.tsx
+++ b/web/src/components/model-picker.tsx
@@ -42,7 +42,7 @@ export function ModelPicker({
onMissingConfig,
showSelectedPrice = true,
showOptionPrices = showSelectedPrice,
- variant = "default",
+ variant = "creation",
requirements,
showConfiguredModelName = false,
}: ModelPickerProps) {
@@ -173,10 +173,8 @@ export function ModelPicker({
ref={menuRef}
data-canvas-no-zoom
className={cn(
- "canvas-model-picker-menu max-w-[calc(100vw-24px)]",
- creationVariant
- ? cn("creation-model-picker-menu", activeGroupKey === null ? "is-brand-list" : "is-model-list")
- : "w-[var(--panel-width-compact)]",
+ "canvas-model-picker-menu creation-model-picker-menu max-w-[calc(100vw-24px)]",
+ activeGroupKey === null ? "is-brand-list" : "is-model-list",
)}
style={
{
@@ -252,7 +250,7 @@ export function ModelPicker({
model={displayModel}
capability={capability}
theme={theme}
- creationVariant={creationVariant}
+ creationVariant
showConfiguredModelName={showConfiguredModelName}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}
@@ -283,8 +281,8 @@ export function ModelPicker({
arrow={false}
content={content}
classNames={{
- root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover", popoverClassName),
- container: cn("canvas-composer-popover-surface", creationVariant && "creation-model-picker-surface"),
+ root: cn("canvas-model-picker-popover", "creation-model-picker-popover", popoverClassName),
+ container: cn("canvas-composer-popover-surface", "creation-model-picker-surface"),
content: "canvas-composer-popover-content",
}}
>
diff --git a/web/src/lib/asset-record.ts b/web/src/lib/asset-record.ts
index 9d46bfa18..11c1d4073 100644
--- a/web/src/lib/asset-record.ts
+++ b/web/src/lib/asset-record.ts
@@ -66,8 +66,10 @@ export function parseAssetRecord(value: unknown): Asset {
const parsed = {
url: requireString(data, "url"),
storageKey: optionalString(data, "storageKey"),
- width: requirePositiveNumber(data, "width"),
- height: requirePositiveNumber(data, "height"),
+ // 上游视频结果经常不带 width/height。0 表示未知,不是伪造尺寸;
+ // 若按「必须大于 0」拒绝,生成成功并已扣费的视频会在入库时被判失败。
+ width: requireNonNegativeNumber(data, "width"),
+ height: requireNonNegativeNumber(data, "height"),
durationMs: optionalNonNegativeNumber(data, "durationMs"),
hasAudio: optionalBoolean(data, "hasAudio"),
bytes: requireNonNegativeNumber(data, "bytes"),
diff --git a/web/src/lib/canvas/agent-approval-presentation.ts b/web/src/lib/canvas/agent-approval-presentation.ts
index 5a699016c..15969a34d 100644
--- a/web/src/lib/canvas/agent-approval-presentation.ts
+++ b/web/src/lib/canvas/agent-approval-presentation.ts
@@ -41,7 +41,7 @@ function fieldLabel(key: string, nodeType: string): string {
}
function operation(value: unknown): AgentApprovalPreviewOperation | null {
- return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" ? value : null;
+ return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "plan_step" ? value : null;
}
function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null {
diff --git a/web/src/lib/canvas/cloud-agent-plan.ts b/web/src/lib/canvas/cloud-agent-plan.ts
new file mode 100644
index 000000000..acc7fe424
--- /dev/null
+++ b/web/src/lib/canvas/cloud-agent-plan.ts
@@ -0,0 +1,50 @@
+import type { CloudAgentPlanItem, CloudAgentUserQuestion } from "@/components/canvas/canvas-cloud-agent-chat-ui";
+
+type PlanCarrier = { planItems?: CloudAgentPlanItem[] };
+
+/**
+ * 从对话消息流里挑出**当前该展示的那份**待办清单。
+ *
+ * 一个对话会跨多轮运行,**每轮各有一条** `plan-${runId}` 清单消息,它们都在同一个 messages
+ * 数组里。这里必须取**最新**的那条:取第一条(`messages.find`)会把清单永远钉在第 1 轮的
+ * 初始状态 —— 后面怎么推进都不刷新、全部做完也不消失(2026-09-16 实测反馈)。
+ */
+export function latestAgentPlanItems(messages: readonly PlanCarrier[]): CloudAgentPlanItem[] {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const items = messages[index].planItems;
+ if (items?.length) return items;
+ }
+ return [];
+}
+
+/** 全部完成才收起清单;只要还有未开始/进行中,就一直占着输入框上方那一行。 */
+export function agentPlanVisible(items: readonly CloudAgentPlanItem[]): boolean {
+ return items.length > 0 && items.some((entry) => entry.status !== "done");
+}
+
+type QuestionCarrier = { role: string; question?: CloudAgentUserQuestion };
+
+/**
+ * 挑出**当前还没作答**的那道提问。
+ *
+ * 取最后一条带 question 的消息;如果它之后已经有用户消息,说明用户答过了,面板收起。
+ *
+ * 为什么需要它:运行中输入框是禁用的,模型在正文里提问用户根本答不了
+ * (生产实测它连问 4 轮、白烧十几步)。`ask_user` 让本轮干净收尾并把问题结构化,
+ * 这个选择器决定「现在该不该把选项面板亮给用户」。
+ */
+export function pendingAgentQuestion(messages: readonly QuestionCarrier[]): CloudAgentUserQuestion | undefined {
+ let index = -1;
+ for (let cursor = messages.length - 1; cursor >= 0; cursor -= 1) {
+ if (messages[cursor].question) {
+ index = cursor;
+ break;
+ }
+ }
+ if (index < 0) return undefined;
+ // 之后只要出现过用户发言,就说明这道题已经答过了。
+ for (let cursor = index + 1; cursor < messages.length; cursor += 1) {
+ if (messages[cursor].role === "user") return undefined;
+ }
+ return messages[index].question;
+}
diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx
index 8e580f473..25c00198f 100644
--- a/web/src/pages/admin/admin-route-pages.tsx
+++ b/web/src/pages/admin/admin-route-pages.tsx
@@ -13,6 +13,7 @@ const AccessSettingsPanel = lazy(() => import("./components/access-settings-pane
const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel"));
const FeatureAvailabilityPanel = lazy(() => import("./components/feature-availability-panel"));
const StorageResourcesPanel = lazy(() => import("./components/storage-resources-panel"));
+const AgentLessonsPanel = lazy(() => import("./components/agent-lessons-panel"));
export function AnalyticsPage() {
const { references } = useAdminContext();
@@ -106,3 +107,11 @@ export function StorageResourcesPage() {
);
}
+
+export function AgentLessonsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx
index 7a7dc11cd..08ac736cd 100644
--- a/web/src/pages/admin/components/admin-shell.tsx
+++ b/web/src/pages/admin/components/admin-shell.tsx
@@ -27,6 +27,7 @@ import {
PlugZap,
RadioTower,
RefreshCw,
+ Sparkles,
Settings2,
ShieldAlert,
ShieldCheck,
@@ -77,6 +78,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
label: "运营",
items: [
{ path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon:
},
+ { path: "/admin/agent-lessons", label: "Agent 记忆", description: "按用户查看个人记忆", icon:
},
{ path: "/admin/payments", label: "支付充值", description: "支付渠道、订单与对账", icon:
},
{ path: "/admin/credit-operations", label: "积分运营", description: "人工调账与异常计费", icon:
},
{ path: "/admin/redemption-codes", label: "兑换码", description: "生成与查看兑换码批次", icon:
},
diff --git a/web/src/pages/admin/components/agent-lessons-panel.tsx b/web/src/pages/admin/components/agent-lessons-panel.tsx
new file mode 100644
index 000000000..e2fe1c518
--- /dev/null
+++ b/web/src/pages/admin/components/agent-lessons-panel.tsx
@@ -0,0 +1,239 @@
+import { App, Button, Input, Select, Space, Tag, Tooltip } from "antd";
+import type { ColumnsType } from "antd/es/table";
+import { RefreshCw, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { agentLessonCategoryLabel, deleteAdminAgentLesson, listAdminAgentLessons, type AdminAgentLesson } from "@/services/api/admin-agent-lessons";
+import { listAdminUsers, type AdminUser } from "@/services/api/auth";
+import { AdminDataTable, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
+
+function authorLabel(record: AdminAgentLesson) {
+ if (record.authorDisplayName && record.authorUsername) return `${record.authorDisplayName}(${record.authorUsername})`;
+ return record.authorDisplayName || record.authorUsername || record.authorUserId || "未知用户";
+}
+
+export default function AgentLessonsPanel() {
+ const { message, modal } = App.useApp();
+ const [items, setItems] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [status, setStatus] = useState("all");
+ const [keyword, setKeyword] = useState("");
+ const [userId, setUserId] = useState();
+ const [users, setUsers] = useState([]);
+ const [userSearch, setUserSearch] = useState("");
+ const [searchingUsers, setSearchingUsers] = useState(false);
+ const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
+ const debouncedUserSearch = useDebouncedValue(userSearch.trim(), 250);
+ const seqRef = useRef(0);
+ const userSearchRef = useRef(0);
+
+ const load = useCallback(async () => {
+ const seq = ++seqRef.current;
+ setLoading(true);
+ try {
+ const data = await listAdminAgentLessons({
+ status: status === "all" ? undefined : status,
+ userId,
+ keyword: debouncedKeyword || undefined,
+ limit: 100,
+ });
+ if (seq !== seqRef.current) return;
+ setItems(data.lessons || []);
+ } catch (error) {
+ if (seq === seqRef.current) message.error(error instanceof Error ? error.message : "读取记忆失败");
+ } finally {
+ if (seq === seqRef.current) setLoading(false);
+ }
+ }, [debouncedKeyword, message, status, userId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ useEffect(() => {
+ const requestId = ++userSearchRef.current;
+ setSearchingUsers(true);
+ void listAdminUsers({ keyword: debouncedUserSearch || undefined, page: 1, pageSize: 50 })
+ .then((result) => {
+ if (requestId !== userSearchRef.current) return;
+ setUsers((current) => {
+ const selected = current.find((user) => user.id === userId);
+ if (selected && !result.users.some((user) => user.id === selected.id)) return [selected, ...result.users];
+ return result.users;
+ });
+ })
+ .catch((error) => {
+ if (requestId === userSearchRef.current) message.error(error instanceof Error ? error.message : "搜索用户失败");
+ })
+ .finally(() => {
+ if (requestId === userSearchRef.current) setSearchingUsers(false);
+ });
+ }, [debouncedUserSearch, message, userId]);
+
+ const remove = (record: AdminAgentLesson) => {
+ modal.confirm({
+ title: `删除「${record.topic}」`,
+ content: `这是 ${authorLabel(record)} 的个人记忆。删除后该用户的 Agent 也无法召回。确认删除?`,
+ okText: "删除",
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ await deleteAdminAgentLesson(record.id);
+ message.success("已删除");
+ await load();
+ },
+ });
+ };
+
+ const columns: ColumnsType = [
+ {
+ title: "用户",
+ key: "author",
+ width: 168,
+ render: (_, record) => (
+
+ {authorLabel(record)}
+ {record.authorUserId ? {record.authorUserId} : null}
+
+ ),
+ },
+ {
+ title: "分类",
+ dataIndex: "category",
+ width: 92,
+ render: (value: string) => {agentLessonCategoryLabel(value)},
+ },
+ {
+ title: "主题",
+ dataIndex: "topic",
+ width: 180,
+ render: (value: string, record) => (
+
+ {value}
+ {record.source ? 来源:{record.source} : null}
+
+ ),
+ },
+ {
+ title: "适用场景 / 做法",
+ dataIndex: "situation",
+ render: (_: string, record) => {
+ const steps = record.steps || [];
+ return (
+
+
{record.situation}
+ {record.lesson ?
{record.lesson} : null}
+ {steps.length ? (
+
+ {steps.map((step, index) => (
+ -
+ {step.tool}
+ {" — "}
+ {step.action}
+ {step.note ? ({step.note}) : null}
+
+ ))}
+
+ ) : null}
+
+ );
+ },
+ },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 96,
+ render: (value: string) => ,
+ },
+ {
+ title: (
+
+ 注入
+
+ ),
+ dataIndex: "injected",
+ width: 64,
+ render: (value: number) => {value ?? 0},
+ },
+ {
+ title: (
+
+ 查阅
+
+ ),
+ dataIndex: "hits",
+ width: 64,
+ render: (value: number) => {value ?? 0},
+ },
+ {
+ title: "操作",
+ key: "actions",
+ width: 72,
+ render: (_, record) => (
+
+ } onClick={() => remove(record)} />
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ 记忆归用户自己批准和管理。这里只做巡查:可按用户、状态、关键词筛选,必要时删除违规内容。批准入口在用户的「设置 → Agent 记忆」。
+
+
+
+
+ }
+ table={{
+ rowKey: "id",
+ size: "small",
+ loading,
+ columns,
+ dataSource: items,
+ pagination: { defaultPageSize: 20, showSizeChanger: true },
+ }}
+ empty={}
+ />
+
+ );
+}
diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx
index 9ec3e75fb..b23663a04 100644
--- a/web/src/pages/assets/index.tsx
+++ b/web/src/pages/assets/index.tsx
@@ -1434,7 +1434,7 @@ function assetArchiveFacts(asset: LibraryAsset) {
{ label: "分类", value: assetCategoryLabel(asset.category) },
];
if (asset.kind === "image" || asset.kind === "video") {
- facts.push({ label: "尺寸", value: `${asset.data.width}x${asset.data.height}` });
+ facts.push({ label: "尺寸", value: assetSizeLabel(asset.data.width, asset.data.height) });
}
if (asset.kind === "video" || asset.kind === "audio") {
facts.push({ label: "时长", value: formatAssetClock(asset.data.durationMs) || "未知" });
@@ -1454,7 +1454,11 @@ function assetSummary(asset: LibraryAsset) {
if (asset.kind === "text") return asset.data.content;
if (asset.kind === "audio") return `${formatAssetDuration(asset.data.durationMs)} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
if (asset.kind === "model") return `${asset.data.fileName} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
- return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
+ return `${assetSizeLabel(asset.data.width, asset.data.height)} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
+}
+
+function assetSizeLabel(width: number, height: number) {
+ return width > 0 && height > 0 ? `${width}x${height}` : "未知";
}
function StorageTag({ asset }: { asset: LibraryAsset }) {
diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx
index d1c494f2a..d9e0fe6eb 100644
--- a/web/src/pages/projects/detail/chapters.tsx
+++ b/web/src/pages/projects/detail/chapters.tsx
@@ -623,7 +623,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD