Merge teacher composer and compact prompt UI into main
This commit is contained in:
8
src/components/community/CommunityInvitation.tsx
Normal file
8
src/components/community/CommunityInvitation.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CommunityLink } from './CommunityLink';
|
||||
|
||||
export function CommunityInvitation() {
|
||||
return <section className="community-invitation" aria-label="作品广场入口" data-testid="community-invitation">
|
||||
<div><h2>还没想到?去找找灵感吧</h2><p>作品广场里,有小伙伴们用麦洛做出的奇思妙想。</p></div>
|
||||
<CommunityLink />
|
||||
</section>;
|
||||
}
|
||||
24
src/components/community/CommunityLink.tsx
Normal file
24
src/components/community/CommunityLink.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowUpRight, Compass } from 'lucide-react';
|
||||
import { openCommunity } from '@/lib/community';
|
||||
import './community.css';
|
||||
|
||||
export function CommunityLink({ variant = 'inline', collapsed = false }: {
|
||||
variant?: 'inline' | 'sidebar'; collapsed?: boolean;
|
||||
}) {
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
return <div>
|
||||
<button type="button" className="community-link" data-variant={variant} data-collapsed={collapsed}
|
||||
aria-label={variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}
|
||||
title="在浏览器打开作品广场" disabled={opening}
|
||||
onClick={() => {
|
||||
setOpening(true); setError(false);
|
||||
void openCommunity().catch(() => setError(true)).finally(() => setOpening(false));
|
||||
}}>
|
||||
<Compass aria-hidden="true" />
|
||||
{!collapsed && <><span>{variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}</span><ArrowUpRight className="community-link-arrow" aria-hidden="true" /></>}
|
||||
</button>
|
||||
{error && <span className="community-link-error" role="alert">没能打开作品广场,请再点一次试试。</span>}
|
||||
</div>;
|
||||
}
|
||||
37
src/components/community/community.css
Normal file
37
src/components/community/community.css
Normal file
@@ -0,0 +1,37 @@
|
||||
.coding-chat-body { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.coding-chat-body[hidden] { display: none; }
|
||||
.coding-chat-body[data-welcome="true"] { padding: 0 24px 16px; background: radial-gradient(ellipse at 50% 40%, #fff8e8 0%, #fffdf8 28%, #fff 67%); }
|
||||
.coding-chat-body[data-welcome="true"] .classroom-chat-view { flex: 1 1 0; overflow-y: auto; padding: 24px 0; }
|
||||
.coding-chat-body[data-welcome="true"] .classroom-composer-view { width: 100%; max-width: 740px; margin: 0 auto; padding-top: 16px; }
|
||||
.coding-chat-body[data-welcome="true"] .classroom-composer-view > .community-invitation { margin: 0 auto 12px; }
|
||||
.coding-chat-body[data-welcome="true"] [data-testid="coding-message-composer"] { width: 100%; padding: 0; }
|
||||
.coding-chat-body[data-welcome="true"] .chat-composer-surface { background: #fff; border-color: #d7dfda; border-radius: 20px; box-shadow: 0 5px 0 #eaf0e8, 0 12px 32px #69774b08; }
|
||||
.coding-start-heading { position: relative; flex-shrink: 0; width: 100%; max-width: 740px; margin: var(--teacher-top-inset, auto) auto auto; padding: 0 12px; text-align: center; }
|
||||
.coding-start-mark { display: inline-grid; position: relative; place-items: center; width: 57px; height: 59px; border-radius: 18px; border: 1px solid #f2d6a7; background: #fff0d7; box-shadow: 0 4px 0 #eeddbd; transform: rotate(-7deg); animation: community-hello 650ms ease-out both; }
|
||||
.coding-start-mark img { width: 35px; height: 35px; }
|
||||
.coding-start-mark > svg { position: absolute; width: 17px; height: 17px; right: -24px; top: -2px; color: #c78b36; }
|
||||
.coding-start-mark > svg:last-child { width: 12px; height: 12px; left: -23px; top: 39px; color: #83a496; }
|
||||
.coding-start-eyebrow { margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; }
|
||||
.coding-start-heading h1 { color: #304968; font-size: clamp(24px, 2.5vw, 34px); line-height: 1.45; font-weight: 650; letter-spacing: -.04em; }
|
||||
.coding-start-heading h1 span { position: relative; white-space: nowrap; background: linear-gradient(transparent 73%, #ffe6a6 73%, #ffe6a6 94%, transparent 94%); }
|
||||
.coding-start-caption { margin-top: 10px; color: #718096; font-size: 13px; line-height: 1.8; }
|
||||
.community-invitation { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 18px; width: 100%; max-width: 740px; margin: 5px auto 0; padding: 21px 23px; border: 1px dashed #e4d9bc; border-radius: 18px; background: #fffaf0; color: #304968; }
|
||||
.community-invitation h2 { margin-bottom: 5px; font-size: 15px; font-weight: 600; }
|
||||
.community-invitation p { color: #7c806e; font-size: 12px; line-height: 1.7; }
|
||||
.community-link { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 8px 13px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||
.community-link:hover:not(:disabled) { background: #e5eed9; border-color: #b7c6a4; }
|
||||
.community-link:disabled { opacity: .6; cursor: wait; }
|
||||
.community-link:focus-visible { outline: 2px solid #547ab0; outline-offset: 3px; }
|
||||
.community-link svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
.community-link .community-link-arrow { width: 14px; height: 14px; }
|
||||
.community-link[data-variant="sidebar"] { justify-content: flex-start; width: 100%; margin-top: 12px; border-color: transparent; background: transparent; text-align: left; }
|
||||
.community-link[data-variant="sidebar"]:hover { background: #e8f0e7; }
|
||||
.community-link[data-collapsed="true"] { justify-content: center; padding-inline: 0; }
|
||||
.community-link-error { display: block; margin-top: 6px; color: #965a45; font-size: 12px; line-height: 1.6; }
|
||||
@keyframes community-hello { 0% { transform: rotate(-15deg) translateY(5px); opacity: 0; } 70% { transform: rotate(3deg); opacity: 1; } 100% { transform: rotate(-7deg); } }
|
||||
@media (max-width: 600px) {
|
||||
.coding-chat-body[data-welcome="true"] { padding: 0 16px 16px; }
|
||||
.coding-start-caption { max-width: 230px; margin: 9px auto 0; }
|
||||
.community-invitation { flex-direction: column; align-items: flex-start; gap: 13px; padding: 19px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } }
|
||||
@@ -53,6 +53,7 @@ import { useProviderStore } from '@/stores/providers';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { toast } from 'sonner';
|
||||
import { CommunityLink } from '@/components/community/CommunityLink';
|
||||
|
||||
type OpenDialogResult = {
|
||||
canceled: boolean;
|
||||
@@ -435,6 +436,7 @@ export function Sidebar({
|
||||
<Plus className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed && '新建项目'}
|
||||
</button>
|
||||
<CommunityLink variant="sidebar" collapsed={sidebarCollapsed} />
|
||||
<div className="classroom-projects mt-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
19
src/hooks/use-teacher-model-choice.ts
Normal file
19
src/hooks/use-teacher-model-choice.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function readModel(key: string): string | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
return value && value.length <= 200 ? value : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
export function useTeacherModelChoice(scope: string) {
|
||||
const key = 'makelore-teacher-model:' + scope;
|
||||
const [choice, setChoice] = useState(() => ({ key, modelId: readModel(key) }));
|
||||
const modelId = choice.key === key ? choice.modelId : readModel(key);
|
||||
const select = (id: string | undefined) => {
|
||||
setChoice({ key, modelId: id });
|
||||
try { if (id) localStorage.setItem(key, id); else localStorage.removeItem(key); } catch { /* Current window retains the choice. */ }
|
||||
};
|
||||
return { modelId, select };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api';
|
||||
import type {
|
||||
TeacherModelCatalog,
|
||||
TeacherAvailability,
|
||||
TeacherCheckInInput,
|
||||
TeacherCheckInResult,
|
||||
@@ -27,6 +28,7 @@ export function legacyTopicBase(base: string, legacyFriend: boolean) {
|
||||
return legacyFriend ? base.replace(/agent-topics$/, 'friend-topics') : base;
|
||||
}
|
||||
export const teacherApi = {
|
||||
models: (runtime: 'local' | 'yuxi') => hostApiFetch<TeacherModelCatalog>('/api/coding/teacher/models?runtime=' + runtime),
|
||||
legacyHistory: (projectId: string) => hostApiFetch<TeacherTopicList>(`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history`),
|
||||
legacyRead: (projectId: string, item: TeacherTopicList['items'][number]) => hostApiFetch<TeacherTopic>(
|
||||
`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history/${encodeURIComponent(item.id)}?source=${encodeURIComponent(item.legacySourceId ?? 'project')}&friend=${item.legacyRole === 'friend'}`),
|
||||
|
||||
8
src/lib/community.ts
Normal file
8
src/lib/community.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { invokeIpc } from './api-client';
|
||||
|
||||
export const COMMUNITY_URL = 'https://square.nianxx.cn/#explore';
|
||||
|
||||
/** Open only after the student explicitly clicks; no community data is fetched. */
|
||||
export async function openCommunity(): Promise<void> {
|
||||
await invokeIpc('shell:openExternal', COMMUNITY_URL);
|
||||
}
|
||||
17
src/lib/message-timestamp.ts
Normal file
17
src/lib/message-timestamp.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { messageTimestamp } from '../../shared/message-timestamp';
|
||||
|
||||
export function formatMessageTimestamp(value: unknown, now = new Date()) {
|
||||
const timestamp = messageTimestamp(value);
|
||||
if (timestamp === undefined) return undefined;
|
||||
const date = new Date(timestamp);
|
||||
const pad = (part: number) => String(part).padStart(2, '0');
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
const sameYear = date.getFullYear() === now.getFullYear();
|
||||
const today = sameYear && date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
|
||||
const day = `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
return {
|
||||
iso: date.toISOString(),
|
||||
short: today ? time : `${sameYear ? '' : `${date.getFullYear()}年`}${day} ${time}`,
|
||||
full: `${date.getFullYear()}年${day} ${time}:${pad(date.getSeconds())}`,
|
||||
};
|
||||
}
|
||||
34
src/pages/Chat/CodingChatBody.tsx
Normal file
34
src/pages/Chat/CodingChatBody.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
/** Keep the same composer mounted while the empty state becomes a conversation. */
|
||||
export function CodingChatBody({ welcome, hidden, bubble, children }: {
|
||||
welcome: boolean; hidden: boolean; bubble?: HTMLElement | null; children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const body = ref.current;
|
||||
const content = body?.querySelector<HTMLElement>('.classroom-chat-view');
|
||||
if (!body || !content || !welcome || hidden || !bubble) return;
|
||||
const update = () => {
|
||||
const area = body.getBoundingClientRect();
|
||||
const message = bubble.getBoundingClientRect();
|
||||
const overlaps = message.height > 0 && message.right > area.left && message.left < area.right && message.bottom > area.top;
|
||||
// The teacher floats over the page; it never narrows or shifts the main column.
|
||||
// Only compact screens need scrollable space to reveal the welcome below the overlay.
|
||||
if (overlaps && area.width < 900) {
|
||||
content.style.setProperty('--teacher-top-inset', `${Math.max(0, Math.ceil(message.bottom - content.getBoundingClientRect().top + 24))}px`);
|
||||
} else {
|
||||
content.style.removeProperty('--teacher-top-inset');
|
||||
}
|
||||
};
|
||||
update();
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
||||
observer?.observe(body); observer?.observe(bubble);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
observer?.disconnect(); window.removeEventListener('resize', update);
|
||||
content.style.removeProperty('--teacher-top-inset');
|
||||
};
|
||||
}, [welcome, hidden, bubble]);
|
||||
return <div ref={ref} className="coding-chat-body" data-welcome={welcome} hidden={hidden}>{children}</div>;
|
||||
}
|
||||
@@ -47,6 +47,9 @@ import { CodingConversationHeader } from './CodingConversationHeader';
|
||||
import { CodingConversationTimeline } from './CodingConversationTimeline';
|
||||
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';
|
||||
@@ -676,6 +679,8 @@ export function CodingChatPanel({
|
||||
});
|
||||
}, [loginRecovery, activeProject, targetConversationId, entryLoadState, workspaceLoadState, handleRecovery, draftKey, onLoginRecoveryConsumed]);
|
||||
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
|
||||
const showWelcome = editable && !selectedConversation?.archivedAt && !running && runStatus === 'idle'
|
||||
&& (!targetConversationId || (entryLoadState === 'live' && snapshot?.nodes.length === 0));
|
||||
|
||||
if (!activeProject && workspaceLoadState !== 'loading') {
|
||||
return (
|
||||
@@ -770,9 +775,8 @@ export function CodingChatPanel({
|
||||
onRecover={async () => {
|
||||
if (targetConversationId) await handleRecovery(codingRecovery(runtimeError?.code).action, false);
|
||||
}}
|
||||
browserOpen={agentBrowserOpen}
|
||||
browserAvailable={Boolean(activeProject)}
|
||||
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
|
||||
consultationAvailable={Boolean(activeProject)}
|
||||
onToggleConsultation={() => setConsultationOpen((current) => !current)}
|
||||
projectName={activeProject?.name}
|
||||
consultationOpen={consultationOpen}
|
||||
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} /> : undefined}
|
||||
@@ -807,6 +811,7 @@ export function CodingChatPanel({
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
<CodingChatBody welcome={showWelcome} hidden={agentBrowserOpen} bubble={teacherBubble}>
|
||||
<div id="classroom-chat-view" role="tabpanel" aria-labelledby="classroom-chat-tab" hidden={agentBrowserOpen} className="classroom-chat-view min-h-0 flex-1 flex-col">
|
||||
|
||||
{(workspaceError || conversationMetadataError || connectionError) && (
|
||||
@@ -829,7 +834,7 @@ export function CodingChatPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetConversationId
|
||||
{showWelcome ? <CodingStartHeading /> : targetConversationId
|
||||
? (
|
||||
<CodingConversationTimeline
|
||||
key={targetConversationId}
|
||||
@@ -892,6 +897,7 @@ 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>
|
||||
@@ -959,6 +965,7 @@ export function CodingChatPanel({
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
</CodingChatBody>
|
||||
</div>
|
||||
|
||||
{consultationOpen && activeProject && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LoaderCircle, Mic, Plus, RotateCcw, Send, Square, X } from 'lucide-react';
|
||||
import { LoaderCircle, Plus, RotateCcw, Send, Square, X } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '@/types/coding-conversation';
|
||||
import type { CodingConversationMetadata } from '@/types/coding-project';
|
||||
import { CodingComposerRuntimeControls } from './CodingComposerRuntimeControls';
|
||||
import { ComposerVoiceButton } from './ComposerVoiceButton';
|
||||
|
||||
export interface CodingComposerProps {
|
||||
value: string;
|
||||
@@ -236,29 +237,7 @@ export function CodingComposer({
|
||||
onRefresh={onRefreshRuntime}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground',
|
||||
voiceInput.state !== 'idle' && 'bg-surface-subtle text-foreground',
|
||||
)}
|
||||
disabled={!voiceInput.canToggle}
|
||||
aria-busy={voiceInput.state === 'transcribing'}
|
||||
aria-label={voiceInput.state === 'recording'
|
||||
? '停止语音输入'
|
||||
: voiceInput.state === 'transcribing'
|
||||
? '正在识别语音'
|
||||
: '语音输入'}
|
||||
title={voiceInput.supported ? '语音输入' : '当前环境不支持语音输入'}
|
||||
onClick={() => void voiceInput.toggle()}
|
||||
>
|
||||
{voiceInput.state === 'transcribing'
|
||||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: voiceInput.state === 'recording'
|
||||
? <Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
|
||||
: <Mic className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
<ComposerVoiceButton voiceInput={voiceInput} />
|
||||
<Button
|
||||
type={showAbort ? 'button' : 'submit'}
|
||||
className={cn(
|
||||
|
||||
@@ -50,12 +50,11 @@ export function CodingConversationHeader({
|
||||
onRename,
|
||||
onAbort,
|
||||
onRecover,
|
||||
browserOpen = false,
|
||||
browserAvailable = false,
|
||||
onToggleBrowser,
|
||||
consultationAvailable = false,
|
||||
onToggleConsultation,
|
||||
onAskTeacher,
|
||||
teacherEntry,
|
||||
consultationOpen,
|
||||
consultationOpen = false,
|
||||
projectName,
|
||||
}: {
|
||||
conversation: CodingConversationMetadata | null;
|
||||
@@ -63,9 +62,8 @@ export function CodingConversationHeader({
|
||||
onRename(title: string): Promise<void>;
|
||||
onAbort(): Promise<void>;
|
||||
onRecover(): Promise<void>;
|
||||
browserOpen?: boolean;
|
||||
browserAvailable?: boolean;
|
||||
onToggleBrowser?(): void;
|
||||
consultationAvailable?: boolean;
|
||||
onToggleConsultation?(): void;
|
||||
onAskTeacher?(): void;
|
||||
teacherEntry?: ReactNode;
|
||||
consultationOpen?: boolean;
|
||||
@@ -134,18 +132,19 @@ export function CodingConversationHeader({
|
||||
{teacherEntry}
|
||||
{!teacherEntry && onAskTeacher && <button type="button" aria-label="智能体" aria-expanded={consultationOpen} aria-controls="coding-consultation-dock"
|
||||
className="no-drag shrink-0 rounded-lg px-2 py-1 text-xs" onClick={onAskTeacher}>智能体</button>}
|
||||
{onToggleBrowser ? (
|
||||
{onToggleConsultation ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={iconButtonClass}
|
||||
disabled={!browserAvailable}
|
||||
aria-label={browserOpen ? '关闭开发浏览器' : '打开开发浏览器'}
|
||||
aria-pressed={browserOpen}
|
||||
title={browserOpen ? '关闭开发浏览器' : '打开开发浏览器'}
|
||||
onClick={onToggleBrowser}
|
||||
disabled={!consultationAvailable}
|
||||
aria-label={consultationOpen ? '收起老师对话栏' : '展开老师对话栏'}
|
||||
aria-expanded={consultationOpen}
|
||||
aria-controls="coding-consultation-dock"
|
||||
title={consultationOpen ? '收起老师对话栏' : '展开老师对话栏'}
|
||||
onClick={onToggleConsultation}
|
||||
>
|
||||
{browserOpen
|
||||
{consultationOpen
|
||||
? <PanelRightClose className="h-4 w-4" aria-hidden="true" />
|
||||
: <PanelRightOpen className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OPEN_WORK_PROMPT } from '../../../shared/coding-work-preview';
|
||||
import { CONTINUE_CODING_PROMPT, type CodingRecoveryAction } from '../../../shared/coding-recovery';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import { CodingRecoveryCard } from './CodingRecoveryCard';
|
||||
import {
|
||||
memo,
|
||||
@@ -539,6 +540,7 @@ const MessageNode = memo(function MessageNode({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<MessageTimestamp value={node.timestamp} pending={!user && node.status === 'streaming'} label={user ? '发送时间' : '回复完成时间'} className={cn('mt-1', user && 'text-right')} />
|
||||
{!user && answerText && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1330,6 +1332,7 @@ const AssistantCommentary = memo(function AssistantCommentary({
|
||||
{blocks.length === 0 && node.status === 'aborted' && <p className="text-muted-foreground">本次处理已中止。</p>}
|
||||
</div>
|
||||
</CompactProcessText>
|
||||
<MessageTimestamp value={node.timestamp} pending={streaming} className="mt-1" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
11
src/pages/Chat/CodingStartHeading.tsx
Normal file
11
src/pages/Chat/CodingStartHeading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Sparkles, Star } from 'lucide-react';
|
||||
import mark from '@/assets/consultations/mark.svg';
|
||||
|
||||
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>
|
||||
<h1>今天,你想<span>创造什么?</span></h1>
|
||||
<p className="coding-start-caption">一个游戏、一段故事,或是一个天马行空的想法。</p>
|
||||
</div>;
|
||||
}
|
||||
17
src/pages/Chat/ComposerVoiceButton.tsx
Normal file
17
src/pages/Chat/ComposerVoiceButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { LoaderCircle, Mic, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { useWorksVoiceInput } from '@/hooks/use-works-voice-input';
|
||||
|
||||
export function ComposerVoiceButton({ voiceInput }: { voiceInput: ReturnType<typeof useWorksVoiceInput> }) {
|
||||
return <Button type="button" variant="ghost"
|
||||
className={cn('h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground',
|
||||
voiceInput.state !== 'idle' && 'bg-surface-subtle text-foreground')}
|
||||
disabled={!voiceInput.canToggle} aria-busy={voiceInput.state === 'transcribing'}
|
||||
aria-label={voiceInput.state === 'recording' ? '停止语音输入' : voiceInput.state === 'transcribing' ? '正在识别语音' : '语音输入'}
|
||||
title={voiceInput.supported ? '语音输入' : '当前环境不支持语音输入'} onClick={() => void voiceInput.toggle()}>
|
||||
{voiceInput.state === 'transcribing' ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: voiceInput.state === 'recording' ? <Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
|
||||
: <Mic className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>;
|
||||
}
|
||||
15
src/pages/Chat/MessageTimestamp.tsx
Normal file
15
src/pages/Chat/MessageTimestamp.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { formatMessageTimestamp } from '@/lib/message-timestamp';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MessageTimestamp({ value, pending = false, label = '回复完成时间', className }: {
|
||||
value?: number | string;
|
||||
pending?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const formatted = pending ? undefined : formatMessageTimestamp(value);
|
||||
const style = cn('block text-[11px] leading-5 text-muted-foreground tabular-nums', className);
|
||||
return formatted
|
||||
? <time className={style} dateTime={formatted.iso} title={formatted.full} aria-label={`${label}:${formatted.full}`}>{formatted.short}</time>
|
||||
: <span className={style} data-message-time={pending ? 'pending' : 'unknown'}>{pending ? '回复中…' : '时间未记录'}</span>;
|
||||
}
|
||||
@@ -13,6 +13,11 @@ import type {
|
||||
} from '../../../shared/coding-teacher';
|
||||
import { TeacherAvatar } from './TeacherAvatar';
|
||||
import { TeacherReply } from './TeacherReply';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import { ComposerVoiceButton } from './ComposerVoiceButton';
|
||||
import { useWorksVoiceInput } from '@/hooks/use-works-voice-input';
|
||||
import { TeacherModelSelect } from './TeacherModelSelect';
|
||||
import { useTeacherModelChoice } from '@/hooks/use-teacher-model-choice';
|
||||
|
||||
// Preserve loaded pages and the reading anchor while moving between contacts.
|
||||
// Durable messages remain Main-owned; this is only the current window's view.
|
||||
@@ -23,8 +28,8 @@ const messageOrderKey = (request: TeacherTopic['requests'][number]) => request.o
|
||||
function restorePendingSend(saved?: TeacherSend, legacyProjectId?: string): TeacherSend | null {
|
||||
if (!saved) return null;
|
||||
// Keep an interrupted send retryable without restoring retired UI protocol fields.
|
||||
const { requestId, text, references, intent, sourceConversationId, projectId } = saved;
|
||||
return { requestId, text, references, intent, sourceConversationId, ...(projectId || legacyProjectId ? { projectId: projectId ?? legacyProjectId } : {}) };
|
||||
const { requestId, text, references, intent, sourceConversationId, projectId, modelId } = saved;
|
||||
return { requestId, text, references, intent, sourceConversationId, ...(modelId ? { modelId } : {}), ...(projectId || legacyProjectId ? { projectId: projectId ?? legacyProjectId } : {}) };
|
||||
}
|
||||
|
||||
export function TeacherChatPanel(props: Parameters<typeof ProjectTeacherChatPanel>[0]) {
|
||||
@@ -141,6 +146,8 @@ function ProjectTeacherChatPanel({
|
||||
? selectedTeacher?.definition ?? selectedAgent?.definition ?? localDefinition
|
||||
: topic?.definition ?? (catalogState
|
||||
? selectedTeacher?.definition ?? selectedAgent?.definition ?? catalogState.config.definition : localDefinition);
|
||||
const modelScope = `${account}:${selectedAgentId ?? definition?.config_id ?? definition?.teacher_id ?? 'teacher'}:${draftRevision ?? 'published'}`;
|
||||
const modelChoice = useTeacherModelChoice(modelScope);
|
||||
const topicEnabled = legacyFriend || legacyView ? false : !topic ? enabled
|
||||
: topic.definition.config_id
|
||||
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
|
||||
@@ -405,7 +412,7 @@ function ProjectTeacherChatPanel({
|
||||
const questionSourceId = retry
|
||||
? retry.sourceConversationId ?? (continuous || topic?.sourceConversationId === 'project' ? undefined : topic?.sourceConversationId)
|
||||
: sourceId;
|
||||
if (!question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
|
||||
if (voiceInput.state !== 'idle' || !question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
|
||||
if (continuous && !retry && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) {
|
||||
setError('引用来自另一个项目或操作对话,请切回来源,或移除引用后再提问。'); return;
|
||||
}
|
||||
@@ -452,6 +459,7 @@ function ProjectTeacherChatPanel({
|
||||
? previous
|
||||
: {
|
||||
requestId: crypto.randomUUID(), text: question, references: questionReferences,
|
||||
...((retry ? retry.modelId : modelChoice.modelId) ? { modelId: retry ? retry.modelId : modelChoice.modelId } : {}),
|
||||
...(continuous ? { projectId } : {}),
|
||||
...(action?.intent ? { intent: action.intent } : {}),
|
||||
...(questionSourceId ? { sourceConversationId: questionSourceId } : {}),
|
||||
@@ -472,8 +480,16 @@ function ProjectTeacherChatPanel({
|
||||
}
|
||||
if (!continuous) setTopics(await teacherApi.list(base));
|
||||
} catch (e) {
|
||||
if (version === generation.current)
|
||||
if (version === generation.current) {
|
||||
const code = (e as { details?: { backendCode?: string } } | null)?.details?.backendCode;
|
||||
// These Main validation failures precede acceptance; allow correcting the selection.
|
||||
// Uncertain transport failures retain the original request and model to avoid double charging.
|
||||
if (code === 'teacher_model_unavailable' || code === 'teacher_model_invalid') {
|
||||
pending.current = null;
|
||||
persistDraft(draftRef.current.text, draftRef.current.references);
|
||||
}
|
||||
setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试');
|
||||
}
|
||||
} finally {
|
||||
sendInFlight.current = false;
|
||||
if (version === generation.current) setBusy(false);
|
||||
@@ -483,8 +499,19 @@ function ProjectTeacherChatPanel({
|
||||
(request) => request.status === 'preparing' || request.status === 'running'
|
||||
);
|
||||
const helpUnavailable = busy || !topicEnabled || Boolean(running);
|
||||
const askForSuggestions = () => void send({ text: '继续看看👀', intent: 'suggestions' });
|
||||
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
|
||||
const askForSuggestions = () => void send({ text: '帮我整体看🧠', intent: 'suggestions' });
|
||||
const changeText = (next: string) => { draftRef.current.text = next; setText(next); persistDraft(next, references, pending.current); };
|
||||
const voiceInput = useWorksVoiceInput({
|
||||
enabled: !busy && topicEnabled && !running,
|
||||
scopeKey: `${modelScope}:${projectId}:${sourceId}:${topic?.id ?? 'new'}:${modelChoice.modelId ?? 'default'}`,
|
||||
onTranscript: transcript => {
|
||||
const current = draftRef.current.text;
|
||||
const next = current.trim() ? `${current.trimEnd()}\n${transcript}` : transcript;
|
||||
if (next.length > 6000) { setError('文字超过 6000 字,请缩短输入后重新录音。'); return; }
|
||||
changeText(next);
|
||||
},
|
||||
});
|
||||
|
||||
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
|
||||
const questions = definition?.suggested_questions ?? [];
|
||||
return (
|
||||
@@ -526,8 +553,8 @@ function ProjectTeacherChatPanel({
|
||||
{topic?.requests.map((request, index) => <div key={request.id} className="mb-7 space-y-4">
|
||||
{continuous && request.projectId && (index === 0 || request.projectId !== topic.requests[index - 1].projectId) && <p className="text-center text-[10px] text-muted-foreground">{request.projectName || '之前的项目'}</p>}
|
||||
{request.intent === 'check-in'
|
||||
? <p className="text-[11px] text-muted-foreground">智能体来看看你的进展</p>
|
||||
: <div className="flex justify-end"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p></div>}
|
||||
? <div className="space-y-1"><p className="text-[11px] text-muted-foreground">智能体来看看你的进展</p><MessageTimestamp value={request.createdAt} label="主动检查时间" /></div>
|
||||
: <div className="flex flex-col items-end gap-1"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p><MessageTimestamp value={request.createdAt} label="发送时间" className="text-right" /></div>}
|
||||
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><TeacherAvatar definition={definition} className="h-6 w-6 rounded-lg" />{definition?.name ?? label}</div>
|
||||
{!!request.toolActivity?.length && <details className="text-xs text-muted-foreground">
|
||||
@@ -540,6 +567,7 @@ function ProjectTeacherChatPanel({
|
||||
</li>)}
|
||||
</ul>
|
||||
</details>}
|
||||
<div className="space-y-1.5">
|
||||
{request.status === 'completed' && request.replyIncomplete ? <div className="space-y-3">
|
||||
<p role="status" className="text-[13px] leading-7 text-muted-foreground">这次回复未能完整显示</p>
|
||||
{request.intent !== 'check-in' && request.text.trim() && <button type="button" disabled={helpUnavailable || !retryScopeMatches(request)} onClick={() => void send({ text: request.text, intent: request.intent }, request)} className="consultation-retry">重新回答</button>}
|
||||
@@ -549,13 +577,12 @@ function ProjectTeacherChatPanel({
|
||||
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
|
||||
: request.status === 'completed' && <>
|
||||
<TeacherReply text={request.response} />
|
||||
<div className="consultation-follow-ups" aria-label="可以和智能体聊的问题">
|
||||
{request.suggestedQuestions?.map((question, index) => <button key={`${request.id}:${index}`} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
||||
</div>
|
||||
</>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-retry">继续看看👀</button>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-retry">帮我整体看🧠</button>}
|
||||
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
||||
{request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
||||
<MessageTimestamp value={request.finishedAt} pending={['running', 'preparing'].includes(request.status)} />
|
||||
</div>
|
||||
{request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label={request.intent === 'suggestions' ? '可以和智能体聊的问题' : '接着聊'}>
|
||||
{request.suggestedQuestions?.map((question, index) => <button type="button" key={`${request.id}:${index}`} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
||||
</div>}
|
||||
{request.status === 'completed' && !request.replyIncomplete && request.replyParseError && <p role="status" className="text-xs text-muted-foreground">回答正文已保留,部分附加内容未能显示。</p>}
|
||||
@@ -591,22 +618,28 @@ function ProjectTeacherChatPanel({
|
||||
</div>
|
||||
</details>}
|
||||
{references.length > 0 && <div className="mb-2 rounded-lg bg-white p-3 text-xs"><p className="line-clamp-3 whitespace-pre-wrap">{references.map((ref) => ref.text).join('\n')}</p><button type="button" className="mt-1 underline" onClick={removeReferences}>移除引用</button></div>}
|
||||
{!draftRevision && !text.trim() && (definition?.shortcuts == null || definition.shortcuts.length > 0) &&
|
||||
<div className="consultation-shortcuts" role="group" aria-label="快捷提问" data-testid="consultation-shortcuts">
|
||||
{definition?.shortcuts == null
|
||||
? <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>
|
||||
: definition.shortcuts.map((shortcut, index) => <button
|
||||
key={index} type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'}
|
||||
onClick={() => void send({ text: shortcut.prompt })}
|
||||
className="consultation-hint"
|
||||
>{shortcut.label}</button>)}
|
||||
</div>}
|
||||
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
|
||||
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
|
||||
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
|
||||
<div className="mt-2 flex min-h-12 items-center justify-between gap-2" data-testid="consultation-composer-actions">
|
||||
<div className="flex max-h-48 min-w-0 flex-1 flex-wrap gap-2 overflow-y-auto pb-1">
|
||||
{!draftRevision && !text.trim() && (definition?.shortcuts == null
|
||||
? <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-hint">继续看看👀</button>
|
||||
: definition.shortcuts.map((shortcut, index) => <button
|
||||
key={index} type="button" disabled={helpUnavailable}
|
||||
onClick={() => void send({ text: shortcut.prompt })}
|
||||
className="consultation-hint max-w-full whitespace-normal break-words text-left"
|
||||
>{shortcut.label}</button>))}
|
||||
</div>
|
||||
<div className="mt-2 flex min-h-[68px] flex-wrap items-center justify-between gap-x-2 gap-y-2" data-testid="consultation-composer-actions">
|
||||
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1" data-testid="teacher-composer-runtime-controls">
|
||||
<TeacherModelSelect key={modelScope + (definition?.runtime ?? 'local')} runtime={definition?.runtime ?? 'local'} scope={modelScope} defaultModel={definition?.model.model_id}
|
||||
modelId={modelChoice.modelId} onChange={modelChoice.select} disabled={busy || !topicEnabled || Boolean(running)} />
|
||||
<ComposerVoiceButton voiceInput={voiceInput} />
|
||||
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
|
||||
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
|
||||
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim() || voiceInput.state !== 'idle'} className="consultation-send flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowUpRight, Loader2, RefreshCw, X } from 'lucide-react';
|
||||
import { useState, type Ref } from 'react';
|
||||
import { useState, type CSSProperties, type Ref } from 'react';
|
||||
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { TeacherAvatar } from './TeacherAvatar';
|
||||
import { TeacherIntroduction } from './TeacherIntroduction';
|
||||
@@ -14,7 +14,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
|
||||
bubbleRef?: Ref<HTMLDivElement>;
|
||||
introductionRef?: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
|
||||
const { definition, invitation, welcome, welcomes, loading, enabled, checking, error } = companion;
|
||||
const [introductionId, setIntroductionId] = useState<string | null>(null);
|
||||
const introductionProps = (id: string) => ({
|
||||
open: introductionId === id,
|
||||
@@ -25,6 +25,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
|
||||
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
|
||||
? [...companion.agents, companion.selectedAgent] : companion.agents;
|
||||
const viewConversation = () => { companion.dismiss(); onOpen(); };
|
||||
const meetTeacher = (agentId: string) => { setIntroductionId(null); companion.dismissWelcome(agentId); onOpen(agentId); };
|
||||
const status = loading ? '正在连接智能体…'
|
||||
: checking ? '智能体在看看你最近的进展…'
|
||||
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
||||
@@ -60,6 +61,26 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
|
||||
<span>{companion.refreshing ? '刷新中' : companion.refreshStatus === 'error' ? '刷新失败' : companion.refreshStatus === 'success' ? '已更新' : '刷新'}</span>
|
||||
</button>
|
||||
<span className="sr-only" role="status" aria-live="polite">{refreshMessage}</span>
|
||||
{!invitation && welcomes.length > 0 && <div ref={bubbleRef} className="teacher-welcome-presence" data-testid="teacher-welcomes" aria-label="认识这里的老师">
|
||||
<div className="teacher-welcome-list">
|
||||
{welcomes.map((item, index) => <article key={item.agentId} className="teacher-welcome-card" data-agent-id={item.agentId}
|
||||
data-tone={item.tone} style={{ '--welcome-delay': `${Math.min(index, 4) * 180}ms` } as CSSProperties}>
|
||||
<button type="button" className="teacher-welcome-avatar" aria-label={`认识${item.definition.name}`} onClick={() => meetTeacher(item.agentId)}>
|
||||
<TeacherAvatar definition={item.definition} />
|
||||
</button>
|
||||
<div className="teacher-welcome-bubble">
|
||||
<button type="button" className="teacher-welcome-open" aria-label={`和${item.definition.name}聊聊`} onClick={() => meetTeacher(item.agentId)}>
|
||||
<span className="teacher-welcome-name">{item.definition.name}</span>
|
||||
<span className="teacher-welcome-text" title={item.text}>{item.text}</span>
|
||||
<span className="teacher-welcome-action">和我聊聊 <ArrowUpRight aria-hidden="true" className="h-3 w-3" /></span>
|
||||
</button>
|
||||
<button type="button" className="teacher-welcome-dismiss" aria-label={`收起${item.definition.name}的介绍`} onClick={() => companion.dismissWelcome(item.agentId)}>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</article>)}
|
||||
</div>
|
||||
</div>}
|
||||
{(invitation || welcome) && <div ref={bubbleRef} className="teacher-companion-presence" data-testid="teacher-presence">
|
||||
<div className="teacher-companion-bubble" data-testid="teacher-invitation" data-bubble-kind={invitation ? 'check-in' : 'welcome'}>
|
||||
<div className="teacher-companion-bubble-heading">
|
||||
|
||||
60
src/pages/Chat/TeacherModelSelect.tsx
Normal file
60
src/pages/Chat/TeacherModelSelect.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronDown, LoaderCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { teacherApi } from '@/lib/coding-teacher';
|
||||
import type { TeacherModelCatalog } from '../../../shared/coding-teacher';
|
||||
|
||||
export function TeacherModelSelect({ runtime, scope, defaultModel, modelId, disabled, onChange }: {
|
||||
runtime: 'local' | 'yuxi'; scope: string; defaultModel?: string | null;
|
||||
modelId?: string; disabled: boolean; onChange(id: string | undefined): void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [result, setResult] = useState<TeacherModelCatalog | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Fetch on opening so account changes, availability and a newly deployed service are respected.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
void teacherApi.models(runtime).then(value => { if (alive) setResult(value); })
|
||||
.catch(() => { if (alive) setError('模型列表暂不可用,请关闭后重试。'); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [open, runtime, scope]);
|
||||
const label = modelId ?? defaultModel ?? '老师配置';
|
||||
const itemClass = 'relative flex min-h-8 cursor-default select-none items-center rounded-xl px-2.5 pr-9 outline-none data-[highlighted]:bg-surface-subtle';
|
||||
return <DropdownMenu.Root open={open} onOpenChange={value => {
|
||||
if (value) { setLoading(true); setResult(null); setError(''); }
|
||||
setOpen(value);
|
||||
}}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button type="button" variant="ghost" disabled={disabled}
|
||||
className="h-8 min-w-0 max-w-48 gap-1 rounded-full bg-transparent px-2 text-xs font-normal shadow-none hover:bg-surface-subtle"
|
||||
aria-label={`老师模型:${label}${modelId ? '' : ',跟随老师配置'}`} title={label}>
|
||||
<span className="min-w-0 truncate text-foreground/85">{label}</span>
|
||||
<span className="shrink-0 text-brand">{modelId ? '模型默认' : '老师配置'}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content side="top" align="end" sideOffset={8} collisionPadding={12} aria-label="选择老师模型"
|
||||
className="z-[101] max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height))] w-64 max-w-[calc(100vw-24px)] overflow-y-auto rounded-[18px] border border-border/80 bg-background p-1.5 text-sm shadow-[0_18px_50px_rgba(26,31,42,0.16)] outline-none">
|
||||
<DropdownMenu.Label className="px-2.5 py-2 text-xs text-muted-foreground">用于接下来的老师提问</DropdownMenu.Label>
|
||||
<DropdownMenu.RadioGroup value={modelId ?? ''} onValueChange={value => onChange(value || undefined)}>
|
||||
<DropdownMenu.RadioItem value="" className={itemClass}>跟随老师配置
|
||||
<DropdownMenu.ItemIndicator className="absolute right-2.5"><Check className="h-4 w-4" /></DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
{result?.supported && result.models.map(model => <DropdownMenu.RadioItem key={model.id} value={model.id} className={itemClass} title={model.id}>
|
||||
<span className="min-w-0 truncate">{model.id}</span>
|
||||
<DropdownMenu.ItemIndicator className="absolute right-2.5"><Check className="h-4 w-4" /></DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>)}
|
||||
</DropdownMenu.RadioGroup>
|
||||
{loading && <div role="status" className="flex items-center gap-2 px-2.5 py-2 text-xs text-muted-foreground"><LoaderCircle className="h-3.5 w-3.5 animate-spin" />正在读取模型…</div>}
|
||||
{result?.supported && !result.models.length && <p role="status" className="px-2.5 py-2 text-xs text-muted-foreground">暂无可切换的模型,可以继续使用老师配置。</p>}
|
||||
{(error || result?.message || (result?.supported && modelId && !result.models.some(model => model.id === modelId))) &&
|
||||
<p role="status" className="px-2.5 py-2 text-xs leading-5 text-muted-foreground">{error || result?.message || '已选模型暂不可用,请重新选择后再提问。'}</p>}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>;
|
||||
}
|
||||
@@ -49,16 +49,23 @@
|
||||
.classroom-consultation-resizer { position: absolute; inset: 0 auto 0 -4px; width: 8px; z-index: 2; cursor: col-resize; }
|
||||
.classroom-consultation-resizer:hover, .classroom-consultation-resizer:focus-visible { background: #7994b533; }
|
||||
.consultation-pane { background: #fffdf8; color: #30465f; }
|
||||
.consultation-hint, .consultation-retry { display: inline-flex; align-items: center; justify-content: center; align-self: flex-start; min-height: 44px; max-width: 100%; padding: 8px 16px; border: 1.5px solid #e3ce8f; border-radius: 16px 16px 16px 6px; background: #fff2c9; color: #6f5127; box-shadow: 0 3px 0 #e7d7a6, inset 0 1px 0 #fff9e8; font-size: 14px; font-weight: 600; line-height: 24px; cursor: pointer; transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
|
||||
.consultation-hint:hover:not(:disabled), .consultation-retry:hover:not(:disabled) { background: #ffe9a9; border-color: #d6b76f; }
|
||||
.consultation-hint:active:not(:disabled), .consultation-retry:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 #e7d7a6, inset 0 1px 0 #fff9e8; }
|
||||
.consultation-retry { display: inline-flex; align-items: center; justify-content: center; align-self: flex-start; min-height: 44px; max-width: 100%; padding: 8px 16px; border: 1.5px solid #e3ce8f; border-radius: 16px 16px 16px 6px; background: #fff2c9; color: #6f5127; box-shadow: 0 3px 0 #e7d7a6, inset 0 1px 0 #fff9e8; font-size: 14px; font-weight: 600; line-height: 24px; cursor: pointer; transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
|
||||
.consultation-retry:hover:not(:disabled) { background: #ffe9a9; border-color: #d6b76f; }
|
||||
.consultation-retry:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 #e7d7a6, inset 0 1px 0 #fff9e8; }
|
||||
.consultation-shortcuts { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; max-height: min(192px, 24vh); overflow-y: auto; margin-bottom: 10px; padding: 3px 4px 6px; }
|
||||
.consultation-hint { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-height: 30px; padding: 4px 10px; border: 0; border-radius: 8px; background: #f8e6b7; box-shadow: 0 2px 0 #d8c492, 0 3px 6px #8b75491a; color: #65491f; font-size: 13px; font-weight: 400; line-height: 22px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
|
||||
.consultation-hint::after { content: ''; width: 5px; height: 5px; flex-shrink: 0; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); opacity: 0.7; }
|
||||
.consultation-hint:hover:not(:disabled) { background: #f7dfa0; box-shadow: 0 3px 0 #d1b979, 0 5px 8px #8b754924; transform: translateY(-1px); }
|
||||
.consultation-hint:active:not(:disabled) { background: #efd28b; box-shadow: 0 1px 0 #c7ad70; transform: translateY(1px); }
|
||||
.consultation-hint:focus-visible { outline: 2px solid #7891af; outline-offset: 2px; }
|
||||
.consultation-hint:disabled, .consultation-retry:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
@media (prefers-reduced-motion: reduce) { .consultation-hint, .consultation-retry { transition: none; } .consultation-hint:active:not(:disabled), .consultation-retry:active:not(:disabled) { transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .consultation-hint, .consultation-retry { transition: none; } .consultation-hint:hover:not(:disabled), .consultation-hint:active:not(:disabled), .consultation-retry:active:not(:disabled) { transform: none; } }
|
||||
.consultation-pane button:focus-visible { outline: 2px solid #71845f; outline-offset: 3px; }
|
||||
.consultation-suggestion { border-color: #e0e6d6; color: #718163; }
|
||||
.consultation-suggestion:hover { border-color: #bac9aa; background: #f4f7ee; }
|
||||
.consultation-follow-ups { display: flex; flex-direction: column; gap: 10px; }
|
||||
.consultation-follow-up { --card-background: #eef5ff; --card-hover: #e2edfc; --card-border: #d3e3f7; display: flex; width: 100%; min-height: 48px; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border: 1px solid var(--card-border); border-radius: 16px; background: var(--card-background); color: #344963; font-size: 16px; line-height: 1.8; text-align: left; overflow-wrap: anywhere; cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease; }
|
||||
.consultation-follow-ups { display: flex; flex-direction: column; gap: 8px; }
|
||||
.consultation-follow-up { --card-background: #eef5ff; --card-hover: #e2edfc; --card-border: #d3e3f7; display: flex; width: 100%; min-height: 44px; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border: 1px solid var(--card-border); border-radius: 12px; background: var(--card-background); color: #344963; font-size: 14px; line-height: 1.7; text-align: left; overflow-wrap: anywhere; cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease; }
|
||||
.consultation-follow-up > svg { width: 14px; height: 14px; }
|
||||
.consultation-follow-up:nth-child(3n + 2) { --card-background: #fff4dc; --card-hover: #ffedc5; --card-border: #eeddb5; }
|
||||
.consultation-follow-up:nth-child(3n + 3) { --card-background: #f3edfc; --card-hover: #eae0f8; --card-border: #e2d7f2; }
|
||||
.consultation-follow-up:hover:not(:disabled) { background: var(--card-hover); }
|
||||
@@ -113,5 +120,25 @@
|
||||
.teacher-companion-actions button:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.teacher-companion-speaker { flex-shrink: 0; width: 48px; height: 48px; margin-top: 5px; border-radius: 8px; }
|
||||
.teacher-companion-speaker img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; filter: drop-shadow(0 3px 1px #33485820); }
|
||||
.teacher-welcome-presence { position: absolute; top: calc(100% + 12px); right: 0; z-index: 90; width: 340px; max-width: calc(100vw - 40px); }
|
||||
.teacher-welcome-list { display: grid; gap: 10px; max-height: min(440px, calc(100vh - 200px)); overflow-y: auto; overscroll-behavior: contain; padding: 4px 6px 8px; scrollbar-width: thin; }
|
||||
.teacher-welcome-card { --welcome-fill: #fff3ce; --welcome-edge: #efddb0; display: flex; align-items: flex-start; gap: 10px; min-width: 0; animation: teacher-welcome-arrive 220ms ease-out backwards; animation-delay: var(--welcome-delay, 0ms); }
|
||||
.teacher-welcome-card[data-tone="1"] { --welcome-fill: #edf5ff; --welcome-edge: #d7e5f5; }
|
||||
.teacher-welcome-card[data-tone="2"] { --welcome-fill: #f3edfc; --welcome-edge: #e5d9f5; }
|
||||
.teacher-welcome-avatar { flex-shrink: 0; width: 42px; height: 44px; margin-top: 8px; padding: 2px; border: 1px solid var(--welcome-edge); border-radius: 14px; background: #fff; box-shadow: 0 3px 0 var(--welcome-edge); cursor: pointer; }
|
||||
.teacher-welcome-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 11px; }
|
||||
.teacher-welcome-bubble { position: relative; flex: 1; min-width: 0; }
|
||||
.teacher-welcome-open::before { content: ''; position: absolute; top: 23px; left: -5px; width: 8px; height: 8px; transform: rotate(45deg); border-left: 1px solid var(--welcome-edge); border-bottom: 1px solid var(--welcome-edge); background: var(--welcome-fill); }
|
||||
.teacher-welcome-open { position: relative; display: flex; flex-direction: column; gap: 6px; width: 100%; min-width: 0; padding: 12px; border: 1px solid var(--welcome-edge); border-radius: 17px 17px 17px 6px; background: var(--welcome-fill); box-shadow: 0 2px 0 var(--welcome-edge); color: #34465c; text-align: left; cursor: pointer; }
|
||||
.teacher-welcome-open:hover { filter: brightness(1.015); }
|
||||
.teacher-welcome-open:focus-visible, .teacher-welcome-dismiss:focus-visible, .teacher-welcome-avatar:focus-visible { outline: 2px solid #547ab0; outline-offset: 3px; }
|
||||
.teacher-welcome-name { padding-right: 20px; font-size: 13px; font-weight: 600; line-height: 19px; overflow-wrap: anywhere; }
|
||||
.teacher-welcome-text { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 13px; line-height: 1.6; }
|
||||
.teacher-welcome-action { display: inline-flex; align-items: center; gap: 3px; color: #596b55; font-size: 11px; line-height: 18px; }
|
||||
.teacher-welcome-dismiss { position: absolute; top: 5px; right: 5px; display: grid; place-items: center; width: 30px; height: 30px; color: #6e776b; border-radius: 10px; cursor: pointer; }
|
||||
.teacher-welcome-dismiss:hover { background: #ffffff90; }
|
||||
@keyframes teacher-welcome-arrive { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .teacher-welcome-card { animation: none; } }
|
||||
@media (max-width: 600px) { .teacher-welcome-presence { position: fixed; top: 64px; right: 12px; max-width: calc(100vw - 24px); } }
|
||||
/* Native preview clearance is measured against the bubble, not the whole pane. */
|
||||
@media (max-height: 650px) { .teacher-companion-bubble { padding-top: 9px; } .teacher-companion-message { -webkit-line-clamp: 2; } }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { teacherApi, teacherTopicsPath, teacherConversationsPath, type TeacherCatalogState } from '@/lib/coding-teacher';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { useTeacherIntroductionRounds } from './use-teacher-introduction-rounds';
|
||||
import {
|
||||
TEACHER_CHECK_IN_INTERVAL_MS,
|
||||
type TeacherCheckInInput,
|
||||
@@ -42,6 +43,11 @@ function writeLocal(key: string, value: unknown) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* In-memory state remains usable. */ }
|
||||
}
|
||||
|
||||
function readIntroducedAgents(key: string | null): string[] {
|
||||
const value = key ? readLocal<unknown>(key, []) : [];
|
||||
return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : [];
|
||||
}
|
||||
|
||||
function unreadInvitation(state: Pick<CompanionState, 'topic' | 'seen'>) {
|
||||
if (state.topic?.conversation) return null;
|
||||
return state.topic?.requests.slice().reverse().find((request) => request.intent === 'check-in'
|
||||
@@ -52,6 +58,21 @@ export function useTeacherCompanion(options: Options) {
|
||||
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
||||
const scope = account + ':' + (options.projectId ?? 'no-project');
|
||||
const storageKey = `makelore-teacher-presence:${scope}`;
|
||||
const introductionKey = options.projectId
|
||||
? `${storageKey}:project:${encodeURIComponent(options.projectId)}:introduced-agents` : null;
|
||||
const [introductions, setIntroductions] = useState(() => ({ key: introductionKey, ids: readIntroducedAgents(introductionKey) }));
|
||||
const introducedIds = introductions.key === introductionKey ? introductions.ids : readIntroducedAgents(introductionKey);
|
||||
const dismissIntroductions = useCallback((ids: string[]) => {
|
||||
if (!introductionKey || !ids.length) return;
|
||||
setIntroductions(current => {
|
||||
const previous = current.key === introductionKey ? current.ids : readIntroducedAgents(introductionKey);
|
||||
const next = [...new Set([...previous, ...ids])];
|
||||
if (current.key === introductionKey && next.length === previous.length) return current;
|
||||
writeLocal(introductionKey, next);
|
||||
return { key: introductionKey, ids: next };
|
||||
});
|
||||
}, [introductionKey]);
|
||||
const dismissWelcome = useCallback((agentId: string) => dismissIntroductions([agentId]), [dismissIntroductions]);
|
||||
const [state, setState] = useState<CompanionState>({ agents: [], definition: null, enabled: false, loading: true, refreshing: false, refreshStatus: 'idle', topic: null, seen: [], welcomeDismissed: false, error: '' });
|
||||
const [unreadAgentIds, setUnreadAgentIds] = useState<string[]>([]);
|
||||
const selectionEpoch = useRef(0);
|
||||
@@ -336,7 +357,22 @@ export function useTeacherCompanion(options: Options) {
|
||||
const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id;
|
||||
const selectedAgent = visibleState.selectedAgent === null ? undefined
|
||||
: visibleState.agents.find((item) => item.teacher_id === selectedAgentId) ?? visibleState.selectedAgent;
|
||||
const welcome = topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
|
||||
useEffect(() => {
|
||||
// Opening one teacher consumes only that teacher's introduction, never their unread replies.
|
||||
if (options.teacherOpen && selectedAgent?.teacher_id) dismissWelcome(selectedAgent.teacher_id);
|
||||
}, [options.teacherOpen, selectedAgent?.teacher_id, dismissWelcome]);
|
||||
useTeacherIntroductionRounds(introductionKey, options.projectId,
|
||||
visibleState.agents.filter(agent => !introducedIds.includes(agent.teacher_id)).map(agent => agent.teacher_id),
|
||||
dismissIntroductions);
|
||||
const welcomes = options.projectId && !options.teacherOpen && !visibleState.loading
|
||||
? visibleState.agents.flatMap((agent, index) => introducedIds.includes(agent.teacher_id) ? [] : [{
|
||||
agentId: agent.teacher_id,
|
||||
definition: agent.definition,
|
||||
text: agent.definition.description.trim() || agent.definition.welcome_message.trim()
|
||||
|| `我是${agent.definition.name},一起聊聊你的想法吧。`,
|
||||
tone: index % 3,
|
||||
}]) : [];
|
||||
const welcome = !visibleState.agents.length && topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed
|
||||
? selectedDefinition?.welcome_message.trim() || null : null;
|
||||
return {
|
||||
unreadAgentIds: stateScope.current === scope ? unreadAgentIds : [],
|
||||
@@ -350,7 +386,9 @@ export function useTeacherCompanion(options: Options) {
|
||||
topic,
|
||||
invitation,
|
||||
welcome,
|
||||
bubbleVisible: Boolean(invitation || welcome),
|
||||
welcomes,
|
||||
dismissWelcome,
|
||||
bubbleVisible: Boolean(invitation || welcome || welcomes.length),
|
||||
checking: last?.intent === 'check-in' && ['preparing', 'running'].includes(last.status),
|
||||
dismiss,
|
||||
noteTopic,
|
||||
|
||||
67
src/pages/Chat/use-teacher-introduction-rounds.ts
Normal file
67
src/pages/Chat/use-teacher-introduction-rounds.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { codingConversationStore, type CodingConversationStoreState } from '@/stores/coding-conversations';
|
||||
|
||||
interface IntroductionProgress {
|
||||
since: number;
|
||||
runs: string[];
|
||||
}
|
||||
|
||||
function readProgress(key: string): Record<string, IntroductionProgress> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(key) ?? '{}');
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, progress]) => (
|
||||
progress && typeof progress.since === 'number' && Number.isFinite(progress.since)
|
||||
&& Array.isArray(progress.runs) && progress.runs.length <= 3
|
||||
&& progress.runs.every((run: unknown) => typeof run === 'string')
|
||||
)));
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
/** Presentation receipts only: observe Main's settled runs, never dispatch or edit a conversation. */
|
||||
export function useTeacherIntroductionRounds(
|
||||
introductionKey: string | null,
|
||||
projectId: string | null,
|
||||
pendingAgentIds: string[],
|
||||
dismiss: (ids: string[]) => void,
|
||||
) {
|
||||
const agentIdsKey = JSON.stringify(pendingAgentIds);
|
||||
useEffect(() => {
|
||||
const agentIds: string[] = JSON.parse(agentIdsKey);
|
||||
if (!introductionKey || !projectId || !agentIds.length) return;
|
||||
const key = `${introductionKey}:rounds`;
|
||||
const saved = readProgress(key);
|
||||
const progress = Object.fromEntries(agentIds.map(id => [id, saved[id] ?? { since: Date.now(), runs: [] }]));
|
||||
const persist = () => {
|
||||
try { localStorage.setItem(key, JSON.stringify(progress)); } catch { /* Keep counting in memory. */ }
|
||||
};
|
||||
persist();
|
||||
const observe = (state: CodingConversationStoreState) => {
|
||||
let changed = false;
|
||||
for (const entry of Object.values(state.entriesByConversationId)) {
|
||||
const snapshot = entry.reducer.snapshot;
|
||||
if (entry.loadState !== 'live' || entry.reducer.invalidation || snapshot?.conversation.projectId !== projectId) continue;
|
||||
const run = snapshot.run;
|
||||
// A successful top-level prompt is one round, regardless of its tool calls or retries.
|
||||
if (run.status !== 'idle' || run.terminalReason !== 'completed' || run.mode !== 'prompt'
|
||||
|| !run.runId || run.startedAt === undefined) continue;
|
||||
const runKey = JSON.stringify([snapshot.conversation.id, run.runId]);
|
||||
for (const item of Object.values(progress)) {
|
||||
// Persist the starting time so loading old history cannot consume introductions.
|
||||
if (item.runs.length >= 3 || run.startedAt < item.since || item.runs.includes(runKey)) continue;
|
||||
item.runs.push(runKey);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) persist();
|
||||
const finished = agentIds.filter(id => progress[id].runs.length >= 3);
|
||||
if (finished.length) dismiss(finished);
|
||||
};
|
||||
// Include a completion delivered during a page reload, and observe all project conversations
|
||||
// so switching the selected operation chat cannot lose or double-count a completion.
|
||||
observe(codingConversationStore.getState());
|
||||
return codingConversationStore.subscribe((state, previous) => {
|
||||
if (state.entriesByConversationId !== previous.entriesByConversationId) observe(state);
|
||||
});
|
||||
}, [introductionKey, projectId, agentIdsKey, dismiss]);
|
||||
}
|
||||
@@ -227,6 +227,7 @@ function optimisticNode(
|
||||
id: nodeId,
|
||||
clientRequestId,
|
||||
role: 'user',
|
||||
timestamp: Date.now(),
|
||||
status: 'optimistic',
|
||||
blocks: [
|
||||
...(draft.text ? [{
|
||||
|
||||
Reference in New Issue
Block a user