Merge grouped teacher introductions into latest main
This commit is contained in:
@@ -25,7 +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 meetTeacher = (agentId: string) => { setIntroductionId(null); companion.dismissWelcomes(); onOpen(agentId); };
|
||||
const status = loading ? '正在连接智能体…'
|
||||
: checking ? '智能体在看看你最近的进展…'
|
||||
: error || (!enabled ? '智能体暂未开放,仍可查看以往讨论' : open ? '我们慢慢聊,想到什么都可以说' : '点我聊聊,想到什么都可以说');
|
||||
@@ -74,7 +74,7 @@ export function TeacherCompanion({ companion, open, onOpen, onToggle, bubbleRef,
|
||||
<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)}>
|
||||
<button type="button" className="teacher-welcome-dismiss" aria-label={`收起全部老师介绍(${item.definition.name})`} onClick={() => companion.dismissWelcomes()}>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +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 { useTeacherIntroductionGroup } from './use-teacher-introduction-group';
|
||||
import {
|
||||
TEACHER_CHECK_IN_INTERVAL_MS,
|
||||
type TeacherCheckInInput,
|
||||
@@ -43,11 +43,6 @@ 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'
|
||||
@@ -58,21 +53,6 @@ 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);
|
||||
@@ -357,21 +337,21 @@ 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 introductions = useTeacherIntroductionGroup(account, options.projectId,
|
||||
!visibleState.loading && visibleState.agents.length > 0);
|
||||
const dismissWelcomes = introductions.dismiss;
|
||||
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) ? [] : [{
|
||||
// Opening any teacher dismisses the whole introduction group, never unread replies.
|
||||
if (options.teacherOpen && selectedAgent?.teacher_id) dismissWelcomes();
|
||||
}, [options.teacherOpen, selectedAgent?.teacher_id, dismissWelcomes]);
|
||||
const welcomes = options.projectId && !options.teacherOpen && !visibleState.loading && !introductions.dismissed
|
||||
? visibleState.agents.map((agent, index) => ({
|
||||
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 {
|
||||
@@ -387,7 +367,7 @@ export function useTeacherCompanion(options: Options) {
|
||||
invitation,
|
||||
welcome,
|
||||
welcomes,
|
||||
dismissWelcome,
|
||||
dismissWelcomes,
|
||||
bubbleVisible: Boolean(invitation || welcome || welcomes.length),
|
||||
checking: last?.intent === 'check-in' && ['preparing', 'running'].includes(last.status),
|
||||
dismiss,
|
||||
|
||||
99
src/pages/Chat/use-teacher-introduction-group.ts
Normal file
99
src/pages/Chat/use-teacher-introduction-group.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { codingConversationStore, type CodingConversationStoreState } from '@/stores/coding-conversations';
|
||||
|
||||
interface IntroductionGroup {
|
||||
version: 1;
|
||||
dismissed: boolean;
|
||||
since: number;
|
||||
runs: string[];
|
||||
}
|
||||
|
||||
function readJson(key: string): unknown {
|
||||
try { return JSON.parse(localStorage.getItem(key) ?? 'null'); } catch { return null; }
|
||||
}
|
||||
|
||||
function isProgress(value: unknown): value is Pick<IntroductionGroup, 'since' | 'runs'> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const progress = value as Partial<IntroductionGroup>;
|
||||
return typeof progress.since === 'number' && Number.isFinite(progress.since)
|
||||
&& Array.isArray(progress.runs) && progress.runs.length <= 3
|
||||
&& progress.runs.every(run => typeof run === 'string');
|
||||
}
|
||||
|
||||
function readGroup(key: string, account: string, projectId: string): IntroductionGroup {
|
||||
const saved = readJson(key);
|
||||
if (isProgress(saved) && 'version' in saved && saved.version === 1
|
||||
&& 'dismissed' in saved && typeof saved.dismissed === 'boolean') {
|
||||
return { version: 1, dismissed: saved.dismissed || saved.runs.length >= 3, since: saved.since, runs: saved.runs };
|
||||
}
|
||||
// Read both previous presence namespaces. Keep their receipts and all chat data untouched.
|
||||
const legacyKeys = [...new Set([
|
||||
`makelore-teacher-presence:${account}:${projectId}:project:${encodeURIComponent(projectId)}:introduced-agents`,
|
||||
`makelore-teacher-presence:${account}:project:${encodeURIComponent(projectId)}:introduced-agents`,
|
||||
])];
|
||||
const group: IntroductionGroup = { version: 1, dismissed: false, since: Date.now(), runs: [] };
|
||||
for (const legacyKey of legacyKeys) {
|
||||
const ids = readJson(legacyKey);
|
||||
if (Array.isArray(ids) && ids.some(id => typeof id === 'string' && id.length > 0)) group.dismissed = true;
|
||||
const rounds = readJson(legacyKey + ':rounds');
|
||||
if (!rounds || typeof rounds !== 'object' || Array.isArray(rounds)) continue;
|
||||
for (const progress of Object.values(rounds).filter(isProgress)) {
|
||||
group.since = Math.min(group.since, progress.since);
|
||||
group.runs = [...new Set([...group.runs, ...progress.runs])].slice(0, 3);
|
||||
}
|
||||
}
|
||||
group.dismissed ||= group.runs.length >= 3;
|
||||
return group;
|
||||
}
|
||||
|
||||
function persist(key: string, value: IntroductionGroup) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* Keep the current window usable. */ }
|
||||
}
|
||||
|
||||
/** One presentation receipt per account/project; teacher catalog changes never split the group. */
|
||||
export function useTeacherIntroductionGroup(account: string, projectId: string | null, enabled: boolean) {
|
||||
const key = projectId && enabled ? `makelore-teacher-presence:${account}:${projectId}:introduction-group` : null;
|
||||
const initial = useMemo(() => key && projectId ? readGroup(key, account, projectId) : null, [key, account, projectId]);
|
||||
const [state, setState] = useState(() => ({ key, value: initial }));
|
||||
const value = state.key === key ? state.value : initial;
|
||||
const latest = useRef({ key, value });
|
||||
useLayoutEffect(() => { latest.current = { key, value }; }, [key, value]);
|
||||
const commit = useCallback((next: IntroductionGroup) => {
|
||||
if (!key || latest.current.key !== key) return;
|
||||
latest.current = { key, value: next };
|
||||
persist(key, next);
|
||||
setState({ key, value: next });
|
||||
}, [key]);
|
||||
const dismiss = useCallback(() => {
|
||||
if (latest.current.key !== key || !latest.current.value || latest.current.value.dismissed) return;
|
||||
commit({ ...latest.current.value, dismissed: true });
|
||||
}, [key, commit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !projectId || !latest.current.value) return;
|
||||
persist(key, latest.current.value);
|
||||
const observe = (store: CodingConversationStoreState) => {
|
||||
const current = latest.current.value;
|
||||
if (latest.current.key !== key || !current || current.dismissed) return;
|
||||
const runs = new Set(current.runs);
|
||||
for (const entry of Object.values(store.entriesByConversationId)) {
|
||||
const snapshot = entry.reducer.snapshot;
|
||||
if (entry.loadState !== 'live' || entry.reducer.invalidation || snapshot?.conversation.projectId !== projectId) continue;
|
||||
const run = snapshot.run;
|
||||
// Count successful top-level turns only; ignore old history, tools and retry intermediates.
|
||||
if (run.status !== 'idle' || run.terminalReason !== 'completed' || run.mode !== 'prompt'
|
||||
|| !run.runId || run.startedAt === undefined || run.startedAt < current.since) continue;
|
||||
runs.add(JSON.stringify([snapshot.conversation.id, run.runId]));
|
||||
}
|
||||
if (runs.size !== current.runs.length) {
|
||||
commit({ ...current, runs: [...runs].slice(0, 3), dismissed: runs.size >= 3 });
|
||||
}
|
||||
};
|
||||
observe(codingConversationStore.getState());
|
||||
return codingConversationStore.subscribe((store, previous) => {
|
||||
if (store.entriesByConversationId !== previous.entriesByConversationId) observe(store);
|
||||
});
|
||||
}, [key, projectId, commit]);
|
||||
|
||||
return { dismissed: value?.dismissed ?? false, dismiss };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user