Integrate delivered Code navigation and Yuxi channel recovery into Canvas trial source

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 00:10:15 +08:00
1 parent 2ca6e3199d
commit 3fe3fdb1c0
38 files changed
+1602 -198

No files matched your search

-2
View File
@@ -49,7 +49,6 @@ import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWelcomeHero } from './CodingWelcomeHero';
import { CodingStartHeading } from './CodingStartHeading';
import { CodingChatBody } from './CodingChatBody';
import { CommunityInvitation } from '@/components/community/CommunityInvitation';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
import { useAuthStore } from '@/stores/auth';
import { TeacherChatPanel } from './TeacherChatPanel';
@@ -916,7 +915,6 @@ export function CodingChatPanel({
</div>
<div hidden={agentBrowserOpen} className="classroom-composer-view shrink-0">
{showWelcome && <CommunityInvitation />}
{selectedConversation?.archivedAt ? (
<div className="m-4 flex items-center justify-between gap-3 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
<span>{running ? '此对话已归档,任务仍在运行。' : '此对话已归档,恢复后可继续聊天。'}</span>
+35 -21
View File
@@ -4,7 +4,7 @@ import {
Archive,
ArchiveRestore,
LoaderCircle,
MessageSquarePlus,
Plus,
MoreHorizontal,
Pencil,
Settings2,
@@ -18,13 +18,22 @@ import type { CodingConversationMetadata } from '@/types/coding-project';
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export function CodingProjectConversations({ projectId, compact = false }: { projectId: string; compact?: boolean }) {
export function CodingProjectConversations({
projectId,
compact = false,
archived = false,
onArchivedChange,
}: {
projectId: string;
compact?: boolean;
archived?: boolean;
onArchivedChange?: (archived: boolean) => void;
}) {
const navigate = useNavigate();
const conversations = useCodingWorkspaceStore((state) => state.conversations);
const activeProjectId = useCodingWorkspaceStore((state) => state.activeProjectId);
const selected = useCodingConversationStore((state) => state.selectedConversationId);
const summaries = useCodingConversationStore((state) => state.summariesByConversationId);
const [archived, setArchived] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [rename, setRename] = useState<CodingConversationMetadata | null>(null);
const selectedConversation = conversations.find((item) => item.id === selected);
@@ -36,9 +45,9 @@ export function CodingProjectConversations({ projectId, compact = false }: { pro
previous?.archivedAt &&
!selectedConversation?.archivedAt
)
setArchived(false);
onArchivedChange?.(false);
previousSelection.current = selectedConversation;
}, [selectedConversation]);
}, [selectedConversation, onArchivedChange]);
if (activeProjectId !== projectId) return null;
const act = async (key: string, operation: () => Promise<unknown>) => {
setBusy(key);
@@ -76,34 +85,34 @@ export function CodingProjectConversations({ projectId, compact = false }: { pro
aria-label="新建对话"
title="新建对话"
disabled={!!busy}
className={`flex flex-1 items-center gap-1.5 rounded py-2 text-xs hover:bg-surface-tertiary ${compact ? "justify-center px-1" : "px-2"}`}
className={`flex shrink-0 items-center gap-1.5 rounded py-2 pr-2 text-xs hover:bg-surface-tertiary ${compact ? "pl-0" : "pl-2"}`}
onClick={() =>
void act('new', async () => {
const workspace = codingWorkspaceStore.getState();
const conversation = await workspace.createConversation();
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
setArchived(false);
onArchivedChange?.(false);
await select(conversation);
}
})
}
>
<MessageSquarePlus className="h-3.5 w-3.5" />
{!compact && '新对话'}
</button>
<button
aria-label={archived ? '返回最近对话' : '查看归档对话'}
title={archived ? '返回最近对话' : '查看归档对话'}
onClick={() => setArchived(!archived)}
className="p-2"
>
<Archive className="h-3.5 w-3.5" />
</button>
<button aria-label="项目设置" title="项目设置" onClick={() => navigate('/project-config')} className="p-2">
<Settings2 className="h-3.5 w-3.5" />
<Plus aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
新对话
</button>
</div>
{archived && <p className="px-2 py-1 text-xs text-muted-foreground">已归档</p>}
{archived && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1 text-xs text-muted-foreground">
<span>已归档对话</span>
<button
type="button"
onClick={() => onArchivedChange?.(false)}
className="rounded py-1 text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
>
返回最近对话
</button>
</div>
)}
{conversations
.filter((item) => Boolean(item.archivedAt) === archived)
.sort((a, b) =>
@@ -175,6 +184,11 @@ export function CodingProjectConversations({ projectId, compact = false }: { pro
await codingWorkspaceStore
.getState()
.patchConversation(item.id, { archived: !item.archivedAt });
if (
item.archivedAt &&
codingWorkspaceStore.getState().activeProjectId === projectId
)
onArchivedChange?.(false);
if (
!item.archivedAt &&
codingConversationStore.getState().selectedConversationId === item.id
+2 -4
View File
@@ -1,10 +1,8 @@
import { Sparkles, Star } from 'lucide-react';
import mark from '@/assets/consultations/mark.svg';
import { CommunityLink } from '@/components/community/CommunityLink';
export function CodingStartHeading() {
return <div className="coding-start-heading" data-testid="coding-start-heading">
<span className="coding-start-mark"><img src={mark} alt="麦洛" /><Sparkles aria-hidden="true" /><Star aria-hidden="true" /></span>
<p className="coding-start-eyebrow">小点子,也能变成大惊喜</p>
<CommunityLink variant="welcome" />
<h1>今天,你想<span>创造什么?</span></h1>
<p className="coding-start-caption">一个游戏、一段故事,或是一个天马行空的想法。</p>
</div>;
+17 -11
View File
@@ -8,25 +8,31 @@
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header { padding-top: 0; padding-bottom: 0; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="classroom-sidebar-navigation"] { align-items: center; }
[data-testid="sidebar"][data-classroom="true"] .classroom-brand { height: 46px; gap: 10px; padding: 0 2px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 41px; margin-top: 10px; gap: 9px; padding: 8px 11px; border: 1px solid #d5e1ed; border-radius: 11px; background: #f8faf5; box-shadow: 0 3px 0 #e3eaf3; font-size: 14px; font-weight: 500; line-height: 20px; white-space: normal; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; border-color: #b7c6a4; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 48px; margin-top: 10px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; box-shadow: none; color: #26384d; font-size: 14px; font-weight: 600; line-height: 20px; white-space: normal; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; }
[data-testid="sidebar"][data-classroom="true"] .community-link-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; }
[data-testid="sidebar"][data-classroom="true"] .community-link-icon svg { width: 20px; height: 20px; }
[data-testid="sidebar"][data-classroom="true"] .community-link-arrow { margin-left: auto; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-brand:hover { background: #eaf1f8; }
.classroom-brand-mark { display: grid; place-items: center; width: 40px; height: 42px; flex-shrink: 0; border: 1px solid #f1d1aa; border-radius: 12px; background: #fff0dd; box-shadow: 0 3px 0 #ecdbc5; }
.classroom-brand-caption { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-content { padding: 10px 12px 14px; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] { min-height: 41px; gap: 9px; padding: 8px 11px; border: 1px solid #d5e1ed; border-radius: 11px; background: #fff; color: #304968; box-shadow: 0 3px 0 #e3eaf3; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] { min-height: 48px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; color: #26384d; box-shadow: none; font-size: 14px; font-weight: 600; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] svg { color: #c44b23; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"]:hover { background: #fff7ef; border-color: #ecc5a5; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"]:hover { background: #fff0e6; }
.classroom-create-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #f0d9c7; border-radius: 12px; background: #ffead9; }
.classroom-create-project-icon svg { width: 20px; height: 20px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-projects { margin-top: 8px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-projects-heading { padding: 6px 3px; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-projects-heading > svg { display: none; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card { min-height: 56px; gap: 9px; padding: 8px; border: 1px solid transparent; border-radius: 13px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card:hover { background: #fff; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card[aria-current="page"] { background: #fff; border-color: #c9d9e9; box-shadow: 0 3px 0 #e2eaf3; }
.classroom-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 34px; height: 37px; border-radius: 10px; background: #ffead9; color: #a85026; }
.classroom-project-icon svg { width: 21px; height: 21px; }
.classroom-project-icon[data-tone="green"] { background: #e1f1d9; color: #507d39; }
.classroom-project-icon[data-tone="blue"] { background: #deedfb; color: #3a6d9d; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card { min-height: 48px; gap: 8px; padding: 8px 40px 8px 8px; border: 0; border-radius: 8px; box-shadow: none; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card:hover { background: #eaf1f8; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card[aria-current="page"] { background: hsl(var(--brand-soft)); box-shadow: none; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card > span:last-child { font-size: 14px; font-weight: 600; }
.classroom-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #f0d9c7; border-radius: 12px; background: #ffead9; color: #a85026; }
.classroom-project-icon svg { width: 20px; height: 20px; }
.classroom-project-icon[data-tone="green"] { border-color: #cfe3c6; background: #e1f1d9; color: #507d39; }
.classroom-project-icon[data-tone="blue"] { border-color: #cddfec; background: #deedfb; color: #3a6d9d; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="project-conversations"] { padding: 8px 0 4px; font-size: 13px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-conversation-actions { gap: 2px; margin-bottom: 4px; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-conversation-actions button { min-height: 32px; min-width: 30px; border-radius: 8px; }
+90 -18
View File
@@ -69,29 +69,29 @@ const blockerText: Record<string, string> = {
channel_sync_degraded: '正在确认微信的使用状态。',
};
const unsettled = (account: CloudUserChannelAccount) =>
account.blockers.includes('wechat_disconnect_pending') ||
Boolean(account.sync_state && account.sync_state !== 'ready') ||
account.blockers.includes('channel_sync_degraded') ||
(['paused', 'disabled'].includes(account.desired_state ?? '') && account.enabled) ||
(account.desired_state === 'enabled' && !account.enabled);
function accountState(account: CloudUserChannelAccount, uncertain: boolean) {
function accountState(account: CloudUserChannelAccount, uncertain: boolean, targetDisabled = false) {
if (uncertain || unsettled(account)) return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' };
if (!account.target_agent_slug) return { text: '未绑定', tone: 'bg-[#f0edf8] text-[#75618e]' };
if (['login_required', 'verification_required'].includes(account.health))
return { text: '需要重新扫码', tone: 'bg-[#fff0d8] text-[#876033]' };
if (targetDisabled) return { text: '智能体已停用', tone: 'bg-[#fff0d8] text-[#876033]' };
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' };
return account.enabled
? { text: '使用中', tone: 'bg-[#e7f4ec] text-[#3f7458]' }
: { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' };
}
async function loadAvailableAgents(): Promise<CloudAgentDraft[]> {
async function loadAgents(): Promise<CloudAgentDraft[]> {
const items: CloudAgentDraft[] = [];
let cursor: string | null = null;
do {
const page = await cloudAgentsApi.list(cursor);
items.push(
...page.agents.filter((agent) => Boolean(agent.published_version) && agent.enabled !== false)
);
items.push(...page.agents);
cursor = page.next_cursor;
} while (cursor);
return items;
@@ -103,6 +103,10 @@ export function ChannelAccounts() {
const initial = useRef({ account: params.get('account') ?? '', target: params.get('target') ?? '' });
const [accounts, setAccounts] = useState<CloudUserChannelAccount[]>([]);
const [agents, setAgents] = useState<CloudAgentDraft[]>([]);
const agentsRef = useRef<CloudAgentDraft[]>([]);
const availableAgents = agents.filter(
(agent) => Boolean(agent.published_version) && agent.enabled !== false
);
const [loading, setLoading] = useState(true);
const [accountsLoaded, setAccountsLoaded] = useState(false);
const [agentsLoaded, setAgentsLoaded] = useState(false);
@@ -126,11 +130,15 @@ export function ChannelAccounts() {
const intents = useRef(new Map<string, { fingerprint: string; id: string }>());
const checks = useRef(new Map<string, Expected>());
const readGeneration = useRef(0);
const ownerPending = useRef(false);
const refreshGeneration = useRef(0);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const selected = accounts.find((account) => account.id === flow?.accountId);
const recoveryBlocked = pending.some((item) => /channelaccount/iu.test(item.operation));
const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded;
const ownerLogoutPending = accounts.some((account) =>
account.blockers.includes('wechat_disconnect_pending')
);
const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded || ownerLogoutPending;
const pendingFor = (id: string) =>
pending.some(
(item) => /channelaccount/iu.test(item.operation) && item.input.channel_account_id === id
@@ -147,6 +155,9 @@ export function ChannelAccounts() {
const generation = ++readGeneration.current;
const page = await cloudAgentsApi.call('channelAccounts', {});
if (alive.current && generation === readGeneration.current) {
ownerPending.current = page.items.some((account) =>
account.blockers.includes('wechat_disconnect_pending')
);
setAccounts(page.items);
setAccountsLoaded(true);
for (const [id, expected] of checks.current)
@@ -165,12 +176,13 @@ export function ChannelAccounts() {
const generation = ++refreshGeneration.current;
const [accountResult, agentResult, recoveryResult] = await Promise.allSettled([
readAccounts(),
loadAvailableAgents(),
loadAgents(),
cloudAgentsApi.recovery(),
]);
if (!alive.current || generation !== refreshGeneration.current) return;
if (accountResult.status === 'rejected') setError(errorText(accountResult.reason));
if (agentResult.status === 'fulfilled') {
agentsRef.current = agentResult.value;
setAgents(agentResult.value);
setAgentsLoaded(true);
setAgentsError('');
@@ -222,7 +234,7 @@ export function ChannelAccounts() {
return id;
};
const run = async <T,>(key: string, accountId: string, task: () => Promise<T>): Promise<T | null> => {
if (busyRef.current || recoveryBlocked || !recoveryLoaded) return null;
if (busyRef.current || recoveryBlocked || !recoveryLoaded || ownerPending.current) return null;
busyRef.current = true;
const generation = ++refreshGeneration.current;
setBusy(key);
@@ -230,6 +242,13 @@ export function ChannelAccounts() {
setError('');
setNotice('');
try {
const observedAccounts = await readAccounts();
if (
ownerPending.current ||
observedAccounts.some((account) => account.blockers.includes('wechat_disconnect_pending'))
)
throw new Error('微信退出还在确认中,请先处理原来的操作。');
if (!alive.current) return null;
return await task();
} catch (failure) {
if (alive.current) {
@@ -299,6 +318,7 @@ export function ChannelAccounts() {
return fresh!;
};
const route = async (account: CloudUserChannelAccount, target: string) => {
if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。');
const input = {
channel_account_id: account.id,
target_agent_slug: target,
@@ -328,6 +348,9 @@ export function ChannelAccounts() {
throw new Error('绑定的智能体已发生变化,请返回上一步确认。');
if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length)
throw new Error('微信还没有准备好,请稍后重试完成绑定。');
if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。');
if (agentsRef.current.some((agent) => agent.slug === target && agent.enabled === false))
throw new Error('当前智能体已停用,请更换智能体后再使用。');
const activate = flowRef.current?.mode !== 'reconnect';
if (!account.enabled && activate) {
const input = { channel_account_id: id, expected_revision: account.revision };
@@ -381,7 +404,7 @@ export function ChannelAccounts() {
!current ||
!agentsLoaded ||
agentsError ||
!agents.some((agent) => agent.slug === current.target) ||
!availableAgents.some((agent) => agent.slug === current.target) ||
disabled
)
return;
@@ -420,6 +443,13 @@ export function ChannelAccounts() {
const setEnabled = async (account: CloudUserChannelAccount) => {
setMore('');
const enabled = !account.enabled;
if (
enabled &&
agentsRef.current.some(
(agent) => agent.slug === account.target_agent_slug && agent.enabled === false
)
)
return;
const key = enabled ? 'enable' : 'pause';
const fresh = await run(key, account.id, async () => {
const input = { channel_account_id: account.id, expected_revision: account.revision };
@@ -472,7 +502,8 @@ export function ChannelAccounts() {
pollingId !== qrAccountId ||
!pollingSession ||
['confirmed', 'failed', 'expired'].includes(pollingStatus) ||
recoveryBlocked
recoveryBlocked ||
ownerLogoutPending
)
return;
let stopped = false;
@@ -501,7 +532,7 @@ export function ChannelAccounts() {
};
// The flow ref identifies the current user-initiated connection without restarting a poll on unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pollingId, pollingSession, pollingStatus, qrAccountId, recoveryBlocked]);
}, [pollingId, pollingSession, pollingStatus, qrAccountId, recoveryBlocked, ownerLogoutPending]);
const verify = async () => {
if (!selected || !qr || !verifyCode.trim()) return;
const input = {
@@ -538,6 +569,27 @@ export function ChannelAccounts() {
{agentsError}。已有微信仍可以暂停或解除绑定。
</p>
)}
{ownerLogoutPending && (
<div
role="status"
className="rounded-2xl bg-[#fff0d8] px-4 py-3 text-sm leading-6 text-[#876033]"
>
<p>有一个微信的退出操作还在确认中,暂时不能添加、扫码或修改微信绑定。</p>
<p>
{recoveryBlocked
? '请通过待处理记录确认原来的操作。'
: '请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。'}
</p>
<Button
variant="ghost"
size="sm"
disabled={Boolean(busy)}
onClick={() => void refresh().catch((failure) => setError(errorText(failure)))}
>
刷新确认
</Button>
</div>
)}
{notice && (
<p role="status" className="rounded-2xl bg-[#e7f4ec] px-4 py-3 text-sm text-[#3f7458]">
{notice}
@@ -612,7 +664,7 @@ export function ChannelAccounts() {
accountsLoaded &&
agentsLoaded &&
!agentsError &&
!agents.length &&
!availableAgents.length &&
!flow &&
agentHint}
{loading ? (
@@ -630,7 +682,7 @@ export function ChannelAccounts() {
) : !accounts.length ? (
agentsLoaded &&
!agentsError &&
agents.length > 0 && (
availableAgents.length > 0 && (
<div className="rounded-[28px] border border-[#dfe9e2] bg-[#f1f8f2] px-6 py-14 text-center">
<Link2 className="mx-auto h-10 w-10 text-[#678b70]" />
<h2 className="mt-5 text-xl font-bold text-[#303747]">把你的智能体带到微信吧</h2>
@@ -655,15 +707,25 @@ export function ChannelAccounts() {
>
{accounts.map((account) => {
const agent = agents.find((item) => item.slug === account.target_agent_slug);
const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id);
const state = accountState(account, uncertain || busyAccount === account.id);
const uncertain =
!recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id);
const targetDisabled = agents.some(
(agent) => agent.slug === account.target_agent_slug && agent.enabled === false
);
const state = accountState(
account,
uncertain || busyAccount === account.id,
targetDisabled
);
const needsScan =
!targetDisabled &&
account.target_agent_slug &&
['login_required', 'verification_required'].includes(account.health);
const blocked = disabled || uncertain;
const changeBlocked =
blocked || unsettled(account) || !agentsLoaded || Boolean(agentsError);
const canResume =
!targetDisabled &&
!unsettled(account) &&
account.health === 'connected' &&
Boolean(account.target_agent_slug) &&
@@ -709,6 +771,16 @@ export function ChannelAccounts() {
</>
)}
</div>
{targetDisabled && (
<p className="mb-3 text-xs leading-5 text-[#876033]">
当前智能体已停用,可以更换智能体。
</p>
)}
{account.blockers.includes('wechat_disconnect_pending') && (
<p className="mb-3 text-xs leading-5 text-[#876033]">
这个微信的退出操作还在确认中。
</p>
)}
{usableBlockers(account)
.filter((item) => blockerText[item])
.map((item) => (
@@ -852,7 +924,7 @@ export function ChannelAccounts() {
</>
) : flow?.step === 'choose' ? (
<>
{!agentsError && agentsLoaded && !agents.length ? (
{!agentsError && agentsLoaded && !availableAgents.length ? (
agentHint
) : (
<div
@@ -860,7 +932,7 @@ export function ChannelAccounts() {
aria-label="选择智能体"
className="grid max-h-72 gap-2 overflow-y-auto"
>
{agents.map((agent) => (
{availableAgents.map((agent) => (
<label
key={agent.slug}
className={`relative flex w-full cursor-pointer items-center gap-3 rounded-2xl border p-4 text-left focus-within:outline focus-within:outline-2 focus-within:outline-[#8468a5] ${flow.target === agent.slug ? 'border-[#a28abb] bg-[#f0eafa]' : 'border-[#e4e2de] bg-white'}`}
@@ -897,7 +969,7 @@ export function ChannelAccounts() {
checkingIds.includes(flow.accountId) ||
!agentsLoaded ||
Boolean(agentsError) ||
!agents.some((agent) => agent.slug === flow.target) ||
!availableAgents.some((agent) => agent.slug === flow.target) ||
(flow.mode === 'switch' && flow.target === selected?.target_agent_slug)
}
onClick={() => void chooseAgent()}
+3 -3
View File
@@ -100,6 +100,9 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
<div className="agent-knowledge-create-action">
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
<div ref={setUploadTarget} className="agent-knowledge-upload-action">
{!selectedKnowledge && <Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>}
</div>
</div>
{creationDetails}
</section>
@@ -119,9 +122,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, ref
</option>)}
</select>
<Button type="button" variant="outline" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
<div ref={setUploadTarget} className="agent-knowledge-upload-action">
{!selectedKnowledge && <Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>}
</div>
</div>
<div className="space-y-4">
{errorTarget === 'management' && error && <p role="alert" className="text-destructive">{error}</p>}
+52 -5
View File
@@ -20,6 +20,17 @@ const labels: Record<string, string> = {
unbindChannelAccount: '解除微信绑定',
};
const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']);
const channelAttentionMessages: Record<string, string> = {
channel_operation_legacy: '这次旧微信操作无法安全恢复,请联系管理员核对后再操作。',
identity_conflict: '当前账号关系不一致,请刷新微信状态并核对账号。',
target_agent_unavailable: '当前智能体暂不可用,请先确认微信状态和目标智能体。',
};
type ChannelAttention = { code: string; message: string };
function channelAttention(item: CloudPendingOperation, code: unknown): ChannelAttention | undefined {
const message = /channel|wechat/iu.test(item.operation) && typeof code === 'string'
? channelAttentionMessages[code] : undefined;
return message ? { code: code as string, message } : undefined;
}
export function CloudRecovery({ pending, onResolved, triggerContainer }: { pending: CloudPendingOperation[]; onResolved: () => void; triggerContainer?: HTMLElement | null }) {
const [open, setOpen] = useState(false);
@@ -27,6 +38,7 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
const [error, setError] = useState('');
const [errorRecord, setErrorRecord] = useState('');
const [notice, setNotice] = useState('');
const [attentionRecords, setAttentionRecords] = useState<Record<string, ChannelAttention>>({});
const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null);
const inFlight = useRef(false);
const awaitingKeyClose = useRef(false);
@@ -37,7 +49,7 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
useEffect(() => { if (triggerContainer === null && !busy) setOpen(false); }, [triggerContainer, busy]);
const close = () => { if (!inFlight.current) setOpen(false); };
const resolve = async (item: CloudPendingOperation, discard = false) => {
if (inFlight.current || awaitingKeyClose.current) return;
if (inFlight.current || awaitingKeyClose.current || (!discard && attentionRecords[item.id])) return;
inFlight.current = true;
setBusy(item.id); setError(''); setErrorRecord(''); setNotice('');
try {
@@ -48,7 +60,11 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。');
} else if (response.pending) {
setResult(null);
setNotice('这次操作还在确认中,请稍后再查看。');
const receipt = response.result !== null && typeof response.result === 'object'
? response.result as Record<string, unknown> : {};
const attention = channelAttention(item, receipt.error_code);
if (attention) setAttentionRecords(previous => ({ ...previous, [item.id]: attention }));
setNotice(attention ? '' : '这次操作还在确认中,请稍后再查看。');
} else if (response.retired) {
setResult(null);
setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。');
@@ -63,6 +79,11 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
setErrorRecord(item.id);
const message = item.operation === 'publish' && !discard ? applicationErrorMessage(e)
: e instanceof Error ? e.message : '这次没能完成,请稍后再试。';
const attention = e instanceof AppError ? channelAttention(item, e.details?.backendCode) : undefined;
if (!discard && attention) {
setAttentionRecords(previous => ({ ...previous, [item.id]: attention }));
setResult(null);
}
if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') {
setError(`${labels[item.operation] ?? '操作'}失败:${message}`);
onResolved();
@@ -70,6 +91,27 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
}
finally { inFlight.current = false; if (alive.current) setBusy(''); }
};
const recheck = async (item: CloudPendingOperation) => {
if (inFlight.current || awaitingKeyClose.current || !attentionRecords[item.id]
|| attentionRecords[item.id].code === 'channel_operation_legacy') return;
inFlight.current = true;
setBusy(item.id); setError(''); setErrorRecord(''); setNotice('');
try {
await Promise.all([cloudAgentsApi.call('channelAccounts', {}), cloudAgentsApi.list()]);
if (!alive.current) return;
setAttentionRecords(previous => {
const next = { ...previous };
delete next[item.id];
return next;
});
setNotice('微信状态已刷新。核对当前账号和目标智能体后,可以再次确认原操作。');
onResolved();
} catch (e) {
if (!alive.current) return;
setErrorRecord(item.id);
setError(e instanceof Error ? e.message : '微信状态暂时无法刷新,请稍后再试。');
} finally { inFlight.current = false; if (alive.current) setBusy(''); }
};
if (!pending.length && !result && !error && !notice && !open) return null;
const trigger = <DialogTrigger asChild><Button type="button" variant="ghost" className="agent-recovery-trigger" aria-label={title + (pending.length ? `(${pending.length})` : '')}>
<History aria-hidden="true" size={16} />{title}{pending.length > 0 && <span className="agent-recovery-count" aria-hidden="true">{pending.length}</span>}
@@ -93,11 +135,14 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
<p className="agent-recovery-time">{new Date(item.created_at).toLocaleString()}</p>
{item.operation === 'createAgent' && <p className="agent-recovery-record-help">还不确定有没有创建成功,先看看列表里有没有它。</p>}
{item.operation === 'publish' && errorRecord !== item.id && <p className="agent-recovery-record-help">{APPLICATION_RETRY_MESSAGE}</p>}
{attentionRecords[item.id] && <p className="agent-recovery-record-help" role="status">{attentionRecords[item.id].message}</p>}
{typeof item.input.query === 'string' && <p className="mt-1 line-clamp-2 text-muted-foreground">{item.input.query}</p>}</div>
{retiredChannelOperations.has(item.operation)
? <Button size="sm" variant="ghost" disabled={actionsDisabled} onClick={() => void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}</Button>
: <div className="agent-recovery-record-actions"><Button size="sm" disabled={actionsDisabled} onClick={() => void resolve(item)}>{item.operation === 'publish'
? busy === item.id ? '正在应用…' : '应用' : busy === item.id ? '正在处理…' : '再试一次'}</Button>
: <div className="agent-recovery-record-actions">{attentionRecords[item.id] && attentionRecords[item.id].code !== 'channel_operation_legacy'
? <Button size="sm" disabled={actionsDisabled} onClick={() => void recheck(item)}>{busy === item.id ? '正在刷新…' : '刷新状态'}</Button>
: <Button size="sm" disabled={actionsDisabled || Boolean(attentionRecords[item.id])} onClick={() => void resolve(item)}>{item.operation === 'publish'
? busy === item.id ? '正在应用…' : '应用' : busy === item.id ? '正在处理…' : '再试一次'}</Button>}
<Button size="sm" variant="ghost" disabled={actionsDisabled} onClick={() => void resolve(item, true)}>不再提醒</Button></div>}
</article>)}
{result && <div className="agent-recovery-result space-y-2"><p>{result.operation === 'createAgent'
@@ -113,7 +158,9 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
{error && <p role="alert" className="text-destructive">{error}</p>}
{notice && <p role="status" className="text-muted-foreground">{notice}</p>}
{!pending.length && !result && !error && !notice && <p className="text-muted-foreground">记录已经处理完了。</p>}
{pending.length > 0 && <p className="agent-recovery-footnote">不再提醒只会关闭提醒,已经保存的内容会保留。</p>}
{pending.length > 0 && <p className="agent-recovery-footnote">{pending.some(item => /channel|wechat/iu.test(item.operation))
? '不再提醒只会移除本机提醒,不会取消微信服务端仍在处理的操作。'
: '不再提醒只会关闭提醒,已经保存的内容会保留。'}</p>}
</div>
<DialogFooter className="agent-recovery-footer"><Button type="button" variant="outline" disabled={Boolean(busy)} onClick={close}>返回列表</Button></DialogFooter>
</DialogContent>
@@ -8,8 +8,8 @@ import { AgentConfigHeading } from './AgentConfigHeading';
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
const resourceSections = [
{ key: 'tools', label: '工具箱', title: '工具箱(Tool)', icon: Wrench, tone: 'blue' },
{ key: 'skills', label: '做事手册', title: '做事手册(Skill)', icon: BookOpen, tone: 'lilac' },
{ key: 'tools', label: 'TOOL', title: 'TOOL', icon: Wrench, tone: 'blue' },
{ key: 'skills', label: 'SKILL', title: 'SKILL', icon: BookOpen, tone: 'lilac' },
{ key: 'mcps', label: 'MCP 服务', title: 'MCP 服务', icon: Plug, tone: 'neutral' },
{ key: 'knowledges', label: '知识库', title: '知识库', icon: Library, tone: 'mint' },
] as const;
@@ -75,7 +75,7 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
</>}
{key === 'skills' && value.skills.length > 0 && <label className="agent-resource-preload">
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法</label>}
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选SKILL的使用方法</label>}
</fieldset>;
})}
</div>;
+1 -1
View File
@@ -47,7 +47,7 @@
.agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; }
.agent-knowledge-upload { border-color: #dce5f3; }
.agent-knowledge-upload, .agent-knowledge-management { display: grid; grid-template-rows: subgrid; grid-row: span 3; }
.agent-knowledge-create-action { display: flex; align-items: center; justify-content: center; }
.agent-knowledge-create-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; min-width: 0; }
.agent-knowledge-management-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-width: 0; }
.agent-config-card .agent-knowledge-management-controls select { width: calc((100% - 144px - 8px) / 2); flex: none; padding-inline: 8px; font-size: 14px; }
.agent-knowledge-upload-action { display: flex; min-width: 0; }