Integrate delivered Code navigation and Yuxi channel recovery into Canvas trial source
This commit is contained in:
1 parent
2ca6e3199d
commit
3fe3fdb1c0
38 files changed
+1602
-198
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user