代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 19:43:15 +08:00
1 parent 8f5af46ec2
commit 8f75e12871
168 files changed
+76575

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
export { useLocalStorage } from './useLocalStorage';
export { useWindowSize, useIsMobile } from './useWindowSize';
export { useDebounce, useDebounceCallback } from './useDebounce';
export { useToggle } from './useToggle';
export { useAsync } from './useAsync';
export { useChatHistory } from './useChatHistory';
export { useExpertAuthBootstrap } from './useExpertAuthBootstrap';
export type { ChatMessage } from './useChatHistory';
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+37
View File
@@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useAsync<T>() {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: false,
error: null,
});
const execute = useCallback(async (asyncFunction: () => Promise<T>) => {
setState({ data: null, loading: true, error: null });
try {
const data = await asyncFunction();
setState({ data, loading: false, error: null });
return data;
} catch (error) {
setState({ data: null, loading: false, error: error as Error });
throw error;
}
}, []);
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null });
}, []);
return {
...state,
execute,
reset,
};
}
+80
View File
@@ -0,0 +1,80 @@
import { useState, useCallback } from 'react';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
interface UseChatHistoryReturn {
messages: ChatMessage[];
addMessage: (role: 'user' | 'assistant', content: string) => void;
clearMessages: () => void;
deleteMessage: (id: string) => void;
updateMessage: (id: string, content: string) => void;
}
export function useChatHistory(storageKey: string = 'chat-history'): UseChatHistoryReturn {
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
try {
localStorage.setItem(storageKey, JSON.stringify(newMessages));
} catch (error) {
console.error('Failed to save chat history:', error);
}
}, [storageKey]);
const addMessage = useCallback((role: 'user' | 'assistant', content: string) => {
const newMessage: ChatMessage = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
role,
content,
timestamp: Date.now(),
};
setMessages((prev) => {
const updated = [...prev, newMessage];
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
const clearMessages = useCallback(() => {
setMessages([]);
localStorage.removeItem(storageKey);
}, [storageKey]);
const deleteMessage = useCallback((id: string) => {
setMessages((prev) => {
const updated = prev.filter((msg) => msg.id !== id);
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
const updateMessage = useCallback((id: string, content: string) => {
setMessages((prev) => {
const updated = prev.map((msg) =>
msg.id === id ? { ...msg, content } : msg
);
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
return {
messages,
addMessage,
clearMessages,
deleteMessage,
updateMessage,
};
}
+40
View File
@@ -0,0 +1,40 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
export function useDebounceCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number = 300
): T {
const callbackRef = useRef(callback);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedCallback = useCallback((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]) as T;
return debouncedCallback;
}
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react';
export interface LlmItem {
id: string;
name: string;
}
export interface CurrentUser {
id: string;
email: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
pages_limit: number;
pages_used: number;
}
type RequestInfoLike = Parameters<typeof fetch>[0];
type RequestInitLike = Parameters<typeof fetch>[1];
export const knowledgetoolUrl = import.meta.env.VITE_AIPI_BACKEND_URL || '';
const LOGIN_URL = `${knowledgetoolUrl}/auth/jwt/login`;
const LLMS_URL = `${knowledgetoolUrl}/api/v1/expert-agents/llms`;
const ME_URL = `${knowledgetoolUrl}/users/me`;
export function useExpertAuthBootstrap() {
const [llmList, setLlmList] = useState<LlmItem[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(false);
const authedFetch = useCallback(async (input: RequestInfoLike, init?: RequestInitLike) => {
const token = localStorage.getItem('tools_token');
const headers = new Headers(init?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
}, []);
const login = useCallback(async () => {
const formData = new FormData();
formData.append('username', 'liuqiang@qq.com');
formData.append('password', '123456');
formData.append('grant_type', 'password');
const res = await fetch(LOGIN_URL, {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error(`登录失败: ${res.status} ${res.statusText}`);
}
const data = await res.json();
if (data?.access_token) {
localStorage.setItem('tools_token', data.access_token);
}
}, []);
const fetchLlmList = useCallback(async () => {
const res = await authedFetch(LLMS_URL, { method: 'GET' });
if (!res.ok) {
throw new Error(`llms 接口失败: ${res.status} ${res.statusText}`);
}
const data: unknown = await res.json();
const list = Array.isArray(data) ? (data as LlmItem[]) : [];
setLlmList(list);
return list;
}, [authedFetch]);
const fetchCurrentUser = useCallback(async () => {
const res = await authedFetch(ME_URL, { method: 'GET' });
if (!res.ok) {
// await login();
throw new Error(`users/me 接口失败: ${res.status} ${res.statusText}`);
}
const data: unknown = await res.json();
const user = (data as CurrentUser) ?? null;
setCurrentUser(user);
return user;
}, [authedFetch]);
const bootstrap = useCallback(async () => {
setLoading(true);
try {
if (!localStorage.getItem('tools_token')) {
await login();
}
await fetchCurrentUser();
} catch(error) {
console.log(error);
} finally {
await Promise.all([fetchLlmList()]);
setLoading(false);
}
}, [fetchCurrentUser, fetchLlmList, login]);
useEffect(() => {
void bootstrap().catch((error) => {
console.error('专家中心初始化失败:', error);
});
}, [bootstrap]);
return {
llmList,
currentUser,
loading,
authedFetch,
login,
fetchLlmList,
fetchCurrentUser,
bootstrap,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('Error reading from localStorage:', error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error writing to localStorage:', error);
}
};
return [storedValue, setValue] as const;
}
+860
View File
@@ -0,0 +1,860 @@
import { useState, useRef, useCallback } from 'react';
/**
* 会话初始状态
*
* status 枚举:
* - 'idle': 未启动
* - 'running': 正在运行(流式输出中)
* - 'waiting_human': 等待人工确认(仅 human_review 模式)
* - 'done': 分析完成
* - 'error': 发生错误
*
* phase 枚举:
* - 'starting': 启动阶段(第 1 轮,协调者分配初始任务)
* - 'diverging': 辩论阶段(多智能体并行输出)
* - 'closing': 收敛阶段(协调者整合草稿)
* - 'done': 完成
*/
const initialState = {
status: 'idle',
sessionId: null,
backendUrl: '',
coordinatorMode: 'auto',
phase: null,
currentRound: 0,
totalRounds: 0,
divergeRounds: 0,
reportSchema: {},
agents: [],
agentStates: {},
coordinatorUpdates: [],
latestDraft: {},
sharedMemory: null,
backgroundRetrieval: null,
finalReport: null,
finalReportPartial: {},
currentFinalField: null,
pendingReview: null,
reviewSubmitting: false,
reviewRevising: false,
errors: [],
timeline: [],
startConfig: null,
};
/**
* 规范化运行时智能体配置
*
* 将模板中的原始 agents 数组转换为运行时格式,
* 确保每个 agent 都包含所有必需字段(id、name、dimension、expertise 等)。
*
* @param {Array} agents - 原始智能体配置数组
* @param {Object} reportSchema - 报告结构定义,用于推断 relevant_fields
* @returns {Array} 规范化后的智能体数组
*/
function normalizeRuntimeAgents(agents = [], reportSchema = {}) {
const allFields = Object.keys(reportSchema || {});
return agents.map((agent, index) => {
const id = agent.id || `agent_${index + 1}`;
const description = agent.description || agent.expertise || agent.dimension || '';
const skill = agent.skill || agent.expertise || description;
return {
id,
name: agent.name || id,
description,
skill,
dimension: agent.dimension || agent.name || id,
scenario_role: agent.scenario_role || agent.expertise || description,
relevant_fields: Array.isArray(agent.relevant_fields) && agent.relevant_fields.length > 0
? agent.relevant_fields
: allFields,
analysis_contract: Array.isArray(agent.analysis_contract)
? agent.analysis_contract
: [
'输出自然语言分析,不输出 JSON。',
'充分展开材料、依据、反例、分歧和边界条件,最终收缩由协调者完成。',
],
retrieval_mode: agent.retrieval_mode || 'always',
allowed_source_ids: agent.allowed_source_ids || [],
retrieval_instruction: agent.retrieval_instruction || '优先检索与本轮任务相关的最新新闻、公开事实和可核验线索。',
};
});
}
/**
* 构建发送给后端的模板对象
*/
function buildTemplate(config) {
return {
template_id: config.template_id,
template_name: config.template_name || '',
template_brief: config.template_brief || '',
consistency_mode: config.consistency_mode || 'free',
report_schema: config.report_schema || {},
report_examples: config.report_examples || {},
field_requirements: config.field_requirements || {},
coordinator_config: config.coordinator_config || {},
agent_adaptation_policy: config.agent_adaptation_policy || {},
};
}
/**
* 构建发送给后端的完整 session 请求体
*/
function buildSessionPayload(config) {
const reportSchema = config.report_schema || {};
const reviewMode = config.coordinator_mode || config.review_mode || 'auto';
return {
task_context: config.task_context || '',
template: buildTemplate(config),
agents: normalizeRuntimeAgents(config.agents || [], reportSchema),
llm_preset_id: config.llm_preset_id || '',
phase_config: config.phase_config || { diverge_rounds: 3 },
review_mode: reviewMode,
retrieval: config.retrieval || config.retrieve,
};
}
/**
* 将后端 proposal 数据转换为前端 CoordinatorCard 所需格式
*
* @param {Object} proposal - 后端提案对象
* @param {Object} previousDraft - 上一轮的草稿(用于合并增量更新)
* @returns {Object} 前端协调者数据
*/
function proposalToCoordData(proposal, previousDraft = {}) {
const draftUpdates = proposal?.draft_updates || {};
return {
round: proposal?.round_index,
divergeRound: proposal?.diverge_round,
totalDivergeRounds: proposal?.total_diverge_rounds,
nextSpeakers: proposal?.next_speakers || [],
focus: proposal?.focus || {},
draft: { ...previousDraft, ...draftUpdates },
draftUpdates,
gaps: proposal?.gaps || [],
fieldAssessment: proposal?.field_assessment || {},
sharedMemory: proposal?.shared_memory || null,
reasoning: proposal?.reasoning || '',
proposal,
};
}
/**
* 解析 SSE (Server-Sent Events) 数据行
*
* 格式: "data: {...}" 或 "data: [DONE]"
* @param {string} line - SSE 行文本
* @returns {Object|null} 解析后的 JSON 对象,无效行返回 null
*/
function parseSsePayload(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) return null;
const jsonStr = trimmed.slice(5).trim();
if (!jsonStr || jsonStr === '[DONE]') return null;
return JSON.parse(jsonStr);
}
/**
* useSession hook
*
* 管理整个分析会话的生命周期:
* - startSession: 发送 POST /sessions/start 创建会话,然后连接 SSE 事件流
* - stopSession: 中断当前连接
* - reset: 重置所有状态
* - approveCoordinator / reviseCoordinator: 人工确认 / 修改提案
*
* SSE 事件类型及处理:
* - session_started: 初始化阶段、智能体、轮次
* - agent_started: 在 timeline 中插入 agent_contribution(streaming=true)
* - agent_token: 追加 token 到对应 agent_contribution 的 content/thinking
* - agent_done: 将对应 agent_contribution 的 streaming 标记为 false
* - coordinator_started: 插入 coordinator_streaming 卡片
* - coordinator_token: 追加 coordinator 的思考过程或内容 token
* - coordinator_status: 更新状态横幅
* - coordinator_proposal: 替换 coordinator_streaming 为 coordinator 完成卡片
* - human_review_required: 切换状态为 waiting_human
* - final_report_token: 追加最终报告 token
* - final_report_done: 插入 final_report 卡片,状态切换为 done
* - error: 追加错误到 errors 数组
*
* @returns {Object} 会话状态 + 操作方法
*/
export function useSession() {
const [state, setState] = useState(initialState);
const abortControllerRef = useRef(null);
const timelineRef = useRef([]); // 用 ref 保存 timeline,避免闭包过期
const configRef = useRef(null); // 保存启动时的配置引用
/** 重置所有状态 */
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
timelineRef.current = [];
configRef.current = null;
setState(initialState);
}, []);
/**
* 从历史记录 JSON 恢复会话展示状态。
*
* 后端历史接口未来会直接返回保存时的 snapshot;这里把 snapshot 写回 state 和 timelineRef,
* 让用户进入页面后无需重新生成,也能直接看到 Timeline、左右侧进度和最终报告。
*/
const hydrateFromSnapshot = useCallback((snapshot = {}) => {
const nextTimeline = Array.isArray(snapshot.timeline) ? snapshot.timeline : [];
timelineRef.current = nextTimeline;
configRef.current = snapshot.config || null;
setState({
...initialState,
...snapshot,
status: snapshot.status || (nextTimeline.length > 0 ? 'done' : 'idle'),
phase: snapshot.phase || (nextTimeline.length > 0 ? 'done' : null),
timeline: nextTimeline,
errors: Array.isArray(snapshot.errors) ? snapshot.errors : [],
agentStates: snapshot.agentStates || {},
agents: Array.isArray(snapshot.agents) ? snapshot.agents : [],
reportSchema: snapshot.reportSchema || snapshot.config?.report_schema || {},
finalReportPartial: snapshot.finalReportPartial || snapshot.finalReport?.report || {},
pendingReview: null,
reviewSubmitting: false,
reviewRevising: false,
});
}, []);
/** 停止当前会话(中断 SSE 连接) */
const stopSession = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setState(prev => ({ ...prev, status: 'idle' }));
}, []);
// 设置模板信息用于开启会话
const configStateChange = useCallback((config) => {
setState(prev => ({ ...prev, startConfig: config }));
},[])
/**
* SSE 事件处理器
*
* 根据 event 类型分发处理,修改 timelineRef 和 state。
* 使用 timelineRef(而非 state.timeline)进行链表式修改,
* 避免在快速 token 到来时因 React 批量更新导致丢帧。
*/
const handleEvent = useCallback((parsed, backendUrl) => {
const { event, data } = parsed;
if (!event) return;
switch (event) {
// ====== 会话启动 ======
case 'session_started': {
const agents = data.agents || [];
const agentStates = {};
agents.forEach(agent => {
agentStates[agent.id] = {
id: agent.id,
name: agent.name,
dimension: agent.dimension,
status: 'idle',
rounds: [],
};
});
setState(prev => ({
...prev,
sessionId: prev.sessionId,
backendUrl,
phase: 'starting',
agents,
agentStates,
totalRounds: (configRef.current?.phase_config?.diverge_rounds || 3) + 1,
divergeRounds: configRef.current?.phase_config?.diverge_rounds || 3,
reportSchema: configRef.current?.report_schema || {},
}));
break;
}
// ====== 智能体开始输出 ======
case 'agent_started': {
const agentId = data.agent_id;
const round = data.round_index || 0;
const phase = data.phase || 'starting';
// 检查是否已有该轮次的 round_start,没有则插入
const hasRoundStart = timelineRef.current.some(
item => item.type === 'round_start' && item.round === round && item.phase === phase
);
if (!hasRoundStart) {
timelineRef.current = [
...timelineRef.current,
{ type: 'round_start', round, phase, agentIds: [agentId] },
];
} else {
timelineRef.current = timelineRef.current.map(item =>
item.type === 'round_start' && item.round === round && item.phase === phase
? { ...item, agentIds: [...new Set([...(item.agentIds || []), agentId])] }
: item
);
}
// 插入流式 agent_contribution
timelineRef.current = [
...timelineRef.current,
{
type: 'agent_contribution',
agentId,
round,
phase,
streaming: true,
data: {
round,
phase,
content: '',
thinking: '',
retrievalLog: [],
retrievalCount: 0,
referenceMap: {},
},
},
];
setState(prev => {
const updatedAgentStates = { ...prev.agentStates };
updatedAgentStates[agentId] = {
...(updatedAgentStates[agentId] || { id: agentId, name: data.agent_name, dimension: agentId, rounds: [] }),
status: 'thinking',
};
return {
...prev,
phase,
currentRound: Math.max(prev.currentRound, round),
agentStates: updatedAgentStates,
timeline: timelineRef.current,
};
});
break;
}
// ====== 智能体完成输出 ======
case 'agent_done': {
const agentId = data.agent_id;
const round = data.round_index || 0;
const phase = data.phase || 'starting';
const retrievalLog = data.retrieval_log || [];
const roundData = {
round,
phase,
content: data.content || '',
thinking: data.thinking || '',
retrievalLog,
retrievalCount: retrievalLog.length,
referenceMap: data.reference_map || {},
metadata: data.metadata || {},
};
// 将对应的流式条目转为完成状态
timelineRef.current = timelineRef.current.map(item =>
item.type === 'agent_contribution' && item.agentId === agentId && item.round === round && item.streaming
? { ...item, streaming: false, data: roundData }
: item
);
setState(prev => {
const updatedAgentStates = { ...prev.agentStates };
const existing = updatedAgentStates[agentId] || {
id: agentId,
name: data.agent_name,
dimension: agentId,
status: 'idle',
rounds: [],
};
updatedAgentStates[agentId] = {
...existing,
name: data.agent_name,
status: 'done',
rounds: [...(existing.rounds || []), roundData],
};
return { ...prev, agentStates: updatedAgentStates, timeline: timelineRef.current };
});
break;
}
// ====== 智能体 token(流式追加) ======
case 'agent_token': {
const agentId = data.agent_id;
const round = data.round_index || 0;
const token = data.token || '';
const tokenType = data.token_type || 'content';
// 找到对应的 streaming agent_contribution 并追加 token
timelineRef.current = timelineRef.current.map(item => {
if (item.type !== 'agent_contribution' || item.agentId !== agentId || item.round !== round || !item.streaming) {
return item;
}
if (tokenType === 'thinking') {
return { ...item, data: { ...item.data, thinking: (item.data.thinking || '') + token } };
}
return { ...item, data: { ...item.data, content: (item.data.content || '') + token } };
});
setState(prev => ({ ...prev, timeline: timelineRef.current }));
break;
}
// ====== 协调员开始流式输出 ======
case 'coordinator_started': {
timelineRef.current = [
...timelineRef.current,
{
type: 'coordinator_streaming',
phase: data.phase || 'diverging',
divergeRound: data.diverge_round,
totalDivergeRounds: data.total_diverge_rounds,
thinking: '',
content: '协调者正在评估字段完成度、更新草稿并选择下一轮发言者。',
statusMessage: '协调草案生成中…',
currentField: null,
reportPartial: {},
},
];
setState(prev => ({ ...prev, phase: data.phase || prev.phase, timeline: timelineRef.current }));
break;
}
// ====== 协调员 token(流式追加) ======
case 'coordinator_token': {
const token = data.token || '';
const tokenType = data.token_type || 'thinking';
const phase = data.phase || 'diverging';
// 找到最后一个 coordinator_streaming 项并追加
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
item.type === 'coordinator_streaming' ? idx : last
), -1);
if (lastIdx === -1) {
// 尚未创建,新建一个
timelineRef.current = [
...timelineRef.current,
{
type: 'coordinator_streaming',
phase,
divergeRound: data.diverge_round,
totalDivergeRounds: data.total_diverge_rounds,
thinking: tokenType === 'thinking' ? token : '',
content: tokenType === 'content' ? token : '',
statusMessage: '',
currentField: data.field || null,
reportPartial: {},
},
];
} else {
timelineRef.current = timelineRef.current.map((item, idx) => {
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
return {
...item,
currentField: data.field || item.currentField,
thinking: tokenType === 'thinking' ? (item.thinking || '') + token : item.thinking,
content: tokenType === 'content' ? (item.content || '') + token : item.content,
statusMessage: '',
};
});
}
setState(prev => ({ ...prev, timeline: timelineRef.current }));
break;
}
// ====== 协调员状态更新 ======
case 'coordinator_status': {
const message = data.message || '';
const phase = data.phase || 'diverging';
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
item.type === 'coordinator_streaming' ? idx : last
), -1);
if (lastIdx === -1) {
timelineRef.current = [
...timelineRef.current,
{
type: 'coordinator_streaming',
phase,
divergeRound: data.diverge_round,
totalDivergeRounds: data.total_diverge_rounds,
thinking: '',
content: '',
statusMessage: message,
currentField: null,
reportPartial: {},
},
];
} else {
timelineRef.current = timelineRef.current.map((item, idx) => {
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
return { ...item, statusMessage: message };
});
}
setState(prev => ({ ...prev, timeline: timelineRef.current }));
break;
}
// ====== 协调员提案完成 ======
case 'coordinator_proposal': {
setState(prev => {
const coordData = proposalToCoordData(data.proposal, prev.latestDraft);
let replaced = false;
// 将最后一个 coordinator_streaming 替换为 coordinator 完成卡片
timelineRef.current = timelineRef.current.map((item, idx) => {
if (item.type !== 'coordinator_streaming' || replaced) return item;
const lastIdx = timelineRef.current.reduce((last, it, i) => it.type === 'coordinator_streaming' ? i : last, -1);
if (idx !== lastIdx) return item;
replaced = true;
return {
type: 'coordinator',
data: coordData,
thinking: coordData.reasoning,
};
});
if (!replaced) {
timelineRef.current = [...timelineRef.current, { type: 'coordinator', data: coordData, thinking: coordData.reasoning }];
}
return {
...prev,
status: data.revised ? 'running' : prev.status,
coordinatorUpdates: [...prev.coordinatorUpdates, coordData],
latestDraft: coordData.draft,
sharedMemory: coordData.sharedMemory,
pendingReview: data.revised ? null : prev.pendingReview,
reviewRevising: false,
timeline: timelineRef.current,
};
});
break;
}
// ====== 需要人工确认(仅 human_review 模式) ======
case 'human_review_required': {
setState(prev => {
const coordData = proposalToCoordData(data.proposal, prev.latestDraft);
return {
...prev,
status: 'waiting_human',
pendingReview: {
...coordData,
proposal: data.proposal,
},
sharedMemory: coordData.sharedMemory,
reviewSubmitting: false,
reviewRevising: false,
};
});
break;
}
// ====== 最终报告生成完成 ======
case 'final_report_done': {
const output = {
report: data.report || {},
dimensions: data.dimensions || {},
meta: data.metadata || {},
};
timelineRef.current = [...timelineRef.current, { type: 'final_report', data: output }];
setState(prev => ({
...prev,
status: 'done',
finalReport: output,
finalReportPartial: output.report || {},
currentFinalField: null,
phase: 'done',
pendingReview: null,
reviewSubmitting: false,
reviewRevising: false,
timeline: timelineRef.current,
}));
break;
}
// ====== 最终报告 token(流式追加) ======
case 'final_report_token': {
const token = data.token || '';
const field = data.field || null;
const reportPartial = data.report_partial || {};
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
item.type === 'coordinator_streaming' ? idx : last
), -1);
if (lastIdx === -1) {
timelineRef.current = [
...timelineRef.current,
{
type: 'coordinator_streaming',
phase: 'final_report',
thinking: '',
content: token,
statusMessage: '',
currentField: field,
reportPartial,
},
];
} else {
timelineRef.current = timelineRef.current.map((item, idx) => {
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
return {
...item,
phase: 'final_report',
content: (item.content || '') + token,
currentField: field || item.currentField,
reportPartial,
};
});
}
setState(prev => ({
...prev,
finalReportPartial: reportPartial,
currentFinalField: field,
timeline: timelineRef.current,
}));
break;
}
// ====== 错误 ======
case 'error': {
setState(prev => ({
...prev,
status: prev.status === 'waiting_human' ? prev.status : 'error',
errors: [...prev.errors, { error: data.message || data.error || JSON.stringify(data), ...data }],
}));
break;
}
default:
break;
}
}, []);
/**
* 读取 SSE 事件流
*
* 使用 fetch + ReadableStream reader 逐行解析,
* 支持 AbortController 中断。
*
* @param {string} url - SSE 端点 URL
* @param {string} backendUrl - 后端基础地址
* @param {AbortController} abortController - 中断控制器
*/
const readEventStream = useCallback(async (url, backendUrl, abortController) => {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done || abortController.signal.aborted) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
try {
const parsed = parseSsePayload(line);
if (parsed) handleEvent(parsed, backendUrl);
} catch (error) {
console.warn('Failed to parse SSE line:', line, error);
}
}
}
}, [handleEvent]);
/**
* 启动分析会话
*
* 1. 发送 POST /sessions/start 创建会话
* 2. 获取 session_id
* 3. 通过 GET /sessions/{id}/events 订阅 SSE 事件流
*
* @param {Object} config - 页面配置(任务背景、模板、智能体等)
* @param {string} backendUrl - 后端 API 地址
*/
const startSession = useCallback(async (config, backendUrl) => {
timelineRef.current = [];
configRef.current = config;
setState({
...initialState,
status: 'running',
backendUrl,
coordinatorMode: config.coordinator_mode || 'auto',
reportSchema: config.report_schema || {},
});
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const payload = buildSessionPayload(config);
const response = await fetch(`${backendUrl}/sessions/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: abortController.signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
const result = await response.json();
const sessionId = result.session_id;
setState(prev => ({
...prev,
sessionId,
status: result.status === 'created' ? 'running' : result.status,
}));
await readEventStream(`${backendUrl}/sessions/${sessionId}/events`, backendUrl, abortController);
// SSE 流结束后,设置最终状态(除非已是 error/waiting_human/done)
setState(prev => ({
...prev,
status: prev.status === 'error' || prev.status === 'waiting_human' || prev.status === 'done'
? prev.status
: 'done',
}));
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Session error:', err);
setState(prev => ({
...prev,
status: 'error',
errors: [...prev.errors, { error: err.message }],
}));
}
}, [readEventStream]);
/**
* 人工确认协调者提案
*
* 在 human_review 模式下,用户修改提案内容后提交确认。
* 发送 POST /sessions/{id}/coordinator/approve。
*
* @param {Object} payload - { nextSpeakers, focus, draft, gaps }
*/
const approveCoordinator = useCallback(async (payload) => {
if (!state.sessionId || !state.backendUrl) {
throw new Error('session 未初始化');
}
const baseProposal = state.pendingReview?.proposal || {};
const proposal = {
...baseProposal,
next_speakers: payload.nextSpeakers,
focus: payload.focus,
draft_updates: payload.draft,
gaps: payload.gaps,
};
setState(prev => ({ ...prev, reviewSubmitting: true }));
try {
const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ proposal }),
});
if (!response.ok) {
const msg = await response.text();
throw new Error(msg || `HTTP ${response.status}`);
}
const result = await response.json();
setState(prev => ({
...prev,
status: 'running',
pendingReview: null,
reviewSubmitting: false,
}));
} catch (err) {
console.error('approve error:', err);
setState(prev => ({
...prev,
reviewSubmitting: false,
errors: [...prev.errors, { error: err.message }],
}));
}
}, [state.sessionId, state.backendUrl, state.pendingReview]);
/**
* 发送修改指令,让协调员重新生成提案
*
* @param {string} instruction - 用户的修改指令(自然语言)
*/
const reviseCoordinator = useCallback(async (instruction) => {
if (!state.sessionId || !state.backendUrl) {
throw new Error('session 未初始化');
}
setState(prev => ({ ...prev, reviewRevising: true }));
try {
const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/revise`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruction }),
});
if (!response.ok) {
const msg = await response.text();
throw new Error(msg || `HTTP ${response.status}`);
}
// 后端会在修订完成后通过 SSE 推送新的 coordinator_proposal
setState(prev => ({
...prev,
reviewRevising: false,
pendingReview: null,
status: 'running',
}));
} catch (err) {
console.error('revise error:', err);
setState(prev => ({
...prev,
reviewRevising: false,
errors: [...prev.errors, { error: err.message }],
}));
}
}, [state.sessionId, state.backendUrl]);
return {
...state,
hydrateFromSnapshot,
reset,
startSession,
stopSession,
approveCoordinator,
reviseCoordinator,
configStateChange,
};
}
+15
View File
@@ -0,0 +1,15 @@
import { useState, useCallback } from 'react';
export function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
const [value, setValue] = useState<boolean>(initialValue);
const toggle = useCallback(() => {
setValue((prev) => !prev);
}, []);
const setToggle = useCallback((newValue: boolean) => {
setValue(newValue);
}, []);
return [value, toggle, setToggle];
}
+32
View File
@@ -0,0 +1,32 @@
import { useState, useEffect, useCallback } from 'react';
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize() {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: window.innerWidth,
height: window.innerHeight,
});
const handleResize = useCallback(() => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
return windowSize;
}
export function useIsMobile() {
const { width } = useWindowSize();
return width < 768;
}